mod output;
use output::hash_edit_success_content;
use output::line_stats;
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 output::append_diff;
use output::bounded_hash_edit_output;
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);
}
};
let prose_check = self.humanize_protection.as_ref().map(|protection| {
protection.check_changes(
prepared
.iter()
.filter(|item| !matches!(item.section.file_op, Some(FileOp::Remove)))
.map(|item| {
(
item.dest.as_deref().unwrap_or(&item.source),
item.normalized.as_str(),
item.after.as_str(),
)
}),
)
});
cancellation.check()?;
if let Some(result) = prose_check
.as_ref()
.and_then(|check| check.blocked_result(tool_name::HASH_EDIT))
{
return Ok(FilesystemOutcome {
result,
paths: Vec::new(),
});
}
let mut outcome =
self.commit_hash_edit_sections(&prepared, args.input.len(), cancellation)?;
if let Some(check) = prose_check {
outcome.result.metadata["humanize_protection"] = check.metadata;
}
Ok(outcome)
}
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()
}