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},
fs::ExistingPathPolicy,
};
use crate::{
checkpoints::SnapshotTool,
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::{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>,
}
impl ToolRuntime {
pub(crate) fn hash_edit(
&self,
args: HashEditArgs,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> ToolResult {
match self.hash_edit_inner(args, cancellation) {
Ok(result) => result,
Err(error) => ToolResult {
tool_name: tool_name::HASH_EDIT.to_string(),
success: false,
content: bounded_hash_edit_output(&error.to_string()),
metadata: json!({}),
display: ToolResultDisplay::default(),
},
}
}
fn hash_edit_inner(
&self,
args: HashEditArgs,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<ToolResult> {
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 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 = self.prepare_hash_edit_sections(resolved)?;
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(|message| anyhow::anyhow!(message))?;
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);
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,
});
}
Ok(prepared)
}
fn commit_hash_edit_sections(
&self,
prepared: &[PreparedSection],
input_bytes: usize,
cancellation: &crate::agent::cancellation::AgentCancellation,
) -> anyhow::Result<ToolResult> {
let mut written = Vec::new();
let mut results = Vec::new();
let mut diff = String::new();
for (index, item) in prepared.iter().enumerate() {
cancellation.check()?;
match self.commit_one_hash_edit(item, cancellation) {
Ok(result) => {
append_diff(
&mut diff,
&item.source,
item.dest.as_deref(),
&item.normalized,
&item.after,
);
written.push(result.path.clone());
results.push(result);
}
Err(error) => {
let not_written = prepared[index + 1..]
.iter()
.map(|entry| entry.section.path.clone())
.collect::<Vec<_>>();
anyhow::bail!(
"Failed to write {}: {}{}{}",
item.section.path,
error,
if written.is_empty() {
String::new()
} else {
format!(". Sections already written: {}", written.join(", "))
},
if not_written.is_empty() {
String::new()
} else {
format!(". Sections not written: {}", not_written.join(", "))
},
);
}
}
}
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(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": results.len(),
"input_bytes": input_bytes,
}),
display: ToolResultDisplay {
edit_diff: Some(bounded_hash_edit_output(&diff)),
},
})
}
fn commit_one_hash_edit(
&self,
item: &PreparedSection,
cancellation: &crate::agent::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(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?
.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(),
})
}
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(anyhow::anyhow!(
"failed to remove move source '{}': {error}; failed to roll back destination '{}': {rollback_error}",
item.source.display(),
dest.display(),
)),
};
}
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(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?;
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(),
})
}
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(|_| anyhow::anyhow!("hashline snapshot store mutex poisoned"))?
.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(),
})
}
}
}
}
#[derive(Debug, Clone)]
struct SectionCommitResult {
path: String,
header: String,
op: String,
}
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 lexical_normalize(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
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 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) {
if before == after && dest.is_none() {
return;
}
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()
));
let old_lines = before.lines().collect::<Vec<_>>();
let new_lines = after.lines().collect::<Vec<_>>();
out.push_str(&format!(
"\n@@ -1,{} +1,{} @@",
old_lines.len(),
new_lines.len()
));
let max = old_lines.len().max(new_lines.len()).min(200);
for index in 0..max {
match (old_lines.get(index), new_lines.get(index)) {
(Some(old), Some(new)) if old == new => {
out.push('\n');
out.push(' ');
out.push_str(old);
}
(Some(old), Some(new)) => {
out.push('\n');
out.push('-');
out.push_str(old);
out.push('\n');
out.push('+');
out.push_str(new);
}
(Some(old), None) => {
out.push('\n');
out.push('-');
out.push_str(old);
}
(None, Some(new)) => {
out.push('\n');
out.push('+');
out.push_str(new);
}
(None, None) => {}
}
}
if old_lines.len().max(new_lines.len()) > max {
out.push_str("\n[truncated: diff preview limited]");
}
}
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::*;
use crate::{
agent::cancellation::AgentCancellation, tools::hash_edit::format::compute_file_hash,
};
use serde_json::json;
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::agent::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_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"));
}
}