refactor(fs-operation): extract provider-neutral text operations

Share typed read/edit/write arguments, Operation schemas, decoding, pure outcomes, byte bounds, replacement policy and numbered rendering. Keep filesystem CAS and descriptor-protected mutation within the provider.

T-707: cargo test -p fs-operation passes (58 tests); overflow, Unicode, limits and local checked-save regressions are covered.
This commit is contained in:
2026-10-06 14:18:34 +09:00
parent b96bd110c6
commit 4511d4f8ac
4 changed files with 1142 additions and 65 deletions
+1 -3
View File
@@ -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
+1
View File
@@ -13,6 +13,7 @@ mod ignore_tests;
mod local;
mod operation;
mod search;
pub mod text;
#[cfg(test)]
mod traversal_tests;
mod walk;
+240 -62
View File
@@ -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<usize>,
replacements: Option<usize>,
}
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<usize> {
self.bytes
}
fn max_edit_replacements(&self) -> Option<usize> {
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());
}
}
+900
View File
@@ -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<Self> {
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<usize>,
/// Maximum lines to return. Omitted or null uses the frontend's default.
#[serde(default)]
pub limit: Option<usize>,
}
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<usize>,
pub max_output_bytes: Option<usize>,
pub max_replacements: Option<usize>,
}
#[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<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, TextError> {
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<T: DeserializeOwned>(input: &str) -> Result<T, TextError> {
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<EditOutcome, TextError> {
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<usize, TextError> {
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<TextOutcome, TextError> {
text_outcome(content, limits)
}
pub fn read(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
text_outcome(content, limits)
}
fn text_outcome(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
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::<EditArgs>(json!({"old_string": "a", "new_string": "b"})).unwrap();
assert!(!edit.replace_all);
assert!(
decode::<EditArgs>(json!({"old_string": "a", "new_string": "b", "replace_all": true}))
.unwrap()
.replace_all
);
assert_eq!(
decode::<WriteArgs>(json!({"content": "😀"}))
.unwrap()
.content,
"😀"
);
assert_eq!(decode::<ReadArgs>(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::<EditArgs>(value),
Err(TextError::Decode(_))
));
}
assert!(matches!(
decode::<WriteArgs>(json!({})),
Err(TextError::Decode(_))
));
assert!(matches!(
decode::<ReadArgs>(json!({"offset": 0})),
Err(TextError::Decode(_))
));
assert!(matches!(
decode::<ReadArgs>(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::<LineReadArgs>(value).unwrap().range(2000),
(0, 2000)
);
}
assert_eq!(
decode::<LineReadArgs>(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::<LineReadArgs>(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::<EditArgs>(input),
Err(TextError::Decode(_))
));
}
assert!(matches!(
decode_json::<WriteArgs>(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::<ReadArgs>(json!({"unknown": true})).unwrap_err();
assert!(std::error::Error::source(&error).is_some());
}
}