crates/q_chat/src/tools/fs_write.rs (804 lines of code) (raw):
use std::io::Write;
use std::path::Path;
use std::sync::LazyLock;
use crossterm::queue;
use crossterm::style::{
self,
Color,
};
use eyre::{
ContextCompat as _,
Result,
bail,
eyre,
};
use fig_os_shim::Context;
use serde::Deserialize;
use similar::DiffableStr;
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::{
LinesWithEndings,
as_24_bit_terminal_escaped,
};
use tracing::{
error,
warn,
};
use super::{
InvokeOutput,
format_path,
sanitize_path_tool_arg,
supports_truecolor,
};
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "command")]
pub enum FsWrite {
/// The tool spec should only require `file_text`, but the model sometimes doesn't want to
/// provide it. Thus, including `new_str` as a fallback check, if it's available.
#[serde(rename = "create")]
Create {
path: String,
file_text: Option<String>,
new_str: Option<String>,
},
#[serde(rename = "str_replace")]
StrReplace {
path: String,
old_str: String,
new_str: String,
},
#[serde(rename = "insert")]
Insert {
path: String,
insert_line: usize,
new_str: String,
},
#[serde(rename = "append")]
Append { path: String, new_str: String },
}
impl FsWrite {
pub async fn invoke(&self, ctx: &Context, updates: &mut impl Write) -> Result<InvokeOutput> {
let fs = ctx.fs();
let cwd = ctx.env().current_dir()?;
match self {
FsWrite::Create { path, .. } => {
let file_text = self.canonical_create_command_text();
let path = sanitize_path_tool_arg(ctx, path);
if let Some(parent) = path.parent() {
fs.create_dir_all(parent).await?;
}
let invoke_description = if fs.exists(&path) { "Replacing: " } else { "Creating: " };
queue!(
updates,
style::Print(invoke_description),
style::SetForegroundColor(Color::Green),
style::Print(format_path(cwd, &path)),
style::ResetColor,
style::Print("\n"),
)?;
write_to_file(ctx, path, file_text).await?;
Ok(Default::default())
},
FsWrite::StrReplace { path, old_str, new_str } => {
let path = sanitize_path_tool_arg(ctx, path);
let file = fs.read_to_string(&path).await?;
let matches = file.match_indices(old_str).collect::<Vec<_>>();
queue!(
updates,
style::Print("Updating: "),
style::SetForegroundColor(Color::Green),
style::Print(format_path(cwd, &path)),
style::ResetColor,
style::Print("\n"),
)?;
match matches.len() {
0 => Err(eyre!("no occurrences of \"{old_str}\" were found")),
1 => {
let file = file.replacen(old_str, new_str, 1);
fs.write(path, file).await?;
Ok(Default::default())
},
x => Err(eyre!("{x} occurrences of old_str were found when only 1 is expected")),
}
},
FsWrite::Insert {
path,
insert_line,
new_str,
} => {
let path = sanitize_path_tool_arg(ctx, path);
let mut file = fs.read_to_string(&path).await?;
queue!(
updates,
style::Print("Updating: "),
style::SetForegroundColor(Color::Green),
style::Print(format_path(cwd, &path)),
style::ResetColor,
style::Print("\n"),
)?;
// Get the index of the start of the line to insert at.
let num_lines = file.lines().enumerate().map(|(i, _)| i + 1).last().unwrap_or(1);
let insert_line = insert_line.clamp(&0, &num_lines);
let mut i = 0;
for _ in 0..*insert_line {
let line_len = &file[i..].find("\n").map_or(file[i..].len(), |i| i + 1);
i += line_len;
}
file.insert_str(i, new_str);
write_to_file(ctx, &path, file).await?;
Ok(Default::default())
},
FsWrite::Append { path, new_str } => {
let path = sanitize_path_tool_arg(ctx, path);
queue!(
updates,
style::Print("Appending to: "),
style::SetForegroundColor(Color::Green),
style::Print(format_path(cwd, &path)),
style::ResetColor,
style::Print("\n"),
)?;
let mut file = fs.read_to_string(&path).await?;
if !file.ends_with_newline() {
file.push('\n');
}
file.push_str(new_str);
write_to_file(ctx, path, file).await?;
Ok(Default::default())
},
}
}
pub fn queue_description(&self, ctx: &Context, updates: &mut impl Write) -> Result<()> {
let cwd = ctx.env().current_dir()?;
self.print_relative_path(ctx, updates)?;
match self {
FsWrite::Create { path, .. } => {
let file_text = self.canonical_create_command_text();
let relative_path = format_path(cwd, path);
let prev = if ctx.fs().exists(path) {
let file = ctx.fs().read_to_string_sync(path)?;
stylize_output_if_able(ctx, path, &file)
} else {
Default::default()
};
let new = stylize_output_if_able(ctx, &relative_path, &file_text);
print_diff(updates, &prev, &new, 1)?;
Ok(())
},
FsWrite::Insert {
path,
insert_line,
new_str,
} => {
let relative_path = format_path(cwd, path);
let file = ctx.fs().read_to_string_sync(&relative_path)?;
// Diff the old with the new by adding extra context around the line being inserted
// at.
let (prefix, start_line, suffix, _) = get_lines_with_context(&file, *insert_line, *insert_line, 3);
let insert_line_content = LinesWithEndings::from(&file)
// don't include any content if insert_line is 0
.nth(insert_line.checked_sub(1).unwrap_or(usize::MAX))
.unwrap_or_default();
let old = [prefix, insert_line_content, suffix].join("");
let new = [prefix, insert_line_content, new_str, suffix].join("");
let old = stylize_output_if_able(ctx, &relative_path, &old);
let new = stylize_output_if_able(ctx, &relative_path, &new);
print_diff(updates, &old, &new, start_line)?;
Ok(())
},
FsWrite::StrReplace { path, old_str, new_str } => {
let relative_path = format_path(cwd, path);
let file = ctx.fs().read_to_string_sync(&relative_path)?;
let (start_line, _) = match line_number_at(&file, old_str) {
Some((start_line, end_line)) => (start_line, end_line),
_ => (0, 0),
};
let old_str = stylize_output_if_able(ctx, &relative_path, old_str);
let new_str = stylize_output_if_able(ctx, &relative_path, new_str);
print_diff(updates, &old_str, &new_str, start_line)?;
Ok(())
},
FsWrite::Append { path, new_str } => {
let relative_path = format_path(cwd, path);
let start_line = ctx.fs().read_to_string_sync(&relative_path)?.lines().count() + 1;
let file = stylize_output_if_able(ctx, &relative_path, new_str);
print_diff(updates, &Default::default(), &file, start_line)?;
Ok(())
},
}
}
pub async fn validate(&mut self, ctx: &Context) -> Result<()> {
match self {
FsWrite::Create { path, .. } => {
if path.is_empty() {
bail!("Path must not be empty")
};
},
FsWrite::StrReplace { path, .. } | FsWrite::Insert { path, .. } => {
let path = sanitize_path_tool_arg(ctx, path);
if !path.exists() {
bail!("The provided path must exist in order to replace or insert contents into it")
}
},
FsWrite::Append { path, new_str } => {
if path.is_empty() {
bail!("Path must not be empty")
};
if new_str.is_empty() {
bail!("Content to append must not be empty")
};
},
}
Ok(())
}
fn print_relative_path(&self, ctx: &Context, updates: &mut impl Write) -> Result<()> {
let cwd = ctx.env().current_dir()?;
let path = match self {
FsWrite::Create { path, .. } => path,
FsWrite::StrReplace { path, .. } => path,
FsWrite::Insert { path, .. } => path,
FsWrite::Append { path, .. } => path,
};
let relative_path = format_path(cwd, path);
queue!(
updates,
style::Print("Path: "),
style::SetForegroundColor(Color::Green),
style::Print(&relative_path),
style::ResetColor,
style::Print("\n\n"),
)?;
Ok(())
}
/// Returns the text to use for the [FsWrite::Create] command. This is required since we can't
/// rely on the model always providing `file_text`.
fn canonical_create_command_text(&self) -> String {
match self {
FsWrite::Create { file_text, new_str, .. } => match (file_text, new_str) {
(Some(file_text), _) => file_text.clone(),
(None, Some(new_str)) => {
warn!("required field `file_text` is missing, using the provided `new_str` instead");
new_str.clone()
},
_ => {
warn!("no content provided for the create command");
String::new()
},
},
_ => String::new(),
}
}
}
/// Writes `content` to `path`, adding a newline if necessary.
async fn write_to_file(ctx: &Context, path: impl AsRef<Path>, mut content: String) -> Result<()> {
if !content.ends_with_newline() {
content.push('\n');
}
ctx.fs().write(path.as_ref(), content).await?;
Ok(())
}
/// Returns a prefix/suffix pair before and after the content dictated by `[start_line, end_line]`
/// within `content`. The updated start and end lines containing the original context along with
/// the suffix and prefix are returned.
///
/// Params:
/// - `start_line` - 1-indexed starting line of the content.
/// - `end_line` - 1-indexed ending line of the content.
/// - `context_lines` - number of lines to include before the start and end.
///
/// Returns `(prefix, new_start_line, suffix, new_end_line)`
fn get_lines_with_context(
content: &str,
start_line: usize,
end_line: usize,
context_lines: usize,
) -> (&str, usize, &str, usize) {
let line_count = content.lines().count();
// We want to support end_line being 0, in which case we should be able to set the first line
// as the suffix.
let zero_check_inc = if end_line == 0 { 0 } else { 1 };
// Convert to 0-indexing.
let (start_line, end_line) = (
start_line.saturating_sub(1).clamp(0, line_count - 1),
end_line.saturating_sub(1).clamp(0, line_count - 1),
);
let new_start_line = 0.max(start_line.saturating_sub(context_lines));
let new_end_line = (line_count - 1).min(end_line + context_lines);
// Build prefix
let mut prefix_start = 0;
for line in LinesWithEndings::from(content).take(new_start_line) {
prefix_start += line.len();
}
let mut prefix_end = prefix_start;
for line in LinesWithEndings::from(&content[prefix_start..]).take(start_line - new_start_line) {
prefix_end += line.len();
}
// Build suffix
let mut suffix_start = 0;
for line in LinesWithEndings::from(content).take(end_line + zero_check_inc) {
suffix_start += line.len();
}
let mut suffix_end = suffix_start;
for line in LinesWithEndings::from(&content[suffix_start..]).take(new_end_line - end_line) {
suffix_end += line.len();
}
(
&content[prefix_start..prefix_end],
new_start_line + 1,
&content[suffix_start..suffix_end],
new_end_line + zero_check_inc,
)
}
/// Prints a git-diff style comparison between `old_str` and `new_str`.
/// - `start_line` - 1-indexed line number that `old_str` and `new_str` start at.
fn print_diff(
updates: &mut impl Write,
old_str: &StylizedFile,
new_str: &StylizedFile,
start_line: usize,
) -> Result<()> {
let diff = similar::TextDiff::from_lines(&old_str.content, &new_str.content);
// First, get the gutter width required for both the old and new lines.
let (mut max_old_i, mut max_new_i) = (1, 1);
for change in diff.iter_all_changes() {
if let Some(i) = change.old_index() {
max_old_i = i + start_line;
}
if let Some(i) = change.new_index() {
max_new_i = i + start_line;
}
}
let old_line_num_width = terminal_width_required_for_line_count(max_old_i);
let new_line_num_width = terminal_width_required_for_line_count(max_new_i);
// Now, print
fn fmt_index(i: Option<usize>, start_line: usize) -> String {
match i {
Some(i) => (i + start_line).to_string(),
_ => " ".to_string(),
}
}
for change in diff.iter_all_changes() {
// Define the colors per line.
let (text_color, gutter_bg_color, line_bg_color) = match (change.tag(), new_str.truecolor) {
(similar::ChangeTag::Equal, true) => (style::Color::Reset, new_str.gutter_bg, new_str.line_bg),
(similar::ChangeTag::Delete, true) => (
style::Color::Reset,
style::Color::Rgb { r: 79, g: 40, b: 40 },
style::Color::Rgb { r: 36, g: 25, b: 28 },
),
(similar::ChangeTag::Insert, true) => (
style::Color::Reset,
style::Color::Rgb { r: 40, g: 67, b: 43 },
style::Color::Rgb { r: 24, g: 38, b: 30 },
),
(similar::ChangeTag::Equal, false) => (style::Color::Reset, new_str.gutter_bg, new_str.line_bg),
(similar::ChangeTag::Delete, false) => (style::Color::Red, new_str.gutter_bg, new_str.line_bg),
(similar::ChangeTag::Insert, false) => (style::Color::Green, new_str.gutter_bg, new_str.line_bg),
};
// Define the change tag character to print, if any.
let sign = match change.tag() {
similar::ChangeTag::Equal => " ",
similar::ChangeTag::Delete => "-",
similar::ChangeTag::Insert => "+",
};
let old_i_str = fmt_index(change.old_index(), start_line);
let new_i_str = fmt_index(change.new_index(), start_line);
// Print the gutter and line numbers.
queue!(updates, style::SetBackgroundColor(gutter_bg_color))?;
queue!(
updates,
style::SetForegroundColor(text_color),
style::Print(sign),
style::Print(" ")
)?;
queue!(
updates,
style::Print(format!(
"{:>old_line_num_width$}",
old_i_str,
old_line_num_width = old_line_num_width
))
)?;
if sign == " " {
queue!(updates, style::Print(", "))?;
} else {
queue!(updates, style::Print(" "))?;
}
queue!(
updates,
style::Print(format!(
"{:>new_line_num_width$}",
new_i_str,
new_line_num_width = new_line_num_width
))
)?;
// Print the line.
queue!(
updates,
style::SetForegroundColor(style::Color::Reset),
style::Print(":"),
style::SetForegroundColor(text_color),
style::SetBackgroundColor(line_bg_color),
style::Print(" "),
style::Print(change),
style::ResetColor,
)?;
}
queue!(
updates,
crossterm::terminal::Clear(crossterm::terminal::ClearType::UntilNewLine),
style::Print("\n"),
)?;
Ok(())
}
/// Returns a 1-indexed line number range of the start and end of `needle` inside `file`.
fn line_number_at(file: impl AsRef<str>, needle: impl AsRef<str>) -> Option<(usize, usize)> {
let file = file.as_ref();
let needle = needle.as_ref();
if let Some((i, _)) = file.match_indices(needle).next() {
let start = file[..i].matches("\n").count();
let end = needle.matches("\n").count();
Some((start + 1, start + end + 1))
} else {
None
}
}
/// Returns the number of terminal cells required for displaying line numbers. This is used to
/// determine how many characters the gutter should allocate when displaying line numbers for a
/// text file.
///
/// For example, `10` and `99` both take 2 cells, whereas `100` and `999` take 3.
fn terminal_width_required_for_line_count(line_count: usize) -> usize {
line_count.to_string().chars().count()
}
fn stylize_output_if_able(ctx: &Context, path: impl AsRef<Path>, file_text: &str) -> StylizedFile {
if supports_truecolor(ctx) {
match stylized_file(path, file_text) {
Ok(s) => return s,
Err(err) => {
error!(?err, "unable to syntax highlight the output");
},
}
}
StylizedFile {
truecolor: false,
content: file_text.to_string(),
gutter_bg: style::Color::Reset,
line_bg: style::Color::Reset,
}
}
/// Represents a [String] that is potentially stylized with truecolor escape codes.
#[derive(Debug)]
struct StylizedFile {
/// Whether or not the file is stylized with 24bit color.
truecolor: bool,
/// File content. If [Self::truecolor] is true, then it has escape codes for styling with 24bit
/// color.
content: String,
/// Background color for the gutter.
gutter_bg: style::Color,
/// Background color for the line content.
line_bg: style::Color,
}
impl Default for StylizedFile {
fn default() -> Self {
Self {
truecolor: false,
content: Default::default(),
gutter_bg: style::Color::Reset,
line_bg: style::Color::Reset,
}
}
}
/// Returns a 24bit terminal escaped syntax-highlighted [String] of the file pointed to by `path`,
/// if able.
fn stylized_file(path: impl AsRef<Path>, file_text: impl AsRef<str>) -> Result<StylizedFile> {
let ps = &*SYNTAX_SET;
let ts = &*THEME_SET;
let extension = path
.as_ref()
.extension()
.wrap_err("missing extension")?
.to_str()
.wrap_err("not utf8")?;
let syntax = ps
.find_syntax_by_extension(extension)
.wrap_err_with(|| format!("missing extension: {}", extension))?;
let theme = &ts.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
let file_text = file_text.as_ref().lines();
let mut file = String::new();
for line in file_text {
let mut ranges = Vec::new();
ranges.append(&mut highlighter.highlight_line(line, ps)?);
let mut escaped_line = as_24_bit_terminal_escaped(&ranges[..], false);
escaped_line.push_str(&format!(
"{}\n",
crossterm::terminal::Clear(crossterm::terminal::ClearType::UntilNewLine),
));
file.push_str(&escaped_line);
}
let (line_bg, gutter_bg) = match (theme.settings.background, theme.settings.gutter) {
(Some(line_bg), Some(gutter_bg)) => (line_bg, gutter_bg),
(Some(line_bg), None) => (line_bg, line_bg),
_ => bail!("missing theme"),
};
Ok(StylizedFile {
truecolor: true,
content: file,
gutter_bg: syntect_to_crossterm_color(gutter_bg),
line_bg: syntect_to_crossterm_color(line_bg),
})
}
fn syntect_to_crossterm_color(syntect: syntect::highlighting::Color) -> style::Color {
style::Color::Rgb {
r: syntect.r,
g: syntect.g,
b: syntect.b,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
const TEST_FILE_CONTENTS: &str = "\
1: Hello world!
2: This is line 2
3: asdf
4: Hello world!
";
const TEST_FILE_PATH: &str = "/test_file.txt";
const TEST_HIDDEN_FILE_PATH: &str = "/aaaa2/.hidden";
/// Sets up the following filesystem structure:
/// ```text
/// test_file.txt
/// /home/testuser/
/// /aaaa1/
/// /bbbb1/
/// /cccc1/
/// /aaaa2/
/// .hidden
/// ```
async fn setup_test_directory() -> Arc<Context> {
let ctx = Context::builder().with_test_home().await.unwrap().build_fake();
let fs = ctx.fs();
fs.write(TEST_FILE_PATH, TEST_FILE_CONTENTS).await.unwrap();
fs.create_dir_all("/aaaa1/bbbb1/cccc1").await.unwrap();
fs.create_dir_all("/aaaa2").await.unwrap();
fs.write(TEST_HIDDEN_FILE_PATH, "this is a hidden file").await.unwrap();
ctx
}
#[test]
fn test_fs_write_deserialize() {
let path = "/my-file";
let file_text = "hello world";
// create
let v = serde_json::json!({
"path": path,
"command": "create",
"file_text": file_text
});
let fw = serde_json::from_value::<FsWrite>(v).unwrap();
assert!(matches!(fw, FsWrite::Create { .. }));
// str_replace
let v = serde_json::json!({
"path": path,
"command": "str_replace",
"old_str": "prev string",
"new_str": "new string",
});
let fw = serde_json::from_value::<FsWrite>(v).unwrap();
assert!(matches!(fw, FsWrite::StrReplace { .. }));
// insert
let v = serde_json::json!({
"path": path,
"command": "insert",
"insert_line": 3,
"new_str": "new string",
});
let fw = serde_json::from_value::<FsWrite>(v).unwrap();
assert!(matches!(fw, FsWrite::Insert { .. }));
// append
let v = serde_json::json!({
"path": path,
"command": "append",
"new_str": "appended content",
});
let fw = serde_json::from_value::<FsWrite>(v).unwrap();
assert!(matches!(fw, FsWrite::Append { .. }));
}
#[tokio::test]
async fn test_fs_write_tool_create() {
let ctx = setup_test_directory().await;
let mut stdout = std::io::stdout();
let file_text = "Hello, world!";
let v = serde_json::json!({
"path": "/my-file",
"command": "create",
"file_text": file_text
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
assert_eq!(
ctx.fs().read_to_string("/my-file").await.unwrap(),
format!("{}\n", file_text)
);
let file_text = "Goodbye, world!\nSee you later";
let v = serde_json::json!({
"path": "/my-file",
"command": "create",
"file_text": file_text
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
// File should end with a newline
assert_eq!(
ctx.fs().read_to_string("/my-file").await.unwrap(),
format!("{}\n", file_text)
);
let file_text = "This is a new string";
let v = serde_json::json!({
"path": "/my-file",
"command": "create",
"new_str": file_text
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
assert_eq!(
ctx.fs().read_to_string("/my-file").await.unwrap(),
format!("{}\n", file_text)
);
}
#[tokio::test]
async fn test_fs_write_tool_str_replace() {
let ctx = setup_test_directory().await;
let mut stdout = std::io::stdout();
// No instances found
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "str_replace",
"old_str": "asjidfopjaieopr",
"new_str": "1623749",
});
assert!(
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.is_err()
);
// Multiple instances found
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "str_replace",
"old_str": "Hello world!",
"new_str": "Goodbye world!",
});
assert!(
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.is_err()
);
// Single instance found and replaced
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "str_replace",
"old_str": "1: Hello world!",
"new_str": "1: Goodbye world!",
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
assert_eq!(
ctx.fs()
.read_to_string(TEST_FILE_PATH)
.await
.unwrap()
.lines()
.next()
.unwrap(),
"1: Goodbye world!",
"expected the only occurrence to be replaced"
);
}
#[tokio::test]
async fn test_fs_write_tool_insert_at_beginning() {
let ctx = setup_test_directory().await;
let mut stdout = std::io::stdout();
let new_str = "1: New first line!\n";
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "insert",
"insert_line": 0,
"new_str": new_str,
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
let actual = ctx.fs().read_to_string(TEST_FILE_PATH).await.unwrap();
assert_eq!(
format!("{}\n", actual.lines().next().unwrap()),
new_str,
"expected the first line to be updated to '{}'",
new_str
);
assert_eq!(
actual.lines().skip(1).collect::<Vec<_>>(),
TEST_FILE_CONTENTS.lines().collect::<Vec<_>>(),
"the rest of the file should not have been updated"
);
}
#[tokio::test]
async fn test_fs_write_tool_insert_after_first_line() {
let ctx = setup_test_directory().await;
let mut stdout = std::io::stdout();
let new_str = "2: New second line!\n";
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "insert",
"insert_line": 1,
"new_str": new_str,
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
let actual = ctx.fs().read_to_string(TEST_FILE_PATH).await.unwrap();
assert_eq!(
format!("{}\n", actual.lines().nth(1).unwrap()),
new_str,
"expected the second line to be updated to '{}'",
new_str
);
assert_eq!(
actual.lines().skip(2).collect::<Vec<_>>(),
TEST_FILE_CONTENTS.lines().skip(1).collect::<Vec<_>>(),
"the rest of the file should not have been updated"
);
}
#[tokio::test]
async fn test_fs_write_tool_insert_when_no_newlines_in_file() {
let ctx = Context::builder().with_test_home().await.unwrap().build_fake();
let mut stdout = std::io::stdout();
let test_file_path = "/file.txt";
let test_file_contents = "hello there";
ctx.fs().write(test_file_path, test_file_contents).await.unwrap();
let new_str = "test";
// First, test appending
let v = serde_json::json!({
"path": test_file_path,
"command": "insert",
"insert_line": 1,
"new_str": new_str,
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
let actual = ctx.fs().read_to_string(test_file_path).await.unwrap();
assert_eq!(actual, format!("{}{}\n", test_file_contents, new_str));
// Then, test prepending
let v = serde_json::json!({
"path": test_file_path,
"command": "insert",
"insert_line": 0,
"new_str": new_str,
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
let actual = ctx.fs().read_to_string(test_file_path).await.unwrap();
assert_eq!(actual, format!("{}{}{}\n", new_str, test_file_contents, new_str));
}
#[tokio::test]
async fn test_fs_write_tool_append() {
let ctx = setup_test_directory().await;
let mut stdout = std::io::stdout();
// Test appending to existing file
let content_to_append = "5: Appended line";
let v = serde_json::json!({
"path": TEST_FILE_PATH,
"command": "append",
"new_str": content_to_append,
});
serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await
.unwrap();
let actual = ctx.fs().read_to_string(TEST_FILE_PATH).await.unwrap();
assert_eq!(
actual,
format!("{}{}\n", TEST_FILE_CONTENTS, content_to_append),
"Content should be appended to the end of the file with a newline added"
);
// Test appending to non-existent file (should fail)
let new_file_path = "/new_append_file.txt";
let content = "This is a new file created by append";
let v = serde_json::json!({
"path": new_file_path,
"command": "append",
"new_str": content,
});
let result = serde_json::from_value::<FsWrite>(v)
.unwrap()
.invoke(&ctx, &mut stdout)
.await;
assert!(result.is_err(), "Appending to non-existent file should fail");
}
#[test]
fn test_lines_with_context() {
let content = "Hello\nWorld!\nhow\nare\nyou\ntoday?";
assert_eq!(get_lines_with_context(content, 1, 1, 1), ("", 1, "World!\n", 2));
assert_eq!(get_lines_with_context(content, 0, 0, 2), ("", 1, "Hello\nWorld!\n", 2));
assert_eq!(
get_lines_with_context(content, 2, 4, 50),
("Hello\n", 1, "you\ntoday?", 6)
);
assert_eq!(get_lines_with_context(content, 4, 100, 2), ("World!\nhow\n", 2, "", 6));
}
#[test]
fn test_gutter_width() {
assert_eq!(terminal_width_required_for_line_count(1), 1);
assert_eq!(terminal_width_required_for_line_count(9), 1);
assert_eq!(terminal_width_required_for_line_count(10), 2);
assert_eq!(terminal_width_required_for_line_count(99), 2);
assert_eq!(terminal_width_required_for_line_count(100), 3);
assert_eq!(terminal_width_required_for_line_count(999), 3);
}
}