Skip to main content

codineer_runtime/
file_ops.rs

1use std::cmp::Reverse;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use std::fmt;
8
9use glob::Pattern;
10use regex::RegexBuilder;
11use serde::{Deserialize, Serialize};
12use walkdir::WalkDir;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum GrepOutputMode {
17    #[default]
18    FilesWithMatches,
19    Content,
20    Count,
21}
22
23impl fmt::Display for GrepOutputMode {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::FilesWithMatches => f.write_str("files_with_matches"),
27            Self::Content => f.write_str("content"),
28            Self::Count => f.write_str("count"),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TextFilePayload {
35    #[serde(rename = "filePath")]
36    pub file_path: String,
37    pub content: String,
38    #[serde(rename = "numLines")]
39    pub num_lines: usize,
40    #[serde(rename = "startLine")]
41    pub start_line: usize,
42    #[serde(rename = "totalLines")]
43    pub total_lines: usize,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct ReadFileOutput {
48    #[serde(rename = "type")]
49    pub kind: String,
50    pub file: TextFilePayload,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct StructuredPatchHunk {
55    #[serde(rename = "oldStart")]
56    pub old_start: usize,
57    #[serde(rename = "oldLines")]
58    pub old_lines: usize,
59    #[serde(rename = "newStart")]
60    pub new_start: usize,
61    #[serde(rename = "newLines")]
62    pub new_lines: usize,
63    pub lines: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct WriteFileOutput {
68    #[serde(rename = "type")]
69    pub kind: String,
70    #[serde(rename = "filePath")]
71    pub file_path: String,
72    pub content: String,
73    #[serde(rename = "structuredPatch")]
74    pub structured_patch: Vec<StructuredPatchHunk>,
75    #[serde(rename = "originalFile")]
76    pub original_file: Option<String>,
77    #[serde(rename = "gitDiff")]
78    pub git_diff: Option<serde_json::Value>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct EditFileOutput {
83    #[serde(rename = "filePath")]
84    pub file_path: String,
85    #[serde(rename = "oldString")]
86    pub old_string: String,
87    #[serde(rename = "newString")]
88    pub new_string: String,
89    #[serde(rename = "originalFile")]
90    pub original_file: String,
91    #[serde(rename = "structuredPatch")]
92    pub structured_patch: Vec<StructuredPatchHunk>,
93    #[serde(rename = "userModified")]
94    pub user_modified: bool,
95    #[serde(rename = "replaceAll")]
96    pub replace_all: bool,
97    #[serde(rename = "gitDiff")]
98    pub git_diff: Option<serde_json::Value>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct GlobSearchOutput {
103    #[serde(rename = "durationMs")]
104    pub duration_ms: u128,
105    #[serde(rename = "numFiles")]
106    pub num_files: usize,
107    pub filenames: Vec<String>,
108    pub truncated: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct GrepSearchInput {
113    pub pattern: String,
114    pub path: Option<String>,
115    pub glob: Option<String>,
116    #[serde(rename = "output_mode")]
117    pub output_mode: Option<GrepOutputMode>,
118    #[serde(rename = "-B")]
119    pub before: Option<usize>,
120    #[serde(rename = "-A")]
121    pub after: Option<usize>,
122    #[serde(rename = "-C")]
123    pub context_short: Option<usize>,
124    pub context: Option<usize>,
125    #[serde(rename = "-n")]
126    pub line_numbers: Option<bool>,
127    #[serde(rename = "-i")]
128    pub case_insensitive: Option<bool>,
129    #[serde(rename = "type")]
130    pub file_type: Option<String>,
131    pub head_limit: Option<usize>,
132    pub offset: Option<usize>,
133    pub multiline: Option<bool>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct GrepSearchOutput {
138    pub mode: Option<GrepOutputMode>,
139    #[serde(rename = "numFiles")]
140    pub num_files: usize,
141    pub filenames: Vec<String>,
142    pub content: Option<String>,
143    #[serde(rename = "numLines")]
144    pub num_lines: Option<usize>,
145    #[serde(rename = "numMatches")]
146    pub num_matches: Option<usize>,
147    #[serde(rename = "appliedLimit")]
148    pub applied_limit: Option<usize>,
149    #[serde(rename = "appliedOffset")]
150    pub applied_offset: Option<usize>,
151}
152
153pub fn read_file(
154    path: &str,
155    offset: Option<usize>,
156    limit: Option<usize>,
157) -> io::Result<ReadFileOutput> {
158    let absolute_path = normalize_path(path)?;
159    let content = fs::read_to_string(&absolute_path)?;
160    let lines: Vec<&str> = content.lines().collect();
161    let start_index = offset.unwrap_or(0).min(lines.len());
162    let end_index = limit.map_or(lines.len(), |limit| {
163        start_index.saturating_add(limit).min(lines.len())
164    });
165    let selected = lines[start_index..end_index].join("\n");
166
167    Ok(ReadFileOutput {
168        kind: String::from("text"),
169        file: TextFilePayload {
170            file_path: absolute_path.to_string_lossy().into_owned(),
171            content: selected,
172            num_lines: end_index.saturating_sub(start_index),
173            start_line: start_index.saturating_add(1),
174            total_lines: lines.len(),
175        },
176    })
177}
178
179pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
180    let absolute_path = normalize_path_allow_missing(path)?;
181    let original_file = fs::read_to_string(&absolute_path).ok();
182    if let Some(parent) = absolute_path.parent() {
183        fs::create_dir_all(parent)?;
184    }
185    fs::write(&absolute_path, content)?;
186
187    Ok(WriteFileOutput {
188        kind: if original_file.is_some() {
189            String::from("update")
190        } else {
191            String::from("create")
192        },
193        file_path: absolute_path.to_string_lossy().into_owned(),
194        content: content.to_owned(),
195        structured_patch: make_patch(original_file.as_deref().unwrap_or(""), content),
196        original_file,
197        git_diff: None,
198    })
199}
200
201pub fn edit_file(
202    path: &str,
203    old_string: &str,
204    new_string: &str,
205    replace_all: bool,
206) -> io::Result<EditFileOutput> {
207    let absolute_path = normalize_path(path)?;
208    let original_file = fs::read_to_string(&absolute_path)?;
209    if old_string == new_string {
210        return Err(io::Error::new(
211            io::ErrorKind::InvalidInput,
212            "old_string and new_string must differ",
213        ));
214    }
215    if !original_file.contains(old_string) {
216        return Err(io::Error::new(
217            io::ErrorKind::NotFound,
218            "old_string not found in file",
219        ));
220    }
221
222    let updated = if replace_all {
223        original_file.replace(old_string, new_string)
224    } else {
225        original_file.replacen(old_string, new_string, 1)
226    };
227    fs::write(&absolute_path, &updated)?;
228
229    Ok(EditFileOutput {
230        file_path: absolute_path.to_string_lossy().into_owned(),
231        old_string: old_string.to_owned(),
232        new_string: new_string.to_owned(),
233        original_file: original_file.clone(),
234        structured_patch: make_patch(&original_file, &updated),
235        user_modified: false,
236        replace_all,
237        git_diff: None,
238    })
239}
240
241pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
242    let started = Instant::now();
243    let base_dir = path
244        .map(normalize_path)
245        .transpose()?
246        .unwrap_or(std::env::current_dir()?);
247    let search_pattern = if Path::new(pattern).is_absolute() {
248        enforce_workspace_boundary(Path::new(pattern))?;
249        pattern.to_owned()
250    } else {
251        base_dir.join(pattern).to_string_lossy().into_owned()
252    };
253
254    let mut matches = Vec::new();
255    let entries = glob::glob(&search_pattern)
256        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
257    for entry in entries.flatten() {
258        if entry.is_file() {
259            matches.push(entry);
260        }
261    }
262
263    matches.sort_by_key(|path| {
264        fs::metadata(path)
265            .and_then(|metadata| metadata.modified())
266            .ok()
267            .map(Reverse)
268    });
269
270    let truncated = matches.len() > 100;
271    let filenames = matches
272        .into_iter()
273        .take(100)
274        .map(|path| path.to_string_lossy().into_owned())
275        .collect::<Vec<_>>();
276
277    Ok(GlobSearchOutput {
278        duration_ms: started.elapsed().as_millis(),
279        num_files: filenames.len(),
280        filenames,
281        truncated,
282    })
283}
284
285pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
286    let base_path = input
287        .path
288        .as_deref()
289        .map(normalize_path)
290        .transpose()?
291        .unwrap_or(std::env::current_dir()?);
292
293    let regex = RegexBuilder::new(&input.pattern)
294        .case_insensitive(input.case_insensitive.unwrap_or(false))
295        .dot_matches_new_line(input.multiline.unwrap_or(false))
296        .build()
297        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
298
299    let glob_filter = input
300        .glob
301        .as_deref()
302        .map(Pattern::new)
303        .transpose()
304        .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
305    let file_type = input.file_type.as_deref();
306    let output_mode = input.output_mode.unwrap_or_default();
307    let context = input.context.or(input.context_short).unwrap_or(0);
308
309    let mut filenames = Vec::new();
310    let mut content_lines = Vec::new();
311    let mut total_matches = 0usize;
312
313    for file_path in collect_search_files(&base_path)? {
314        if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
315            continue;
316        }
317
318        let Ok(file_contents) = fs::read_to_string(&file_path) else {
319            continue;
320        };
321
322        if output_mode == GrepOutputMode::Count {
323            let count = regex.find_iter(&file_contents).count();
324            if count > 0 {
325                filenames.push(file_path.to_string_lossy().into_owned());
326                total_matches += count;
327            }
328            continue;
329        }
330
331        let lines: Vec<&str> = file_contents.lines().collect();
332        let mut matched_lines = Vec::new();
333        for (index, line) in lines.iter().enumerate() {
334            if regex.is_match(line) {
335                total_matches += 1;
336                matched_lines.push(index);
337            }
338        }
339
340        if matched_lines.is_empty() {
341            continue;
342        }
343
344        filenames.push(file_path.to_string_lossy().into_owned());
345        if output_mode == GrepOutputMode::Content {
346            let mut emitted = std::collections::BTreeSet::new();
347            for index in matched_lines {
348                let start = index.saturating_sub(input.before.unwrap_or(context));
349                let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
350                for (current, line) in lines.iter().enumerate().take(end).skip(start) {
351                    if !emitted.insert(current) {
352                        continue;
353                    }
354                    let prefix = if input.line_numbers.unwrap_or(true) {
355                        format!("{}:{}:", file_path.to_string_lossy(), current + 1)
356                    } else {
357                        format!("{}:", file_path.to_string_lossy())
358                    };
359                    content_lines.push(format!("{prefix}{line}"));
360                }
361            }
362        }
363    }
364
365    let (filenames, applied_limit, applied_offset) =
366        apply_limit(filenames, input.head_limit, input.offset);
367    let content_output = if output_mode == GrepOutputMode::Content {
368        let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
369        return Ok(GrepSearchOutput {
370            mode: Some(GrepOutputMode::Content),
371            num_files: filenames.len(),
372            filenames,
373            num_lines: Some(lines.len()),
374            content: Some(lines.join("\n")),
375            num_matches: Some(total_matches),
376            applied_limit: limit,
377            applied_offset: offset,
378        });
379    } else {
380        None
381    };
382
383    Ok(GrepSearchOutput {
384        mode: Some(output_mode),
385        num_files: filenames.len(),
386        filenames,
387        content: content_output,
388        num_lines: None,
389        num_matches: (output_mode == GrepOutputMode::Count).then_some(total_matches),
390        applied_limit,
391        applied_offset,
392    })
393}
394
395fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
396    if base_path.is_file() {
397        return Ok(vec![base_path.to_path_buf()]);
398    }
399
400    let mut files = Vec::new();
401    for entry in WalkDir::new(base_path) {
402        let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
403        if entry.file_type().is_file() {
404            files.push(entry.path().to_path_buf());
405        }
406    }
407    Ok(files)
408}
409
410fn matches_optional_filters(
411    path: &Path,
412    glob_filter: Option<&Pattern>,
413    file_type: Option<&str>,
414) -> bool {
415    if let Some(glob_filter) = glob_filter {
416        let path_string = path.to_string_lossy();
417        if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
418            return false;
419        }
420    }
421
422    if let Some(file_type) = file_type {
423        let extension = path.extension().and_then(|extension| extension.to_str());
424        if extension != Some(file_type) {
425            return false;
426        }
427    }
428
429    true
430}
431
432fn apply_limit<T>(
433    items: Vec<T>,
434    limit: Option<usize>,
435    offset: Option<usize>,
436) -> (Vec<T>, Option<usize>, Option<usize>) {
437    let offset_value = offset.unwrap_or(0);
438    let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
439    let explicit_limit = limit.unwrap_or(250);
440    if explicit_limit == 0 {
441        return (items, None, (offset_value > 0).then_some(offset_value));
442    }
443
444    let truncated = items.len() > explicit_limit;
445    items.truncate(explicit_limit);
446    (
447        items,
448        truncated.then_some(explicit_limit),
449        (offset_value > 0).then_some(offset_value),
450    )
451}
452
453fn make_patch(original: &str, updated: &str) -> Vec<StructuredPatchHunk> {
454    let mut lines = Vec::new();
455    for line in original.lines() {
456        lines.push(format!("-{line}"));
457    }
458    for line in updated.lines() {
459        lines.push(format!("+{line}"));
460    }
461
462    vec![StructuredPatchHunk {
463        old_start: 1,
464        old_lines: original.lines().count(),
465        new_start: 1,
466        new_lines: updated.lines().count(),
467        lines,
468    }]
469}
470
471fn workspace_root() -> io::Result<PathBuf> {
472    if let Ok(override_root) = std::env::var("CODINEER_WORKSPACE_ROOT") {
473        return Ok(PathBuf::from(override_root));
474    }
475    std::env::current_dir()
476}
477
478fn enforce_workspace_boundary(resolved: &Path) -> io::Result<()> {
479    let root = dunce::canonicalize(workspace_root()?).map_err(|e| {
480        io::Error::new(
481            io::ErrorKind::NotFound,
482            format!("cannot resolve workspace root: {e}"),
483        )
484    })?;
485    if root.as_os_str().is_empty() || !resolved.starts_with(&root) {
486        return Err(io::Error::new(
487            io::ErrorKind::PermissionDenied,
488            format!(
489                "path '{}' is outside the workspace root '{}'; access denied",
490                resolved.display(),
491                root.display(),
492            ),
493        ));
494    }
495    Ok(())
496}
497
498fn normalize_path(path: &str) -> io::Result<PathBuf> {
499    let candidate = if Path::new(path).is_absolute() {
500        PathBuf::from(path)
501    } else {
502        std::env::current_dir()?.join(path)
503    };
504    match dunce::canonicalize(&candidate) {
505        Ok(resolved) => {
506            enforce_workspace_boundary(&resolved)?;
507            Ok(resolved)
508        }
509        Err(err) if err.kind() == io::ErrorKind::NotFound => {
510            // The file does not exist yet; canonicalize() cannot resolve it.
511            // Compare the raw candidate against the raw (non-canonical) workspace
512            // root so that both sides are consistently unresolved and symlinks do
513            // not skew the comparison.  This ensures we return PermissionDenied
514            // rather than NotFound for paths that are clearly outside the workspace
515            // (e.g. /etc/passwd on Windows where it does not exist).
516            let root = workspace_root()?;
517            if !candidate.starts_with(&root) {
518                return Err(io::Error::new(
519                    io::ErrorKind::PermissionDenied,
520                    format!(
521                        "path '{}' is outside the workspace root '{}'; access denied",
522                        candidate.display(),
523                        root.display(),
524                    ),
525                ));
526            }
527            Err(err)
528        }
529        Err(err) => Err(err),
530    }
531}
532
533fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
534    let candidate = if Path::new(path).is_absolute() {
535        PathBuf::from(path)
536    } else {
537        std::env::current_dir()?.join(path)
538    };
539
540    if let Ok(canonical) = dunce::canonicalize(&candidate) {
541        enforce_workspace_boundary(&canonical)?;
542        return Ok(canonical);
543    }
544
545    // Walk up the path to find the deepest existing ancestor, canonicalize it
546    // (which resolves UNC prefixes and 8.3 short names on Windows), then
547    // re-attach the remaining suffix so the boundary check operates on fully
548    // resolved paths and avoids false positives from RUNNER~1 vs runneradmin.
549    let mut ancestor = candidate.clone();
550    let mut suffix = PathBuf::new();
551    loop {
552        if let Ok(canonical_ancestor) = dunce::canonicalize(&ancestor) {
553            enforce_workspace_boundary(&canonical_ancestor)?;
554            return Ok(if suffix.as_os_str().is_empty() {
555                canonical_ancestor
556            } else {
557                canonical_ancestor.join(&suffix)
558            });
559        }
560        match ancestor.file_name() {
561            Some(name) => {
562                suffix = if suffix.as_os_str().is_empty() {
563                    PathBuf::from(name)
564                } else {
565                    PathBuf::from(name).join(&suffix)
566                };
567            }
568            None => break,
569        }
570        match ancestor.parent() {
571            Some(parent) if !parent.as_os_str().is_empty() => {
572                ancestor = parent.to_path_buf();
573            }
574            _ => break,
575        }
576    }
577
578    Ok(candidate)
579}
580
581#[cfg(test)]
582mod tests {
583    use std::time::{SystemTime, UNIX_EPOCH};
584
585    use super::{
586        edit_file, glob_search, grep_search, read_file, write_file, GrepOutputMode, GrepSearchInput,
587    };
588
589    fn workspace_dir() -> std::path::PathBuf {
590        std::env::temp_dir().join("codineer-test-workspace")
591    }
592
593    fn temp_path(name: &str) -> std::path::PathBuf {
594        let unique = SystemTime::now()
595            .duration_since(UNIX_EPOCH)
596            .expect("time should move forward")
597            .as_nanos();
598        workspace_dir().join(format!("codineer-native-{name}-{unique}"))
599    }
600
601    fn allow_temp_workspace() {
602        let ws = workspace_dir();
603        std::fs::create_dir_all(&ws).ok();
604        let ws = ws.canonicalize().unwrap_or(ws);
605        std::env::set_var("CODINEER_WORKSPACE_ROOT", ws);
606    }
607
608    #[test]
609    fn reads_and_writes_files() {
610        allow_temp_workspace();
611        let path = temp_path("read-write.txt");
612        let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
613            .expect("write should succeed");
614        assert_eq!(write_output.kind, "create");
615
616        let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
617            .expect("read should succeed");
618        assert_eq!(read_output.file.content, "two");
619    }
620
621    #[test]
622    fn edits_file_contents() {
623        allow_temp_workspace();
624        let path = temp_path("edit.txt");
625        write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
626            .expect("initial write should succeed");
627        let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
628            .expect("edit should succeed");
629        assert!(output.replace_all);
630    }
631
632    #[test]
633    fn globs_and_greps_directory() {
634        allow_temp_workspace();
635        let dir = temp_path("search-dir");
636        std::fs::create_dir_all(&dir).expect("directory should be created");
637        let file = dir.join("demo.rs");
638        write_file(
639            file.to_string_lossy().as_ref(),
640            "fn main() {\n println!(\"hello\");\n}\n",
641        )
642        .expect("file write should succeed");
643
644        let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
645            .expect("glob should succeed");
646        assert_eq!(globbed.num_files, 1);
647
648        let grep_output = grep_search(&GrepSearchInput {
649            pattern: String::from("hello"),
650            path: Some(dir.to_string_lossy().into_owned()),
651            glob: Some(String::from("**/*.rs")),
652            output_mode: Some(GrepOutputMode::Content),
653            before: None,
654            after: None,
655            context_short: None,
656            context: None,
657            line_numbers: Some(true),
658            case_insensitive: Some(false),
659            file_type: None,
660            head_limit: Some(10),
661            offset: Some(0),
662            multiline: Some(false),
663        })
664        .expect("grep should succeed");
665        assert!(grep_output.content.unwrap_or_default().contains("hello"));
666    }
667
668    #[test]
669    #[cfg(unix)]
670    fn rejects_absolute_path_outside_workspace() {
671        allow_temp_workspace();
672        let result = read_file("/etc/passwd", None, None);
673        assert!(result.is_err());
674        let err = result.unwrap_err();
675        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
676    }
677
678    #[test]
679    #[cfg(windows)]
680    fn rejects_absolute_path_outside_workspace_windows() {
681        allow_temp_workspace();
682        let result = read_file("C:\\Windows\\System32\\drivers\\etc\\hosts", None, None);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn rejects_relative_path_traversal_above_workspace() {
688        allow_temp_workspace();
689        let result = read_file("../../../etc/passwd", None, None);
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn rejects_write_outside_workspace() {
695        allow_temp_workspace();
696        let sentinel = std::env::temp_dir().join("codineer-test-outside-sentinel");
697        let sentinel_str = sentinel.to_string_lossy().to_string();
698        let result = write_file(&sentinel_str, "malicious");
699        let denied = result.is_err()
700            && result
701                .as_ref()
702                .unwrap_err()
703                .kind()
704                .eq(&std::io::ErrorKind::PermissionDenied);
705        if !denied {
706            let _ = std::fs::remove_file(&sentinel);
707        }
708        assert!(denied, "write outside workspace must be denied");
709    }
710
711    #[test]
712    fn allows_operations_within_workspace() {
713        allow_temp_workspace();
714        let path = temp_path("inside-workspace.txt");
715        write_file(path.to_string_lossy().as_ref(), "safe content")
716            .expect("write within workspace should succeed");
717        let read_output = read_file(path.to_string_lossy().as_ref(), None, None)
718            .expect("read within workspace should succeed");
719        assert_eq!(read_output.file.content, "safe content");
720    }
721
722    #[test]
723    fn edit_rejects_identical_old_and_new_string() {
724        allow_temp_workspace();
725        let path = temp_path("edit-reject.txt");
726        write_file(path.to_string_lossy().as_ref(), "content").expect("write");
727        let result = edit_file(path.to_string_lossy().as_ref(), "content", "content", false);
728        assert!(result.is_err());
729        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
730    }
731
732    #[test]
733    fn edit_rejects_missing_old_string() {
734        allow_temp_workspace();
735        let path = temp_path("edit-missing.txt");
736        write_file(path.to_string_lossy().as_ref(), "alpha beta").expect("write");
737        let result = edit_file(path.to_string_lossy().as_ref(), "gamma", "delta", false);
738        assert!(result.is_err());
739        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
740    }
741}