diff --git a/crates/fs-operation/Cargo.toml b/crates/fs-operation/Cargo.toml index 6d360b536..65f4a02ae 100644 --- a/crates/fs-operation/Cargo.toml +++ b/crates/fs-operation/Cargo.toml @@ -13,9 +13,7 @@ ignore = "0.4.25" libc = "0.2" schemars.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true sha2 = "0.10.9" tempfile.workspace = true thiserror.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 91921a3b0..63436ed05 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -13,6 +13,7 @@ mod ignore_tests; mod local; mod operation; mod search; +pub mod text; #[cfg(test)] mod traversal_tests; mod walk; diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index 32b0b4cc7..887a09248 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -189,15 +189,7 @@ fn run_read_with_limits( .min(total_lines); let byte_truncated = selected_bytes_seen > response_limit; if byte_truncated { - let mut byte_end = response_limit.min(selected.len()); - while byte_end > 0 { - match std::str::from_utf8(&selected[..byte_end]) { - Ok(_) => break, - Err(error) if error.error_len().is_none() => byte_end -= 1, - Err(_) => break, - } - } - selected.truncate(byte_end); + selected.truncate(crate::text::bounded_utf8_len(&selected, response_limit)); } Ok(ReadResult { path: logical, @@ -415,55 +407,33 @@ pub fn run_edit( let content = String::from_utf8(bytes).map_err(|_| { FsError::InvalidArgument(format!("{} is not valid UTF-8", logical.as_str())) })?; - let occurrences = content.matches(&request.old_string).count(); - if occurrences == 0 { - return Err(FsError::InvalidArgument( - "old_string was not found".to_string(), - )); - } - if !request.replace_all && occurrences != 1 { - return Err(FsError::InvalidArgument(format!( - "old_string matched {occurrences} times; set replace_all=true or provide a unique string" - ))); - } - let replacement_count = if request.replace_all { occurrences } else { 1 }; - if let Some(limit) = access.max_edit_replacements() - && replacement_count > limit - { - return Err(FsError::InvalidArgument(format!( - "{} exceeds provider Edit replacement limit {limit}", - logical.as_str() - ))); - } - let removed_bytes = request - .old_string - .len() - .checked_mul(replacement_count) - .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; - let added_bytes = request - .new_string - .len() - .checked_mul(replacement_count) - .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; - let edited_len = content - .len() - .checked_sub(removed_bytes) - .and_then(|size| size.checked_add(added_bytes)) - .ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?; - enforce_write_bound(&logical, edited_len, access)?; - let edited = if request.replace_all { - content.replace(&request.old_string, &request.new_string) - } else { - content.replacen(&request.old_string, &request.new_string, 1) + let args = crate::text::EditArgs { + old_string: request.old_string, + new_string: request.new_string, + replace_all: request.replace_all, }; - debug_assert_eq!(edited.len(), edited_len); + let edited = crate::text::edit( + &content, + &args, + crate::text::TextLimits { + max_input_bytes: access.max_write_bytes(), + max_output_bytes: access.max_write_bytes(), + max_replacements: access.max_edit_replacements(), + }, + ) + .map_err(|error| map_text_error(&logical, error))?; access - .atomic_write_file(&path, &target, edited.as_bytes(), AtomicWriteMode::Replace) + .atomic_write_file( + &path, + &target, + edited.content.as_bytes(), + AtomicWriteMode::Replace, + ) .map_err(|error| map_io(&logical, error))?; Ok(EditResult { - replacements: if request.replace_all { occurrences } else { 1 }, - bytes_written: edited.len(), - content_hash: hash_bytes(edited.as_bytes()), + replacements: edited.replacements, + bytes_written: edited.bytes_written, + content_hash: hash_bytes(edited.content.as_bytes()), }) } @@ -579,15 +549,37 @@ fn enforce_write_bound( bytes: usize, access: &dyn FsAccessPolicy, ) -> Result<(), FsError> { - if let Some(limit) = access.max_write_bytes() - && bytes > limit - { - return Err(FsError::InvalidArgument(format!( - "{} exceeds provider write limit {limit}", + crate::text::check_output_bytes( + bytes, + crate::text::TextLimits { + max_output_bytes: access.max_write_bytes(), + ..Default::default() + }, + ) + .map_err(|error| map_text_error(logical, error)) +} + +fn map_text_error(logical: &FsPath, error: crate::text::TextError) -> FsError { + use crate::text::TextError; + + let message = match error { + TextError::InputTooLarge { limit, .. } | TextError::OutputTooLarge { limit, .. } => { + format!("{} exceeds provider write limit {limit}", logical.as_str()) + } + TextError::ReplacementLimitExceeded { limit, .. } => format!( + "{} exceeds provider Edit replacement limit {limit}", logical.as_str() - ))); - } - Ok(()) + ), + TextError::Decode(error) => format!("invalid arguments: {error}"), + TextError::EmptyOldString => "old_string must not be empty".to_string(), + TextError::IdenticalStrings => "old_string and new_string are identical".to_string(), + TextError::NotFound => "old_string was not found".to_string(), + TextError::MultipleMatches { occurrences } => format!( + "old_string matched {occurrences} times; set replace_all=true or provide a unique string" + ), + TextError::Overflow => "edited content size overflowed".to_string(), + }; + FsError::InvalidArgument(message) } fn read_mutation_preimage( @@ -814,3 +806,189 @@ mod bounded_read_tests { assert!(result.truncated); } } + +#[cfg(test)] +mod text_policy_tests { + use super::*; + + struct Access { + root: PathBuf, + bytes: Option, + replacements: Option, + } + + impl FsAccessPolicy for Access { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + + fn is_writable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + + fn max_write_bytes(&self) -> Option { + self.bytes + } + + fn max_edit_replacements(&self) -> Option { + self.replacements + } + } + + #[test] + fn edit_policy_errors_preserve_the_preimage_and_provider_messages() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let source = "a a"; + fs::write(root.join("text.txt"), source).unwrap(); + let access = Access { + root: root.clone(), + bytes: Some(4), + replacements: Some(1), + }; + for (old, new, all, expected_message) in [ + ("", "x", false, "old_string must not be empty"), + ("a", "a", false, "old_string and new_string are identical"), + ( + "missing", + "x", + false, + "text.txt exceeds provider write limit 4", + ), + ("b", "x", false, "old_string was not found"), + ( + "a", + "x", + false, + "old_string matched 2 times; set replace_all=true or provide a unique string", + ), + ( + "a", + "x", + true, + "text.txt exceeds provider Edit replacement limit 1", + ), + ( + "a a", + "πŸ˜€x", + false, + "text.txt exceeds provider write limit 4", + ), + ] { + let error = run_edit( + &root, + EditRequest { + path: FsPath::new("text.txt").unwrap(), + old_string: old.into(), + new_string: new.into(), + replace_all: all, + expected_hash: hash_bytes(source.as_bytes()), + }, + &access, + ) + .unwrap_err(); + assert!( + matches!(error, FsError::InvalidArgument(message) if message == expected_message) + ); + assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source); + } + let access = Access { + replacements: None, + ..access + }; + let error = run_edit( + &root, + EditRequest { + path: FsPath::new("text.txt").unwrap(), + old_string: "a".into(), + new_string: "xxx".into(), + replace_all: true, + expected_hash: hash_bytes(source.as_bytes()), + }, + &access, + ) + .unwrap_err(); + assert!( + matches!(error, FsError::InvalidArgument(message) if message == "text.txt exceeds provider write limit 4") + ); + assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source); + } + + #[test] + fn edit_keeps_cas_before_content_policy_and_hashes_the_core_result() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::write(root.join("text.txt"), "Γ©πŸ˜€").unwrap(); + let access = Access { + root: root.clone(), + bytes: None, + replacements: None, + }; + let error = run_edit( + &root, + EditRequest { + path: FsPath::new("text.txt").unwrap(), + old_string: String::new(), + new_string: "x".into(), + replace_all: false, + expected_hash: [0; 32], + }, + &access, + ) + .unwrap_err(); + assert!(matches!(error, FsError::Conflict(_))); + let result = run_edit( + &root, + EditRequest { + path: FsPath::new("text.txt").unwrap(), + old_string: "Γ©".into(), + new_string: "η•Œ".into(), + replace_all: false, + expected_hash: hash_bytes("Γ©πŸ˜€".as_bytes()), + }, + &access, + ) + .unwrap(); + assert_eq!(result.replacements, 1); + assert_eq!(result.bytes_written, 7); + assert_eq!(result.content_hash, hash_bytes("η•ŒπŸ˜€".as_bytes())); + assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), "η•ŒπŸ˜€"); + } + + #[test] + fn local_write_remains_binary_safe_and_maps_shared_bounds() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let access = Access { + root: root.clone(), + bytes: Some(2), + replacements: None, + }; + let result = run_write( + &root, + WriteRequest { + path: FsPath::new("binary").unwrap(), + content: vec![0xff, 0], + expected_hash: None, + }, + &access, + ) + .unwrap(); + assert_eq!(result.bytes_written, 2); + assert_eq!(fs::read(root.join("binary")).unwrap(), vec![0xff, 0]); + let error = run_write( + &root, + WriteRequest { + path: FsPath::new("large").unwrap(), + content: vec![0xff, 0, 1], + expected_hash: None, + }, + &access, + ) + .unwrap_err(); + assert!( + matches!(error, FsError::InvalidArgument(message) if message == "large exceeds provider write limit 2") + ); + assert!(!root.join("large").exists()); + } +} diff --git a/crates/fs-operation/src/text.rs b/crates/fs-operation/src/text.rs new file mode 100644 index 000000000..c541ef5b5 --- /dev/null +++ b/crates/fs-operation/src/text.rs @@ -0,0 +1,900 @@ +//! Pure text-operation policy shared by filesystem providers and operation frontends. +//! +//! This module owns no paths, descriptors, version fences, or I/O. Limits count +//! UTF-8 bytes, not characters, and default to unlimited. + +use schemars::JsonSchema; +use serde::{Deserialize, de::DeserializeOwned}; +use thiserror::Error; + +/// The shared text Operation definitions, independent of paths and frontends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextOperation { + Read, + ReadLines, + Edit, + Write, +} + +impl TextOperation { + /// Resolve a lower-case Operation name. A frontend chooses whether its + /// `read` Operation accepts whole-text or line-range arguments. + pub fn from_name(name: &str, line_read: bool) -> Option { + match name { + "read" if line_read => Some(Self::ReadLines), + "read" => Some(Self::Read), + "edit" => Some(Self::Edit), + "write" => Some(Self::Write), + _ => None, + } + } + + pub fn argument_schema(self) -> schemars::Schema { + match self { + Self::Read => schemars::schema_for!(ReadArgs), + Self::ReadLines => schemars::schema_for!(LineReadArgs), + Self::Edit => schemars::schema_for!(EditArgs), + Self::Write => schemars::schema_for!(WriteArgs), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct EditArgs { + /// String to replace. Must be nonempty and unique unless replace_all is true. + pub old_string: String, + /// Replacement text. Must differ from old_string; may be empty to delete it. + pub new_string: String, + /// Replace every non-overlapping occurrence. Defaults to false. + #[serde(default)] + pub replace_all: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct WriteArgs { + /// Full text to write. + pub content: String, +} + +/// Whole-text reads accept no operation-specific arguments. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReadArgs {} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct LineReadArgs { + /// Zero-based line offset. Omitted or null means zero. + #[serde(default)] + pub offset: Option, + /// Maximum lines to return. Omitted or null uses the frontend's default. + #[serde(default)] + pub limit: Option, +} + +impl LineReadArgs { + pub fn range(&self, default_limit: usize) -> (usize, usize) { + ( + self.offset.unwrap_or(0), + self.limit.unwrap_or(default_limit).max(1), + ) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TextLimits { + pub max_input_bytes: Option, + pub max_output_bytes: Option, + pub max_replacements: Option, +} + +#[derive(Debug, Error)] +pub enum TextError { + #[error("invalid arguments: {0}")] + Decode(#[source] serde_json::Error), + #[error("old_string must not be empty")] + EmptyOldString, + #[error("old_string and new_string are identical")] + IdenticalStrings, + #[error("old_string was not found")] + NotFound, + #[error( + "old_string matched {occurrences} times; set replace_all=true or provide a unique string" + )] + MultipleMatches { occurrences: usize }, + #[error("input size {bytes} exceeds limit {limit}")] + InputTooLarge { bytes: usize, limit: usize }, + #[error("output size {bytes} exceeds limit {limit}")] + OutputTooLarge { bytes: usize, limit: usize }, + #[error("replacement count {replacements} exceeds limit {limit}")] + ReplacementLimitExceeded { replacements: usize, limit: usize }, + #[error("edited content size overflowed")] + Overflow, +} + +pub fn decode(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(TextError::Decode) +} + +/// Decode raw Tool JSON directly, retaining duplicate-field rejection rather +/// than losing that information through an intermediate JSON object. +pub fn decode_json(input: &str) -> Result { + serde_json::from_str(input).map_err(TextError::Decode) +} + +impl EditArgs { + /// Validate argument-only policy before a caller acquires a preimage. + pub fn validate(&self, limits: TextLimits) -> Result<(), TextError> { + if self.old_string.is_empty() { + return Err(TextError::EmptyOldString); + } + if self.old_string == self.new_string { + return Err(TextError::IdenticalStrings); + } + check_input_bytes(self.old_string.len(), limits)?; + check_input_bytes(self.new_string.len(), limits)?; + Ok(()) + } +} + +/// A numbered, bounded text response with provider line metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rendered { + pub body: String, + pub line_count: usize, + pub total_lines: usize, + pub truncated: bool, +} + +const RENDERED_TRUNCATION_MARKER: &str = "\n[truncated at rendered output byte limit]\n"; + +/// Render selected text with one-based line numbers and a hard UTF-8 byte bound. +/// +/// As in the Tool renderer, room for the truncation marker is always reserved, +/// and a partially emitted numbered line counts as a line. Tiny bounds emit +/// only the marker prefix that fits. Line numbers saturate rather than wrap. +pub fn render_numbered( + text: &str, + start_line: usize, + total_lines: usize, + truncated: bool, + max_bytes: usize, +) -> Rendered { + let budget = max_bytes.saturating_sub(RENDERED_TRUNCATION_MARKER.len()); + let mut body = String::with_capacity(text.len().min(max_bytes)); + let mut line_count = 0usize; + let mut output_truncated = false; + for (index, line) in text.lines().enumerate() { + // Build only the small numeric prefix, not an unbounded copy of a line. + let number = start_line.saturating_add(index).saturating_add(1); + let prefix = format!("{number:>6}\t"); + let numbered_len = prefix.len().saturating_add(line.len()).saturating_add(1); + let mut remaining = budget.saturating_sub(body.len()); + if remaining > 0 { + line_count = line_count.saturating_add(1); + } + let line_truncated = numbered_len > remaining; + for fragment in [prefix.as_str(), line, "\n"] { + let mut end = remaining.min(fragment.len()); + while !fragment.is_char_boundary(end) { + end -= 1; + } + body.push_str(&fragment[..end]); + remaining -= end; + // Do not skip a character that failed to fit and then emit later + // fragments; the response must be a prefix of the numbered line. + if end < fragment.len() { + break; + } + } + if line_truncated { + let marker_len = max_bytes + .saturating_sub(body.len()) + .min(RENDERED_TRUNCATION_MARKER.len()); + body.push_str(&RENDERED_TRUNCATION_MARKER[..marker_len]); + output_truncated = true; + break; + } + } + Rendered { + body, + line_count, + total_lines, + truncated: start_line > 0 || truncated || output_truncated, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditOutcome { + pub content: String, + pub replacements: usize, + pub bytes_written: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextOutcome { + pub content: String, + pub bytes: usize, +} + +pub fn edit(original: &str, args: &EditArgs, limits: TextLimits) -> Result { + args.validate(limits)?; + check_input_bytes(original.len(), limits)?; + let occurrences = original.matches(&args.old_string).count(); + if occurrences == 0 { + return Err(TextError::NotFound); + } + if !args.replace_all && occurrences != 1 { + return Err(TextError::MultipleMatches { occurrences }); + } + let replacements = occurrences; + if let Some(limit) = limits.max_replacements + && replacements > limit + { + return Err(TextError::ReplacementLimitExceeded { + replacements, + limit, + }); + } + // All arithmetic and output policy are checked before allocating the result. + let bytes_written = edited_len( + original.len(), + args.old_string.len(), + args.new_string.len(), + replacements, + )?; + check_output_bytes(bytes_written, limits)?; + let mut content = String::with_capacity(bytes_written); + let mut cursor = 0; + for (start, matched) in original.match_indices(&args.old_string) { + content.push_str(&original[cursor..start]); + content.push_str(&args.new_string); + cursor = start + matched.len(); + } + content.push_str(&original[cursor..]); + debug_assert_eq!(content.len(), bytes_written); + Ok(EditOutcome { + content, + replacements, + bytes_written, + }) +} + +fn edited_len( + original: usize, + old: usize, + new: usize, + replacements: usize, +) -> Result { + let removed = old.checked_mul(replacements).ok_or(TextError::Overflow)?; + let added = new.checked_mul(replacements).ok_or(TextError::Overflow)?; + let bytes = original + .checked_sub(removed) + .and_then(|bytes| bytes.checked_add(added)) + .ok_or(TextError::Overflow)?; + // String/Vec capacity is bounded by isize::MAX even on platforms where usize + // arithmetic still fits. Reject that case before String::with_capacity. + if bytes > isize::MAX as usize { + return Err(TextError::Overflow); + } + Ok(bytes) +} + +pub fn write(content: String, limits: TextLimits) -> Result { + text_outcome(content, limits) +} + +pub fn read(content: String, limits: TextLimits) -> Result { + text_outcome(content, limits) +} + +fn text_outcome(content: String, limits: TextLimits) -> Result { + let bytes = content.len(); + check_input_bytes(bytes, limits)?; + check_output_bytes(bytes, limits)?; + Ok(TextOutcome { content, bytes }) +} + +pub(crate) fn check_input_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> { + if let Some(limit) = limits.max_input_bytes + && bytes > limit + { + return Err(TextError::InputTooLarge { bytes, limit }); + } + Ok(()) +} + +pub(crate) fn check_output_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> { + if let Some(limit) = limits.max_output_bytes + && bytes > limit + { + return Err(TextError::OutputTooLarge { bytes, limit }); + } + Ok(()) +} + +/// Keep valid UTF-8 prefixes intact without changing byte-read behavior on +/// genuinely invalid UTF-8. Providers must remain able to return binary data. +pub(crate) fn bounded_utf8_len(bytes: &[u8], max_bytes: usize) -> usize { + let mut end = max_bytes.min(bytes.len()); + while end > 0 { + match std::str::from_utf8(&bytes[..end]) { + Ok(_) => break, + Err(error) if error.error_len().is_none() => end -= 1, + Err(_) => break, + } + } + end +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn args(old: &str, new: &str, replace_all: bool) -> EditArgs { + EditArgs { + old_string: old.into(), + new_string: new.into(), + replace_all, + } + } + + #[test] + fn decode_uses_shared_argument_defaults_and_types() { + let edit = decode::(json!({"old_string": "a", "new_string": "b"})).unwrap(); + assert!(!edit.replace_all); + assert!( + decode::(json!({"old_string": "a", "new_string": "b", "replace_all": true})) + .unwrap() + .replace_all + ); + assert_eq!( + decode::(json!({"content": "πŸ˜€"})) + .unwrap() + .content, + "πŸ˜€" + ); + assert_eq!(decode::(json!({})).unwrap(), ReadArgs {}); + for value in [ + json!({"old_string": "a"}), + json!({"old_string": 2, "new_string": "b"}), + json!({"old_string": "a", "new_string": "b", "replace_all": null}), + ] { + assert!(matches!( + decode::(value), + Err(TextError::Decode(_)) + )); + } + assert!(matches!( + decode::(json!({})), + Err(TextError::Decode(_)) + )); + assert!(matches!( + decode::(json!({"offset": 0})), + Err(TextError::Decode(_)) + )); + assert!(matches!( + decode::(json!(null)), + Err(TextError::Decode(_)) + )); + } + + #[test] + fn line_range_preserves_null_defaults_and_minimum_one() { + for value in [json!({}), json!({"offset": null, "limit": null})] { + assert_eq!( + decode::(value).unwrap().range(2000), + (0, 2000) + ); + } + assert_eq!( + decode::(json!({"offset": 7, "limit": 0})) + .unwrap() + .range(99), + (7, 1) + ); + assert_eq!(LineReadArgs::default().range(0), (0, 1)); + assert_eq!( + LineReadArgs { + offset: Some(usize::MAX), + limit: Some(usize::MAX) + } + .range(1), + (usize::MAX, usize::MAX) + ); + for value in [ + json!({"offset": -1}), + json!({"limit": "2"}), + json!({"limit": 1.5}), + ] { + assert!(matches!( + decode::(value), + Err(TextError::Decode(_)) + )); + } + } + + #[test] + fn schema_is_the_argument_contract() { + let edit = serde_json::to_value(schemars::schema_for!(EditArgs)).unwrap(); + assert_eq!(edit["required"], json!(["old_string", "new_string"])); + assert_eq!(edit["properties"]["replace_all"]["default"], json!(false)); + let read = serde_json::to_value(schemars::schema_for!(ReadArgs)).unwrap(); + assert_eq!(read["additionalProperties"], json!(false)); + assert!( + read.get("properties") + .is_none_or(|properties| properties == &json!({})) + ); + let lines = serde_json::to_value(schemars::schema_for!(LineReadArgs)).unwrap(); + assert_eq!( + lines["properties"]["offset"]["type"], + json!(["integer", "null"]) + ); + assert_eq!(lines["properties"]["limit"]["default"], json!(null)); + } + + #[test] + fn edit_rejects_empty_identical_missing_and_ambiguous_matches() { + assert!(matches!( + args("", "x", true).validate(TextLimits::default()), + Err(TextError::EmptyOldString) + )); + assert!(matches!( + edit("a", &args("a", "a", false), TextLimits::default()), + Err(TextError::IdenticalStrings) + )); + assert!(matches!( + edit("a", &args("b", "c", false), TextLimits::default()), + Err(TextError::NotFound) + )); + assert!(matches!( + edit("aa", &args("a", "b", false), TextLimits::default()), + Err(TextError::MultipleMatches { occurrences: 2 }) + )); + assert!(matches!( + edit("", &args("a", "", false), TextLimits::default()), + Err(TextError::NotFound) + )); + } + + #[test] + fn edit_handles_unique_all_deletion_literal_and_nonoverlapping_matches() { + for (original, old, new, all, expected, replacements) in [ + ( + "before old after", + "old", + "new", + false, + "before new after", + 1, + ), + ("aaa", "aa", "x", true, "xa", 1), + ("a-a-a", "a", "", true, "--", 3), + ("[a].* [a].*", "[a].*", "$1", true, "$1 $1", 2), + ("x", "x", "", false, "", 1), + ] { + let result = edit(original, &args(old, new, all), TextLimits::default()).unwrap(); + assert_eq!(result.content, expected); + assert_eq!(result.replacements, replacements); + assert_eq!(result.bytes_written, expected.len()); + } + } + + #[test] + fn edit_preserves_unicode_and_counts_bytes() { + let result = edit("Γ©πŸ˜€\nΓ©πŸ˜€", &args("Γ©πŸ˜€", "η•Œ", true), TextLimits::default()).unwrap(); + assert_eq!(result.content, "η•Œ\nη•Œ"); + assert_eq!(result.replacements, 2); + assert_eq!(result.bytes_written, 7); + // Matching is literal, without Unicode normalization. + assert!(matches!( + edit("e\u{301}", &args("Γ©", "x", false), TextLimits::default()), + Err(TextError::NotFound) + )); + } + + #[test] + fn edit_input_limits_cover_arguments_and_preimage_with_inclusive_boundaries() { + let limits = TextLimits { + max_input_bytes: Some(4), + ..Default::default() + }; + args("πŸ˜€", "a", false).validate(limits).unwrap(); + assert!(matches!( + args("πŸ˜€x", "a", false).validate(limits), + Err(TextError::InputTooLarge { bytes: 5, limit: 4 }) + )); + assert!(matches!( + args("a", "πŸ˜€x", false).validate(limits), + Err(TextError::InputTooLarge { bytes: 5, limit: 4 }) + )); + assert!(matches!( + edit("aaaaa", &args("a", "b", true), limits), + Err(TextError::InputTooLarge { bytes: 5, limit: 4 }) + )); + assert_eq!( + edit("aaaa", &args("a", "b", true), limits).unwrap().content, + "bbbb" + ); + } + + #[test] + fn edit_output_and_replacement_limits_apply_before_constructing_result() { + let args = args("a", "πŸ˜€", true); + let limits = TextLimits { + max_output_bytes: Some(7), + ..Default::default() + }; + assert!(matches!( + edit("aa", &args, limits), + Err(TextError::OutputTooLarge { bytes: 8, limit: 7 }) + )); + assert_eq!( + edit( + "aa", + &args, + TextLimits { + max_output_bytes: Some(8), + ..limits + } + ) + .unwrap() + .bytes_written, + 8 + ); + assert!(matches!( + edit( + "aa", + &args, + TextLimits { + max_replacements: Some(1), + ..Default::default() + } + ), + Err(TextError::ReplacementLimitExceeded { + replacements: 2, + limit: 1 + }) + )); + assert!(matches!( + edit( + "a", + &args, + TextLimits { + max_replacements: Some(0), + ..Default::default() + } + ), + Err(TextError::ReplacementLimitExceeded { + replacements: 1, + limit: 0 + }) + )); + assert_eq!( + edit( + "aa", + &args, + TextLimits { + max_replacements: Some(2), + ..Default::default() + } + ) + .unwrap() + .replacements, + 2 + ); + assert_eq!( + edit( + "a", + &EditArgs { + old_string: "a".into(), + new_string: String::new(), + replace_all: false + }, + TextLimits { + max_output_bytes: Some(0), + ..Default::default() + } + ) + .unwrap() + .content, + "" + ); + } + + #[test] + fn raw_json_decode_preserves_duplicate_argument_rejection() { + for input in [ + r#"{"old_string":"a","old_string":"b","new_string":"c"}"#, + r#"{"old_string":"a","new_string":"b","replace_all":false,"replace_all":true}"#, + ] { + assert!(matches!( + decode_json::(input), + Err(TextError::Decode(_)) + )); + } + assert!(matches!( + decode_json::(r#"{"content":"a","content":"b"}"#), + Err(TextError::Decode(_)) + )); + } + + #[test] + fn size_arithmetic_rejects_every_overflow_before_preallocation() { + // Exercise impossible-to-allocate sizes through the exact helper edit + // invokes before allocating; no giant strings need to be constructed. + for (original, old, new, count) in [ + (usize::MAX, usize::MAX, 0, 2), // removed multiplication + (2, 1, usize::MAX, 2), // added multiplication + (1, 2, 0, 1), // subtraction + (usize::MAX, 1, 2, 1), // addition + (1, 1, isize::MAX as usize + 1, 1), // allocation capacity + ] { + assert!(matches!( + edited_len(original, old, new, count), + Err(TextError::Overflow) + )); + } + assert_eq!( + edited_len(1, 1, isize::MAX as usize, 1).unwrap(), + isize::MAX as usize + ); + assert_eq!(edited_len(8, 2, 1, 3).unwrap(), 5); + } + + #[test] + fn read_and_write_preserve_whole_text_and_enforce_both_byte_limits() { + for operation in [read, write] { + assert_eq!( + operation(String::new(), TextLimits::default()).unwrap(), + TextOutcome { + content: String::new(), + bytes: 0 + } + ); + let content = "Γ©\r\nπŸ˜€\n"; + let limits = TextLimits { + max_input_bytes: Some(content.len()), + max_output_bytes: Some(content.len()), + max_replacements: Some(0), + }; + let result = operation(content.into(), limits).unwrap(); + assert_eq!(result.content, content); + assert_eq!(result.bytes, content.len()); + assert!(matches!( + operation( + "πŸ˜€".into(), + TextLimits { + max_input_bytes: Some(3), + ..Default::default() + } + ), + Err(TextError::InputTooLarge { bytes: 4, limit: 3 }) + )); + assert!(matches!( + operation( + "πŸ˜€".into(), + TextLimits { + max_output_bytes: Some(3), + ..Default::default() + } + ), + Err(TextError::OutputTooLarge { bytes: 4, limit: 3 }) + )); + assert!(matches!( + operation( + "a".into(), + TextLimits { + max_output_bytes: Some(0), + ..Default::default() + } + ), + Err(TextError::OutputTooLarge { bytes: 1, limit: 0 }) + )); + } + } + + #[test] + fn utf8_byte_bounds_preserve_binary_compatibility() { + for (limit, expected) in [ + (0, 0), + (1, 1), + (2, 1), + (3, 1), + (4, 1), + (5, 5), + (usize::MAX, 6), + ] { + assert_eq!(bounded_utf8_len("aπŸ˜€z".as_bytes(), limit), expected); + } + assert_eq!(bounded_utf8_len(&[0xff, 0xfe, 0x00], 2), 2); + } + + #[test] + fn operation_names_and_schemas_are_shared_authority() { + assert_eq!( + TextOperation::from_name("read", false), + Some(TextOperation::Read) + ); + assert_eq!( + TextOperation::from_name("read", true), + Some(TextOperation::ReadLines) + ); + for line_read in [false, true] { + assert_eq!( + TextOperation::from_name("edit", line_read), + Some(TextOperation::Edit) + ); + assert_eq!( + TextOperation::from_name("write", line_read), + Some(TextOperation::Write) + ); + for name in ["Read", "WRITE", "read_lines", "stat", "", " read"] { + assert_eq!(TextOperation::from_name(name, line_read), None); + } + } + for (operation, expected) in [ + (TextOperation::Read, schemars::schema_for!(ReadArgs)), + ( + TextOperation::ReadLines, + schemars::schema_for!(LineReadArgs), + ), + (TextOperation::Edit, schemars::schema_for!(EditArgs)), + (TextOperation::Write, schemars::schema_for!(WriteArgs)), + ] { + assert_eq!(operation.argument_schema(), expected); + } + let schema = serde_json::to_value(TextOperation::Edit.argument_schema()).unwrap(); + assert!( + schema["properties"]["new_string"]["description"] + .as_str() + .unwrap() + .contains("Must differ") + ); + } + + #[test] + fn renderer_preserves_numbering_lines_and_provider_metadata() { + let result = render_numbered("alpha\r\n\nΞ²eta\n", 0, 3, false, 1024); + assert_eq!(result.body, " 1\talpha\n 2\t\n 3\tΞ²eta\n"); + assert_eq!(result.line_count, 3); + assert_eq!(result.total_lines, 3); + assert!(!result.truncated); + let result = render_numbered("last", 9, 10, false, 1024); + assert_eq!(result.body, " 10\tlast\n"); + assert!(result.truncated); + assert!(render_numbered("last", 0, 10, true, 1024).truncated); + assert_eq!( + render_numbered("", 0, 0, false, 0), + Rendered { + body: String::new(), + line_count: 0, + total_lines: 0, + truncated: false, + } + ); + assert!(render_numbered("", 1, 1, false, 0).truncated); + } + + #[test] + fn renderer_reserves_marker_and_counts_partial_lines_as_before() { + let marker = RENDERED_TRUNCATION_MARKER; + let result = render_numbered("a\nb", 0, 2, false, marker.len() + 9); + assert_eq!(result.body, format!(" 1\ta\n{marker}")); + assert_eq!(result.line_count, 1); + assert_eq!(result.total_lines, 2); + assert!(result.truncated); + let result = render_numbered("a", 0, 1, false, marker.len() + 1); + assert_eq!(result.body, format!(" {marker}")); + assert_eq!(result.line_count, 1); + assert!(result.truncated); + let result = render_numbered("a", 0, 1, false, marker.len() + 9); + assert_eq!(result.body, " 1\ta\n"); + assert_eq!(result.line_count, 1); + assert!(!result.truncated); + } + + #[test] + fn renderer_tiny_bounds_unicode_and_large_offsets_never_overflow() { + let marker = RENDERED_TRUNCATION_MARKER; + for max_bytes in 0..=marker.len() { + let result = render_numbered("πŸ˜€", 0, 1, false, max_bytes); + assert_eq!(result.body, marker[..max_bytes]); + assert_eq!(result.line_count, 0); + assert!(result.truncated); + } + for extra in 1..=4 { + let result = render_numbered("πŸ˜€πŸ˜€", 0, 1, false, marker.len() + 7 + extra); + let expected = if extra < 4 { + " 1\t" + } else { + " 1\tπŸ˜€" + }; + assert_eq!(result.body, format!("{expected}{marker}")); + assert_eq!(result.line_count, 1); + assert!(result.body.len() <= marker.len() + 7 + extra); + assert!(result.truncated); + } + let result = render_numbered("a\nb", usize::MAX, usize::MAX, false, usize::MAX); + assert_eq!( + result.body, + format!("{}\ta\n{}\tb\n", usize::MAX, usize::MAX) + ); + assert_eq!(result.line_count, 2); + assert_eq!(result.total_lines, usize::MAX); + assert!(result.truncated); + } + + #[test] + fn renderer_matches_original_behavior_for_ordinary_bounds() { + fn original( + text: &str, + start_line: usize, + total_lines: usize, + truncated: bool, + max_bytes: usize, + ) -> Rendered { + let marker = RENDERED_TRUNCATION_MARKER; + let budget = max_bytes - marker.len(); + let mut body = String::with_capacity(text.len().min(max_bytes)); + let mut line_count = 0; + let mut output_truncated = false; + for (index, line) in text.lines().enumerate() { + let numbered = format!("{:>6}\t{}\n", start_line + index + 1, line); + let remaining = budget.saturating_sub(body.len()); + if remaining > 0 { + line_count += 1; + } + if numbered.len() > remaining { + let mut end = remaining; + while !numbered.is_char_boundary(end) { + end -= 1; + } + body.push_str(&numbered[..end]); + body.push_str(marker); + output_truncated = true; + break; + } + body.push_str(&numbered); + } + Rendered { + body, + line_count, + total_lines, + truncated: start_line > 0 || truncated || output_truncated, + } + } + for text in ["", "a", "\n", "πŸ˜€Γ©η•Œ\nsecond\n", "first\r\n\r\nlast"] { + for start in [0, 1, 999_999] { + for bound in RENDERED_TRUNCATION_MARKER.len()..=160 { + let result = render_numbered(text, start, 99, false, bound); + assert_eq!(result, original(text, start, 99, false, bound)); + assert!(result.body.len() <= bound); + } + } + } + } + + #[test] + fn errors_keep_actionable_messages_and_decode_source() { + assert_eq!( + TextError::EmptyOldString.to_string(), + "old_string must not be empty" + ); + assert_eq!( + TextError::IdenticalStrings.to_string(), + "old_string and new_string are identical" + ); + assert_eq!(TextError::NotFound.to_string(), "old_string was not found"); + assert_eq!( + TextError::MultipleMatches { occurrences: 3 }.to_string(), + "old_string matched 3 times; set replace_all=true or provide a unique string" + ); + assert_eq!( + TextError::Overflow.to_string(), + "edited content size overflowed" + ); + let error = decode::(json!({"unknown": true})).unwrap_err(); + assert!(std::error::Error::source(&error).is_some()); + } +}