use crate::tools::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{
FILE_EDIT_TARGET_MAX_BYTES, FILE_EDIT_TEXT_MAX_BYTES, FILE_WRITE_MAX_BYTES, HashEditArgs,
},
contract::{metadata_key as meta, tool_name},
dispatch::FilesystemOutcome,
fs::ExistingPathPolicy,
};
use crate::{
checkpoints::SnapshotTool,
path_utils::lexical_normalize,
persistence::{CrossProcessFileLock, atomic_write},
};
use anyhow::Context;
use serde_json::json;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard},
};
use super::{
format::{compute_file_hash, format_hashline_header},
input::{SplitOptions, parse_input},
model::{FileOp, ParseWarning, ParsedSection},
normalize::normalize_for_snapshot_storage,
recovery::{
RECOVERY_EXTERNAL_WARNING, RECOVERY_LINE_REMAP_WARNING, RECOVERY_SESSION_CHAIN_WARNING,
RECOVERY_SESSION_REPLAY_WARNING, RecoveryError, apply_with_staleness_recovery,
recover_path_by_tag,
},
};
const HASH_EDIT_OUTPUT_MAX_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone)]
struct ResolvedSection {
section: ParsedSection,
source: PathBuf,
dest: Option<PathBuf>,
}
#[derive(Debug, Clone)]
struct PreparedSection {
section: ParsedSection,
source: PathBuf,
dest: Option<PathBuf>,
normalized: String,
after: String,
persisted: String,
pre_snapshot: Option<Vec<u8>>,
warnings: Vec<ParseWarning>,
block_resolutions: Vec<super::model::BlockResolution>,
first_changed_line: Option<usize>,
added: usize,
removed: usize,
}
#[derive(Debug)]
struct PartialCommitError {
message: String,
metadata: serde_json::Value,
paths: Vec<PathBuf>,
}
impl std::fmt::Display for PartialCommitError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for PartialCommitError {}
#[derive(Debug)]
struct FilesystemMutationError {
source: anyhow::Error,
changed_paths: Vec<PathBuf>,
operation_completed: bool,
}
impl std::fmt::Display for FilesystemMutationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.source, formatter)
}
}
impl std::error::Error for FilesystemMutationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.source.as_ref())
}
}
fn is_stale_recovery(item: &PreparedSection) -> bool {
item.warnings.iter().any(|warning| matches!(warning,
ParseWarning::ApplyRepair { message } if matches!(message.as_str(),
RECOVERY_EXTERNAL_WARNING | RECOVERY_LINE_REMAP_WARNING | RECOVERY_SESSION_CHAIN_WARNING |
RECOVERY_SESSION_REPLAY_WARNING | super::recovery::HEAD_TAIL_STALE_WARNING)))
}
fn file_metadata(item: &PreparedSection, status: &str, error: Option<&str>) -> serde_json::Value {
let applied = matches!(
status,
"committed"
| "committed_but_undurable"
| "committed_with_error"
| "destination_written_source_retained"
);
json!({
"path": item.section.path,
"destination": match &item.section.file_op { Some(FileOp::Move { dest }) => Some(dest), _ => None },
"operation": match item.section.file_op { Some(FileOp::Move { .. }) => "move", Some(FileOp::Remove) => "delete", None if item.after == item.normalized => "noop", None => "update" },
"status": status,
"added": if applied { item.added } else { 0 },
"removed": if applied { item.removed } else { 0 },
"stale_recovery": is_stale_recovery(item),
"error": error,
})
}
fn stale_metadata(stale: &super::recovery::StaleTagError, sections: usize) -> serde_json::Value {
json!({
"outcome": "stale_tag", "error_kind": "stale_tag", "path": stale.path,
"expected_tag": stale.expected, "current_tag": stale.current,
"sections": sections, "committed": 0, "added": 0, "removed": 0,
"stale_recovery": false, "files": [],
})
}
fn error_kind_for(error: &anyhow::Error) -> &'static str {
if crate::cancellation::is_run_canceled(error) {
"canceled"
} else if error
.downcast_ref::<crate::persistence::AtomicWriteCommittedButUndurable>()
.is_some()
{
"undurable"
} else {
"write"
}
}
fn current_mutation_paths(error: &anyhow::Error, item: &PreparedSection) -> Vec<PathBuf> {
if let Some(mutation) = error.downcast_ref::<FilesystemMutationError>() {
return mutation.changed_paths.clone();
}
if error
.downcast_ref::<crate::persistence::AtomicWriteCommittedButUndurable>()
.is_some()
{
return vec![item.dest.as_ref().unwrap_or(&item.source).clone()];
}
Vec::new()
}
fn operation_changes_disk(item: &PreparedSection) -> bool {
match item.section.file_op {
Some(FileOp::Move { .. } | FileOp::Remove) => true,
None => item.after != item.normalized,
}
}
fn commit_error(
prepared: &[PreparedSection],
results: &[SectionCommitResult],
index: usize,
error: anyhow::Error,
) -> PartialCommitError {
let current_operation_completed = error
.downcast_ref::<FilesystemMutationError>()
.is_some_and(|mutation| mutation.operation_completed);
let error_kind = error_kind_for(&error);
let current_paths = current_mutation_paths(&error, &prepared[index]);
let current_mutated = !current_paths.is_empty();
let current_operation_committed = current_operation_completed
|| (current_mutated
&& error_kind == "undurable"
&& !matches!(prepared[index].section.file_op, Some(FileOp::Move { .. })));
let committed = results.len() + usize::from(current_operation_committed);
let current_changed = current_mutated && operation_changes_disk(&prepared[index]);
let changed =
results.iter().filter(|result| result.changed).count() + usize::from(current_changed);
let canceled = error_kind == "canceled";
let error_message = error.to_string();
let files = prepared
.iter()
.enumerate()
.map(|(file_index, item)| {
let status = if file_index < index {
"committed"
} else if file_index == index && current_mutated {
if error_kind == "undurable"
&& matches!(item.section.file_op, Some(FileOp::Move { .. }))
{
"destination_written_source_retained"
} else if error_kind == "undurable" {
"committed_but_undurable"
} else if current_operation_completed {
"committed_with_error"
} else {
"partially_applied"
}
} else if file_index == index && canceled {
"canceled"
} else if file_index == index {
"failed"
} else {
"not_written"
};
file_metadata(
item,
status,
(file_index == index && !canceled).then_some(error_message.as_str()),
)
})
.collect::<Vec<_>>();
let mut touched_paths = results
.iter()
.flat_map(|result| result.touched_paths.iter().cloned())
.collect::<Vec<_>>();
touched_paths.extend(current_paths);
let outcome = if canceled && changed == 0 {
"canceled"
} else if committed > 0 || current_mutated {
"partial"
} else {
"failure"
};
PartialCommitError {
message: format!("hash_edit stopped after {committed} committed section(s): {error}"),
metadata: json!({
"outcome": outcome, "error_kind": error_kind,
"sections": prepared.len(), "committed": committed, "changed": changed,
"added": prepared[..index].iter().map(|item| item.added).sum::<usize>() + usize::from(current_mutated) * prepared[index].added,
"removed": prepared[..index].iter().map(|item| item.removed).sum::<usize>() + usize::from(current_mutated) * prepared[index].removed,
"stale_recovery": prepared.iter().take(index).any(is_stale_recovery) || (current_mutated && is_stale_recovery(&prepared[index])),
"files": files, "touched_paths": touched_paths.iter().map(|path| path.to_string_lossy()).collect::<Vec<_>>(),
}),
paths: touched_paths,
}
}
fn filesystem_mutation_error(
error: anyhow::Error,
paths: Vec<PathBuf>,
operation_completed: bool,
) -> anyhow::Error {
anyhow::Error::new(FilesystemMutationError {
source: error,
changed_paths: paths,
operation_completed,
})
}
impl ToolRuntime {
pub(in crate::tools) fn hash_edit_outcome(
&self,
args: HashEditArgs,
cancellation: &crate::cancellation::AgentCancellation,
) -> FilesystemOutcome {
match self.hash_edit_inner(args, cancellation) {
Ok(result) => result,
Err(error) => {
let metadata = if let Some(failure) = error.downcast_ref::<PartialCommitError>() {
failure.metadata.clone()
} else if let Some(stale) = error
.downcast_ref::<RecoveryError>()
.and_then(|failure| failure.stale.as_ref())
{
stale_metadata(stale, 0)
} else if crate::cancellation::is_run_canceled(&error) {
json!({
"outcome": "canceled", "error_kind": "canceled", "sections": 0,
"committed": 0, "changed": 0, "added": 0, "removed": 0,
"stale_recovery": false, "files": [], "touched_paths": [],
})
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let paths = error
.downcast_ref::<PartialCommitError>()
.map(|failure| failure.paths.clone())
.unwrap_or_default();
FilesystemOutcome {
result: ToolResult {
tool_name: tool_name::HASH_EDIT.to_string(),
success: false,
content: bounded_hash_edit_output(&error.to_string()),
metadata,
display: ToolResultDisplay::default(),
},
paths,
}
}
}
}
fn hash_edit_inner(
&self,
args: HashEditArgs,
cancellation: &crate::cancellation::AgentCancellation,
) -> anyhow::Result<FilesystemOutcome> {
cancellation.check()?;
if args.input.len() > FILE_EDIT_TEXT_MAX_BYTES {
anyhow::bail!(
"hash_edit input is {} bytes; limit is {FILE_EDIT_TEXT_MAX_BYTES} bytes",
args.input.len()
);
}
let sections = parse_input(
&args.input,
SplitOptions {
cwd: Some(self.cwd_canonical.clone()),
path: None,
},
)
.map_err(|error| anyhow::anyhow!(error))?;
if sections.is_empty() {
anyhow::bail!("hash_edit input contains no sections");
}
let section_count = sections.len();
let resolved = self.resolve_hash_edit_sections(sections)?;
let lock_paths = collect_lock_paths(&resolved)?;
let in_process_locks = lock_paths
.iter()
.map(|path| self.file_lock(path))
.collect::<anyhow::Result<Vec<_>>>()?;
let _in_process_guards = lock_in_process_paths(&lock_paths, &in_process_locks)?;
let _cross_process_guards = lock_paths
.iter()
.map(|path| CrossProcessFileLock::acquire(path))
.collect::<anyhow::Result<Vec<_>>>()?;
let prepared = match self.prepare_hash_edit_sections(resolved) {
Ok(prepared) => prepared,
Err(error) => {
if let Some(stale) = error
.downcast_ref::<RecoveryError>()
.and_then(|failure| failure.stale.as_ref())
{
return Err(anyhow::Error::new(PartialCommitError {
message: error.to_string(),
metadata: stale_metadata(stale, section_count),
paths: Vec::new(),
}));
}
return Err(error);
}
};
self.commit_hash_edit_sections(&prepared, args.input.len(), cancellation)
}
fn resolve_hash_edit_sections(
&self,
sections: Vec<ParsedSection>,
) -> anyhow::Result<Vec<ResolvedSection>> {
let mut resolved = Vec::with_capacity(sections.len());
for mut section in sections {
let mut source = match self.resolve_hash_edit_source(§ion.path) {
Ok(path) => path,
Err(error) => {
let Some(hash) = section.file_hash.as_deref() else {
return Err(error);
};
let store = self
.hashline_snapshots
.lock()
.map_err(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?;
let recovery = recover_path_by_tag(&store, Path::new(§ion.path), hash)
.map_err(|message| anyhow::anyhow!(message))?;
drop(store);
let Some(recovery) = recovery else {
return Err(error);
};
section.path = recovery.path.to_string_lossy().into_owned();
self.resolve_hash_edit_source(§ion.path)?
}
};
source = source.canonicalize()?;
let dest = match §ion.file_op {
Some(FileOp::Move { dest }) => {
let dest = self.resolve_hash_edit_move_dest(dest)?;
if source == dest {
anyhow::bail!("MV destination is the same as {}", source.display());
}
Some(dest)
}
Some(FileOp::Remove) | None => None,
};
resolved.push(ResolvedSection {
section,
source,
dest,
});
}
reject_duplicate_targets(&resolved)?;
Ok(resolved)
}
fn resolve_hash_edit_source(&self, path: &str) -> anyhow::Result<PathBuf> {
let candidate = candidate_path(&self.cwd, path);
if fs::symlink_metadata(&candidate)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
anyhow::bail!(
"hash_edit source '{}' must not be a symlink",
candidate.display()
);
}
let path = self.resolve_existing_path(
path,
ExistingPathPolicy::hash_edit(self.hashline_absolute_paths),
)?;
let metadata = fs::metadata(&path)?;
if metadata.is_dir() {
anyhow::bail!(
"hash_edit source '{}' must not be a directory",
path.display()
);
}
if !metadata.is_file() {
anyhow::bail!(
"hash_edit source '{}' must be a regular file",
path.display()
);
}
Ok(path)
}
fn resolve_hash_edit_move_dest(&self, path: &str) -> anyhow::Result<PathBuf> {
let user_path = PathBuf::from(path);
let is_absolute = user_path.is_absolute();
let candidate = if is_absolute {
user_path
} else {
self.cwd.join(user_path)
};
if let Ok(metadata) = fs::symlink_metadata(&candidate) {
if metadata.file_type().is_symlink() {
anyhow::bail!(
"MV destination '{}' must not be a symlink",
candidate.display()
);
}
anyhow::bail!("MV destination '{}' already exists", candidate.display());
}
let normalized = lexical_normalize(&candidate);
if (!is_absolute || !self.hashline_absolute_paths)
&& !normalized.starts_with(&self.cwd_canonical)
{
anyhow::bail!(
"path '{}' escapes cwd '{}' (tools.hash_edit.absolute_paths is false)",
normalized.display(),
self.cwd_canonical.display()
);
}
let parent = normalized
.parent()
.ok_or_else(|| anyhow::anyhow!("MV destination has no parent"))?;
if !parent.exists() {
anyhow::bail!(
"MV destination parent '{}' does not exist",
parent.display()
);
}
let parent_canonical = parent.canonicalize()?;
if !is_absolute || !self.hashline_absolute_paths {
self.ensure_inside_with_setting(&parent_canonical, "tools.hash_edit.absolute_paths")?;
}
Ok(parent_canonical.join(
normalized
.file_name()
.ok_or_else(|| anyhow::anyhow!("MV destination has no file name"))?,
))
}
fn prepare_hash_edit_sections(
&self,
resolved: Vec<ResolvedSection>,
) -> anyhow::Result<Vec<PreparedSection>> {
let mut prepared = Vec::with_capacity(resolved.len());
for item in resolved {
let metadata = fs::metadata(&item.source)?;
if metadata.len() > FILE_EDIT_TARGET_MAX_BYTES {
anyhow::bail!(
"hash_edit target '{}' is {} bytes; limit is {FILE_EDIT_TARGET_MAX_BYTES} bytes",
item.source.display(),
metadata.len()
);
}
let raw = fs::read_to_string(&item.source)
.with_context(|| format!("failed to read {}", item.source.display()))?;
let normalized = normalize_for_snapshot_storage(&raw);
let expected = item.section.file_hash.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"hash_edit section for {} is missing #TAG; read the file first",
item.section.path
)
})?;
let edits = if matches!(item.section.file_op, Some(FileOp::Remove)) {
&[]
} else {
item.section.edits.as_slice()
};
let mut store = self
.hashline_snapshots
.lock()
.map_err(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?;
let applied = apply_with_staleness_recovery(
&mut store,
&item.source,
&normalized,
expected,
edits,
)
.map_err(|error| {
anyhow::Error::new(error.with_display_path(Path::new(&item.section.path)))
})?;
drop(store);
if applied.text.len() > FILE_WRITE_MAX_BYTES {
anyhow::bail!(
"edited content for '{}' is {} bytes; write limit is {FILE_WRITE_MAX_BYTES} bytes",
item.source.display(),
applied.text.len()
);
}
let persisted = restore_original_shape(&raw, &applied.text);
if persisted.len() > FILE_WRITE_MAX_BYTES {
anyhow::bail!(
"persisted content for '{}' is {} bytes; write limit is {FILE_WRITE_MAX_BYTES} bytes",
item.source.display(),
persisted.len()
);
}
if let Some(parent) = item.dest.as_ref().and_then(|path| path.parent()) {
self.ensure_inside_with_setting(parent, "tools.hash_edit.absolute_paths")
.or_else(|error| {
if self.hashline_absolute_paths {
Ok(())
} else {
Err(error)
}
})?;
}
let pre_snapshot = self.pre_snapshot_bytes(&item.source)?;
let mut warnings = item.section.warnings.clone();
warnings.extend(applied.warnings);
let (added, removed) = line_stats(
&normalized,
&applied.text,
matches!(item.section.file_op, Some(FileOp::Remove)),
);
prepared.push(PreparedSection {
section: item.section,
source: item.source,
dest: item.dest,
normalized,
after: applied.text,
persisted,
pre_snapshot,
warnings,
block_resolutions: applied.block_resolutions,
first_changed_line: applied.first_changed_line,
added,
removed,
});
}
Ok(prepared)
}
fn commit_hash_edit_sections(
&self,
prepared: &[PreparedSection],
input_bytes: usize,
cancellation: &crate::cancellation::AgentCancellation,
) -> anyhow::Result<FilesystemOutcome> {
let mut results = Vec::new();
let mut diff = String::new();
let mut diff_truncated = false;
for (index, item) in prepared.iter().enumerate() {
if let Err(error) = cancellation.check() {
return Err(anyhow::Error::new(commit_error(
prepared, &results, index, error,
)));
}
match self.commit_one_hash_edit(item, cancellation) {
Ok(result) => {
if !diff_truncated {
diff_truncated = append_diff(
&mut diff,
&item.source,
item.dest.as_deref(),
&item.normalized,
&item.after,
);
}
results.push(result);
}
Err(error) => {
return Err(anyhow::Error::new(commit_error(
prepared, &results, index, error,
)));
}
}
}
let content = bounded_hash_edit_output(&hash_edit_success_content(&results, prepared));
let primary_path = results
.last()
.map(|result| result.path.clone())
.unwrap_or_default();
Ok(FilesystemOutcome {
result: ToolResult {
tool_name: tool_name::HASH_EDIT.to_string(),
success: true,
content,
metadata: json!({
(meta::PATH): primary_path,
"paths": results.iter().map(|result| result.path.clone()).collect::<Vec<_>>(),
"sections": prepared.len(), "input_bytes": input_bytes, "outcome": "success",
"committed": results.len(), "stale_recovery": prepared.iter().any(is_stale_recovery),
"added": prepared.iter().map(|item| item.added).sum::<usize>(),
"removed": prepared.iter().map(|item| item.removed).sum::<usize>(), "diff_truncated": diff_truncated,
"files": prepared.iter().map(|item| file_metadata(item, "committed", None)).collect::<Vec<_>>(),
}),
display: ToolResultDisplay {
edit_diff: Some(bounded_hash_edit_output(&diff)),
},
},
paths: results
.into_iter()
.flat_map(|result| result.touched_paths)
.collect(),
})
}
fn commit_one_hash_edit(
&self,
item: &PreparedSection,
cancellation: &crate::cancellation::AgentCancellation,
) -> anyhow::Result<SectionCommitResult> {
match &item.section.file_op {
Some(FileOp::Remove) => {
cancellation.check()?;
let metadata = fs::symlink_metadata(&item.source)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
anyhow::bail!(
"REM target '{}' must be a regular file",
item.source.display()
);
}
cancellation.check()?;
fs::remove_file(&item.source)?;
self.record_file_snapshot(
SnapshotTool::Edit,
&item.source,
item.pre_snapshot.as_deref(),
None,
);
self.hashline_snapshots
.lock()
.map_err(|_| {
filesystem_mutation_error(
anyhow::anyhow!("hashline snapshot store mutex poisoned"),
vec![item.source.clone()],
true,
)
})?
.invalidate(&item.source);
Ok(SectionCommitResult {
path: item.source.to_string_lossy().into_owned(),
header: format_hashline_header(
&item.source.to_string_lossy(),
&compute_file_hash(&item.normalized),
),
op: "delete".to_string(),
changed: true,
touched_paths: vec![item.source.clone()],
})
}
Some(FileOp::Move { .. }) => {
cancellation.check()?;
let dest = item.dest.as_ref().expect("move destination resolved");
if dest.exists() {
anyhow::bail!("MV destination '{}' already exists", dest.display());
}
atomic_write(dest, item.persisted.as_bytes())?;
if let Err(error) = fs::remove_file(&item.source) {
let rollback = fs::remove_file(dest);
return match rollback {
Ok(()) => Err(error.into()),
Err(rollback_error) => Err(filesystem_mutation_error(
anyhow::anyhow!(
"failed to remove move source '{}': {error}; failed to roll back destination '{}': {rollback_error}",
item.source.display(),
dest.display(),
),
vec![dest.clone()],
false,
)),
};
}
self.record_file_snapshot(
SnapshotTool::Edit,
&item.source,
item.pre_snapshot.as_deref(),
None,
);
self.record_file_snapshot(
SnapshotTool::WriteFile,
dest,
None,
Some(item.persisted.as_bytes()),
);
let mut store = self.hashline_snapshots.lock().map_err(|_| {
filesystem_mutation_error(
anyhow::anyhow!("hashline snapshot store mutex poisoned"),
vec![item.source.clone(), dest.clone()],
true,
)
})?;
store.relocate(&item.source, dest);
let tag = store.record(dest, &item.after, all_line_numbers(&item.after));
store.invalidate(&item.source);
Ok(SectionCommitResult {
path: dest.to_string_lossy().into_owned(),
header: format_hashline_header(&dest.to_string_lossy(), &tag),
op: "move".to_string(),
changed: true,
touched_paths: vec![item.source.clone(), dest.clone()],
})
}
None => {
cancellation.check()?;
atomic_write(&item.source, item.persisted.as_bytes())?;
self.record_file_snapshot(
SnapshotTool::Edit,
&item.source,
item.pre_snapshot.as_deref(),
Some(item.persisted.as_bytes()),
);
let tag = self
.hashline_snapshots
.lock()
.map_err(|_| {
filesystem_mutation_error(
anyhow::anyhow!("hashline snapshot store mutex poisoned"),
vec![item.source.clone()],
true,
)
})?
.record(&item.source, &item.after, all_line_numbers(&item.after));
Ok(SectionCommitResult {
path: item.source.to_string_lossy().into_owned(),
header: format_hashline_header(&item.source.to_string_lossy(), &tag),
op: if item.after == item.normalized {
"noop"
} else {
"update"
}
.to_string(),
changed: item.after != item.normalized,
touched_paths: vec![item.source.clone()],
})
}
}
}
}
#[derive(Debug, Clone)]
struct SectionCommitResult {
path: String,
header: String,
op: String,
changed: bool,
touched_paths: Vec<PathBuf>,
}
fn collect_lock_paths(resolved: &[ResolvedSection]) -> anyhow::Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for item in resolved {
paths.push(item.source.clone());
if let Some(dest) = &item.dest {
paths.push(dest.clone());
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn lock_in_process_paths<'a>(
paths: &[PathBuf],
locks: &'a [Arc<Mutex<()>>],
) -> anyhow::Result<Vec<MutexGuard<'a, ()>>> {
let mut guards = Vec::with_capacity(locks.len());
for (path, lock) in paths.iter().zip(locks) {
guards.push(lock.lock().map_err(|_| {
anyhow::anyhow!("file mutation lock poisoned for '{}'", path.display())
})?);
}
Ok(guards)
}
fn reject_duplicate_targets(resolved: &[ResolvedSection]) -> anyhow::Result<()> {
let mut seen_sources = HashMap::<PathBuf, String>::new();
let mut all_targets = HashSet::<PathBuf>::new();
for item in resolved {
if let Some(previous) = seen_sources.insert(item.source.clone(), item.section.path.clone())
{
anyhow::bail!(
"Multiple hashline sections resolve to the same file ({} and {}). Merge their ops under one header before applying.",
previous,
item.section.path
);
}
if !all_targets.insert(item.source.clone()) {
anyhow::bail!("duplicate hash_edit target '{}'", item.source.display());
}
if let Some(dest) = &item.dest
&& !all_targets.insert(dest.clone())
{
anyhow::bail!("duplicate hash_edit target '{}'", dest.display());
}
}
Ok(())
}
fn candidate_path(cwd: &Path, path: &str) -> PathBuf {
let user_path = PathBuf::from(path);
if user_path.is_absolute() {
user_path
} else {
cwd.join(user_path)
}
}
fn restore_original_shape(raw: &str, normalized_after: &str) -> String {
let bom = raw.starts_with('\u{FEFF}');
let without_bom = raw.strip_prefix('\u{FEFF}').unwrap_or(raw);
let crlf = without_bom.contains("\r\n");
let mut out = if crlf {
normalized_after.replace('\n', "\r\n")
} else {
normalized_after.to_string()
};
if bom {
out.insert(0, '\u{FEFF}');
}
out
}
fn all_line_numbers(text: &str) -> Vec<usize> {
let count = if text.is_empty() {
0
} else {
text.split_inclusive('\n').count()
};
(1..=count).collect()
}
fn hash_edit_success_content(
results: &[SectionCommitResult],
prepared: &[PreparedSection],
) -> String {
let mut lines = vec![format!("applied {} hash_edit section(s)", results.len())];
for result in results {
lines.push(format!("{} {}", result.op, result.header));
}
let warnings = prepared
.iter()
.flat_map(|entry| entry.warnings.iter())
.map(format_warning)
.collect::<Vec<_>>();
if !warnings.is_empty() {
lines.push("warnings:".to_string());
lines.extend(
warnings
.into_iter()
.take(20)
.map(|warning| format!("- {warning}")),
);
}
let resolutions = prepared
.iter()
.flat_map(|entry| entry.block_resolutions.iter())
.collect::<Vec<_>>();
if !resolutions.is_empty() {
lines.push("block resolutions:".to_string());
lines.extend(resolutions.into_iter().take(20).map(|resolution| {
format!(
"- {:?} anchor {} -> {}..{}",
resolution.op, resolution.anchor_line, resolution.start, resolution.end
)
}));
}
for entry in prepared {
if let Some(line) = entry.first_changed_line {
lines.push(format!(
"first_changed_line {}: {}",
entry.source.display(),
line
));
}
}
lines.join("\n")
}
fn line_stats(before: &str, after: &str, deleted: bool) -> (usize, usize) {
if deleted {
return (0, before.lines().count());
}
let diff = similar::TextDiff::from_lines(before, after);
diff.ops()
.iter()
.filter(|op| op.tag() != similar::DiffTag::Equal)
.fold((0, 0), |(added, removed), op| {
(added + op.new_range().len(), removed + op.old_range().len())
})
}
fn format_warning(warning: &ParseWarning) -> String {
match warning {
ParseWarning::BareBodyAutoPiped => {
"bare body rows were treated as payload lines".to_string()
}
ParseWarning::StrayDotSkipped { line_num } => {
format!("stray dot skipped at line {line_num}")
}
ParseWarning::ApplyRepair { message } => message.clone(),
}
}
fn append_diff(
out: &mut String,
source: &Path,
dest: Option<&Path>,
before: &str,
after: &str,
) -> bool {
if before == after && dest.is_none() {
return false;
}
if !out.is_empty() {
out.push('\n');
}
let display = dest.unwrap_or(source);
out.push_str(&format!(
"diff --git a/{} b/{}\n--- a/{}\n+++ b/{}",
source.display(),
display.display(),
source.display(),
display.display()
));
if out.len() > HASH_EDIT_OUTPUT_MAX_BYTES {
return true;
}
let diff = similar::TextDiff::from_lines(before, after);
for op in diff.ops() {
if op.tag() == similar::DiffTag::Equal {
continue;
}
let old_start = if op.old_range().is_empty() {
op.old_range().start
} else {
op.old_range().start + 1
};
let new_start = if op.new_range().is_empty() {
op.new_range().start
} else {
op.new_range().start + 1
};
out.push_str(&format!(
"\n@@ -{},{old_len} +{},{new_len} @@",
old_start,
new_start,
old_len = op.old_range().len(),
new_len = op.new_range().len(),
));
for change in diff.iter_changes(op) {
let (prefix, value) = match change.tag() {
similar::ChangeTag::Delete => ('-', change.value()),
similar::ChangeTag::Insert => ('+', change.value()),
similar::ChangeTag::Equal => (' ', change.value()),
};
out.push('\n');
out.push(prefix);
out.push_str(value.trim_end_matches(['\r', '\n']));
if out.len() > HASH_EDIT_OUTPUT_MAX_BYTES {
return true;
}
}
}
false
}
fn bounded_hash_edit_output(text: &str) -> String {
if text.len() <= HASH_EDIT_OUTPUT_MAX_BYTES {
return text.to_string();
}
let mut end = HASH_EDIT_OUTPUT_MAX_BYTES;
while !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n[truncated: hash_edit output exceeded {HASH_EDIT_OUTPUT_MAX_BYTES} bytes]",
&text[..end]
)
}
#[cfg(test)]
mod tests {
use super::super::{
format::compute_file_hash,
input::{SplitOptions, parse_input},
};
use super::*;
use crate::cancellation::AgentCancellation;
use crate::tools::ToolRuntime;
use serde_json::json;
use std::fs;
use std::sync::{Arc, atomic::AtomicBool};
fn read_tag(runtime: &ToolRuntime, path: &str, text: &str) -> String {
let canonical = runtime.cwd.join(path).canonicalize().unwrap();
runtime.hashline_snapshots.lock().unwrap().record(
&canonical,
text,
1..=text.lines().count(),
)
}
#[test]
fn hash_edit_success_writes_and_returns_fresh_tag() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\ntwo\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\ntwo\n");
let result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[a.txt#{tag}]\nSWAP 2.=2:\n+TWO")}),
);
assert!(result.success, "{}", result.content);
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"one\nTWO\n"
);
assert!(result.content.contains(&compute_file_hash("one\nTWO\n")));
assert!(result.display.edit_diff.unwrap().contains("-two"));
}
#[test]
fn hash_edit_cancellation_before_commit_leaves_move_unchanged() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("a.txt");
let dest = dir.path().join("b.txt");
fs::write(&source, "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let cancellation = AgentCancellation::new(Arc::new(AtomicBool::new(true)));
let error = runtime
.hash_edit_inner(
HashEditArgs {
input: format!("[a.txt#{tag}]\nMV b.txt"),
},
&cancellation,
)
.unwrap_err();
assert!(crate::cancellation::is_run_canceled(&error));
assert_eq!(fs::read_to_string(source).unwrap(), "one\n");
assert!(!dest.exists());
}
#[test]
fn hash_edit_move_commit_does_not_recheck_cancellation_after_mutation() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let input = format!("[a.txt#{tag}]\nMV b.txt");
let sections = parse_input(
&input,
SplitOptions {
cwd: Some(runtime.cwd_canonical.clone()),
path: None,
},
)
.unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
let (cancellation, handle) = AgentCancellation::default().child_token();
let result = runtime.commit_one_hash_edit(&prepared[0], &cancellation);
handle.cancel();
assert!(result.is_ok());
assert_eq!(
fs::read_to_string(dir.path().join("b.txt")).unwrap(),
"one\n"
);
assert!(!dir.path().join("a.txt").exists());
}
#[test]
fn hash_edit_validation_failure_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\ntwo\n").unwrap();
fs::write(dir.path().join("b.txt"), "red\nblue\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag_a = read_tag(&runtime, "a.txt", "one\ntwo\n");
let tag_b = read_tag(&runtime, "b.txt", "red\nblue\n");
let result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[a.txt#{tag_a}]\nSWAP 2.=2:\n+TWO\n[b.txt#{tag_b}]\nSWAP 9.=9:\n+NOPE")}),
);
assert!(!result.success);
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"one\ntwo\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("b.txt")).unwrap(),
"red\nblue\n"
);
}
#[test]
fn hash_edit_moves_and_relocates_snapshot() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[a.txt#{tag}]\nMV b.txt")}),
);
assert!(result.success, "{}", result.content);
assert!(!dir.path().join("a.txt").exists());
assert_eq!(
fs::read_to_string(dir.path().join("b.txt")).unwrap(),
"one\n"
);
let dest = dir.path().join("b.txt").canonicalize().unwrap();
assert!(
runtime
.hashline_snapshots
.lock()
.unwrap()
.head(&dest)
.is_some()
);
}
#[test]
fn hash_edit_removes_and_invalidates_snapshot() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let canonical = dir.path().join("a.txt").canonicalize().unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let result = runtime.dispatch("hash_edit", json!({"input": format!("[a.txt#{tag}]\nREM")}));
assert!(result.success, "{}", result.content);
assert!(!canonical.exists());
assert!(
runtime
.hashline_snapshots
.lock()
.unwrap()
.head(&canonical)
.is_none()
);
}
#[cfg(unix)]
#[test]
fn hash_edit_rejects_symlink_and_existing_destination() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("real.txt"), "one\n").unwrap();
fs::write(dir.path().join("dest.txt"), "dest\n").unwrap();
symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "real.txt", "one\n");
let symlink_result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[link.txt#{tag}]\nREM")}),
);
assert!(!symlink_result.success);
assert!(symlink_result.content.contains("symlink"));
let existing_dest = runtime.dispatch(
"hash_edit",
json!({"input": format!("[real.txt#{tag}]\nMV dest.txt")}),
);
assert!(!existing_dest.success);
assert!(existing_dest.content.contains("already exists"));
}
#[test]
fn hash_edit_success_metadata_covers_update_move_delete_and_noop() {
let dir = tempfile::tempdir().unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
fs::write(dir.path().join("update.txt"), "one\ntwo\n").unwrap();
let update_tag = read_tag(&runtime, "update.txt", "one\ntwo\n");
let update = runtime.dispatch(
"hash_edit",
json!({"input": format!("[update.txt#{update_tag}]\nSWAP 2.=2:\n+TWO")}),
);
assert!(update.success, "{}", update.content);
assert_eq!(update.metadata["outcome"], "success");
assert_eq!(update.metadata["sections"], 1);
assert_eq!(update.metadata["committed"], 1);
assert_eq!(update.metadata["added"], 1);
assert_eq!(update.metadata["removed"], 1);
assert_eq!(update.metadata["stale_recovery"], false);
assert_eq!(update.metadata["files"][0]["path"], "update.txt");
assert_eq!(update.metadata["files"][0]["operation"], "update");
assert_eq!(update.metadata["files"][0]["status"], "committed");
fs::write(dir.path().join("move.txt"), "one\n").unwrap();
let move_tag = read_tag(&runtime, "move.txt", "one\n");
let moved = runtime.dispatch(
"hash_edit",
json!({"input": format!("[move.txt#{move_tag}]\nMV moved.txt")}),
);
assert!(moved.success, "{}", moved.content);
assert_eq!(moved.metadata["files"][0]["path"], "move.txt");
assert_eq!(moved.metadata["files"][0]["destination"], "moved.txt");
assert_eq!(moved.metadata["files"][0]["operation"], "move");
assert_eq!(moved.metadata["added"], 0);
assert_eq!(moved.metadata["removed"], 0);
fs::write(dir.path().join("delete.txt"), "one\ntwo\n").unwrap();
let delete_tag = read_tag(&runtime, "delete.txt", "one\ntwo\n");
let deleted = runtime.dispatch(
"hash_edit",
json!({"input": format!("[delete.txt#{delete_tag}]\nREM")}),
);
assert!(deleted.success, "{}", deleted.content);
assert_eq!(deleted.metadata["files"][0]["operation"], "delete");
assert_eq!(deleted.metadata["added"], 0);
assert_eq!(deleted.metadata["removed"], 2);
fs::write(dir.path().join("noop.txt"), "same\n").unwrap();
let noop_tag = read_tag(&runtime, "noop.txt", "same\n");
let noop = runtime.dispatch(
"hash_edit",
json!({"input": format!("[noop.txt#{noop_tag}]\nSWAP 1.=1:\n+same")}),
);
assert!(noop.success, "{}", noop.content);
assert_eq!(noop.metadata["files"][0]["operation"], "noop");
assert_eq!(noop.metadata["added"], 0);
assert_eq!(noop.metadata["removed"], 0);
}
#[test]
fn hash_edit_stale_metadata_has_typed_tags_and_section_count() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let result = runtime.dispatch("hash_edit", json!({"input":"[a.txt#ABCD]\nDEL 1"}));
assert!(!result.success);
assert_eq!(result.metadata["outcome"], "stale_tag");
assert_eq!(result.metadata["error_kind"], "stale_tag");
assert_eq!(result.metadata["path"], "a.txt");
assert_eq!(result.metadata["expected_tag"], "ABCD");
assert_eq!(result.metadata["current_tag"], compute_file_hash("one\n"));
assert_eq!(result.metadata["sections"], 1);
assert_eq!(result.metadata["committed"], 0);
}
#[test]
fn hash_edit_success_metadata_marks_stale_recovery() {
let dir = tempfile::tempdir().unwrap();
let old = "one\ntwo\nthree\nfour\nfive\nsix\nseven\n";
fs::write(dir.path().join("a.txt"), old).unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", old);
fs::write(
dir.path().join("a.txt"),
"one\ntwo\nthree\nfour\nfive\nsix\nSEVEN\n",
)
.unwrap();
let result = runtime.dispatch(
"hash_edit",
json!({"input": format!("[a.txt#{tag}]\nSWAP 1.=1:\n+ONE")}),
);
assert!(result.success, "{}", result.content);
assert_eq!(result.metadata["stale_recovery"], true);
assert_eq!(result.metadata["files"][0]["stale_recovery"], true);
}
#[test]
fn hash_edit_partial_metadata_is_deterministic_after_prior_commit() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
fs::write(dir.path().join("b.txt"), "two\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag_a = read_tag(&runtime, "a.txt", "one\n");
let tag_b = read_tag(&runtime, "b.txt", "two\n");
let input = format!("[a.txt#{tag_a}]\nSWAP 1.=1:\n+ONE\n[b.txt#{tag_b}]\nMV c.txt");
let sections = parse_input(
&input,
SplitOptions {
cwd: Some(runtime.cwd_canonical.clone()),
path: None,
},
)
.unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
fs::write(dir.path().join("c.txt"), "race\n").unwrap();
let error = runtime
.commit_hash_edit_sections(&prepared, input.len(), &AgentCancellation::default())
.unwrap_err();
let partial = error.downcast_ref::<PartialCommitError>().unwrap();
let metadata = &partial.metadata;
assert_eq!(
partial.paths,
vec![dir.path().join("a.txt").canonicalize().unwrap()]
);
assert_eq!(metadata["outcome"], "partial");
assert_eq!(metadata["error_kind"], "write");
assert_eq!(metadata["sections"], 2);
assert_eq!(metadata["committed"], 1);
assert_eq!(metadata["changed"], 1);
assert_eq!(metadata["added"], 1);
assert_eq!(metadata["removed"], 1);
assert_eq!(metadata["files"][0]["status"], "committed");
assert_eq!(metadata["files"][1]["status"], "failed");
assert_eq!(metadata["files"][1]["destination"], "c.txt");
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"ONE\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("b.txt")).unwrap(),
"two\n"
);
assert_eq!(
fs::read_to_string(dir.path().join("c.txt")).unwrap(),
"race\n"
);
}
#[test]
fn hash_edit_partial_cancellation_metadata_preserves_committed_state() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
fs::write(dir.path().join("b.txt"), "two\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag_a = read_tag(&runtime, "a.txt", "one\n");
let tag_b = read_tag(&runtime, "b.txt", "two\n");
let input = format!("[a.txt#{tag_a}]\nSWAP 1.=1:\n+ONE\n[b.txt#{tag_b}]\nSWAP 1.=1:\n+TWO");
let sections = parse_input(&input, SplitOptions::default()).unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
let results = vec![SectionCommitResult {
path: "a.txt".to_string(),
header: "[a.txt#ABCD]".to_string(),
op: "update".to_string(),
changed: true,
touched_paths: vec![PathBuf::from("a.txt")],
}];
let cancellation = anyhow::Error::new(crate::cancellation::AgentRunCanceled);
let partial = commit_error(&prepared, &results, 1, cancellation);
assert_eq!(partial.metadata["error_kind"], "canceled");
assert_eq!(partial.metadata["committed"], 1);
assert_eq!(partial.metadata["changed"], 1);
assert_eq!(partial.metadata["files"][0]["status"], "committed");
assert_eq!(partial.metadata["files"][1]["status"], "canceled");
}
#[test]
fn hash_edit_first_pre_mutation_failure_is_not_partial() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let input = format!("[a.txt#{tag}]\nSWAP 1.=1:\n+ONE\nMV b.txt");
let sections = parse_input(&input, SplitOptions::default()).unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
fs::write(dir.path().join("b.txt"), "race\n").unwrap();
let error = runtime
.commit_hash_edit_sections(&prepared, input.len(), &AgentCancellation::default())
.unwrap_err();
let failure = error.downcast_ref::<PartialCommitError>().unwrap();
assert!(failure.paths.is_empty());
assert_eq!(failure.metadata["outcome"], "failure");
assert_eq!(failure.metadata["committed"], 0);
assert_eq!(failure.metadata["changed"], 0);
assert_eq!(failure.metadata["files"][0]["added"], 0);
assert_eq!(failure.metadata["files"][0]["removed"], 0);
assert_eq!(failure.metadata["files"][0]["status"], "failed");
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"one\n"
);
}
#[test]
fn hash_edit_first_post_mutation_failure_reports_disk_change() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let input = format!("[a.txt#{tag}]\nSWAP 1.=1:\n+ONE");
let sections = parse_input(&input, SplitOptions::default()).unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
let snapshots = Arc::clone(&runtime.hashline_snapshots);
let _ = std::thread::spawn(move || {
let _guard = snapshots.lock().unwrap();
panic!("poison snapshot store");
})
.join();
let error = runtime
.commit_hash_edit_sections(&prepared, input.len(), &AgentCancellation::default())
.unwrap_err();
let partial = error.downcast_ref::<PartialCommitError>().unwrap();
assert_eq!(
partial.paths,
vec![dir.path().join("a.txt").canonicalize().unwrap()]
);
assert_eq!(partial.metadata["outcome"], "partial");
assert_eq!(partial.metadata["committed"], 1);
assert_eq!(partial.metadata["changed"], 1);
assert_eq!(
partial.metadata["files"][0]["status"],
"committed_with_error"
);
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"ONE\n"
);
}
#[test]
fn hash_edit_noop_post_write_failure_does_not_claim_content_change() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "same\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "same\n");
let input = format!("[a.txt#{tag}]\nSWAP 1.=1:\n+same");
let sections = parse_input(&input, SplitOptions::default()).unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
let error = filesystem_mutation_error(
anyhow::anyhow!("snapshot failure"),
vec![prepared[0].source.clone()],
true,
);
let partial = commit_error(&prepared, &[], 0, error);
assert_eq!(partial.metadata["outcome"], "partial");
assert_eq!(partial.metadata["committed"], 1);
assert_eq!(partial.metadata["changed"], 0);
assert_eq!(
partial.metadata["files"][0]["status"],
"committed_with_error"
);
}
#[test]
fn hash_edit_undurable_mutations_keep_paths_and_commit_status() {
for (operation, target, committed, status) in [
("", "a.txt", 1, "committed_but_undurable"),
(
"\nMV b.txt",
"b.txt",
0,
"destination_written_source_retained",
),
] {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.txt"), "one\n").unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let tag = read_tag(&runtime, "a.txt", "one\n");
let input = format!("[a.txt#{tag}]\nSWAP 1.=1:\n+ONE{operation}");
let sections = parse_input(&input, SplitOptions::default()).unwrap();
let resolved = runtime.resolve_hash_edit_sections(sections).unwrap();
let prepared = runtime.prepare_hash_edit_sections(resolved).unwrap();
let target = dir.path().canonicalize().unwrap().join(target);
fs::write(&target, "ONE\n").unwrap();
let error =
crate::persistence::AtomicWriteCommittedButUndurable::test_error(target.clone());
let partial = commit_error(&prepared, &[], 0, error);
assert_eq!(partial.paths, vec![target]);
assert_eq!(partial.metadata["outcome"], "partial");
assert_eq!(partial.metadata["error_kind"], "undurable");
assert_eq!(partial.metadata["committed"], committed);
assert_eq!(partial.metadata["changed"], 1);
assert_eq!(partial.metadata["files"][0]["status"], status);
if !operation.is_empty() {
assert_eq!(
fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"one\n"
);
}
}
}
#[test]
fn hash_edit_zero_write_cancellation_has_typed_metadata() {
let dir = tempfile::tempdir().unwrap();
let runtime = ToolRuntime::new(dir.path()).unwrap();
let cancellation = AgentCancellation::new(Arc::new(AtomicBool::new(true)));
let outcome = runtime.hash_edit_outcome(
HashEditArgs {
input: "invalid but canceled first".to_string(),
},
&cancellation,
);
assert!(outcome.paths.is_empty());
let result = outcome.result;
assert!(!result.success);
assert_eq!(result.metadata["outcome"], "canceled");
assert_eq!(result.metadata["error_kind"], "canceled");
assert_eq!(result.metadata["committed"], 0);
assert_eq!(result.metadata["changed"], 0);
}
#[test]
fn hash_edit_diff_includes_changes_after_line_two_hundred() {
let before = (1..=250)
.map(|line| format!("line {line}"))
.collect::<Vec<_>>()
.join("\n");
let after = before.replace("line 225", "changed 225");
let mut diff = String::new();
assert!(!append_diff(
&mut diff,
Path::new("a.txt"),
None,
&before,
&after
));
assert!(diff.contains("-line 225"), "{diff}");
assert!(diff.contains("+changed 225"), "{diff}");
assert!(!diff.contains("-line 226"), "{diff}");
}
#[test]
fn hash_edit_diff_stops_after_activity_limit_and_formats_insert_range() {
let mut insertion = String::new();
assert!(!append_diff(
&mut insertion,
Path::new("a.txt"),
None,
"one\n",
"zero\none\n",
));
assert!(insertion.contains("@@ -0,0 +1,1 @@"), "{insertion}");
let before = "a".repeat(HASH_EDIT_OUTPUT_MAX_BYTES);
let after = "b".repeat(HASH_EDIT_OUTPUT_MAX_BYTES);
let mut bounded = String::new();
assert!(append_diff(
&mut bounded,
Path::new("a.txt"),
None,
&before,
&after,
));
assert!(bounded.len() <= HASH_EDIT_OUTPUT_MAX_BYTES * 2 + 256);
}
#[test]
fn hash_edit_output_cap_includes_truncation_marker() {
let text = "x\n".repeat(2000);
let bounded = bounded_hash_edit_output(&(text + &"y".repeat(HASH_EDIT_OUTPUT_MAX_BYTES)));
assert!(bounded.len() > HASH_EDIT_OUTPUT_MAX_BYTES);
assert!(bounded.contains("[truncated: hash_edit output exceeded"));
}
}