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()?)
480        .unwrap_or_else(|_| workspace_root().unwrap_or_default());
481    if !resolved.starts_with(&root) {
482        return Err(io::Error::new(
483            io::ErrorKind::PermissionDenied,
484            format!(
485                "path '{}' is outside the workspace root '{}'; access denied",
486                resolved.display(),
487                root.display(),
488            ),
489        ));
490    }
491    Ok(())
492}
493
494fn normalize_path(path: &str) -> io::Result<PathBuf> {
495    let candidate = if Path::new(path).is_absolute() {
496        PathBuf::from(path)
497    } else {
498        std::env::current_dir()?.join(path)
499    };
500    match dunce::canonicalize(&candidate) {
501        Ok(resolved) => {
502            enforce_workspace_boundary(&resolved)?;
503            Ok(resolved)
504        }
505        Err(err) if err.kind() == io::ErrorKind::NotFound => {
506            // The file does not exist yet; canonicalize() cannot resolve it.
507            // Compare the raw candidate against the raw (non-canonical) workspace
508            // root so that both sides are consistently unresolved and symlinks do
509            // not skew the comparison.  This ensures we return PermissionDenied
510            // rather than NotFound for paths that are clearly outside the workspace
511            // (e.g. /etc/passwd on Windows where it does not exist).
512            let root = workspace_root()?;
513            if !candidate.starts_with(&root) {
514                return Err(io::Error::new(
515                    io::ErrorKind::PermissionDenied,
516                    format!(
517                        "path '{}' is outside the workspace root '{}'; access denied",
518                        candidate.display(),
519                        root.display(),
520                    ),
521                ));
522            }
523            Err(err)
524        }
525        Err(err) => Err(err),
526    }
527}
528
529fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
530    let candidate = if Path::new(path).is_absolute() {
531        PathBuf::from(path)
532    } else {
533        std::env::current_dir()?.join(path)
534    };
535
536    if let Ok(canonical) = dunce::canonicalize(&candidate) {
537        enforce_workspace_boundary(&canonical)?;
538        return Ok(canonical);
539    }
540
541    // Walk up the path to find the deepest existing ancestor, canonicalize it
542    // (which resolves UNC prefixes and 8.3 short names on Windows), then
543    // re-attach the remaining suffix so the boundary check operates on fully
544    // resolved paths and avoids false positives from RUNNER~1 vs runneradmin.
545    let mut ancestor = candidate.clone();
546    let mut suffix = PathBuf::new();
547    loop {
548        if let Ok(canonical_ancestor) = dunce::canonicalize(&ancestor) {
549            enforce_workspace_boundary(&canonical_ancestor)?;
550            return Ok(if suffix.as_os_str().is_empty() {
551                canonical_ancestor
552            } else {
553                canonical_ancestor.join(&suffix)
554            });
555        }
556        match ancestor.file_name() {
557            Some(name) => {
558                suffix = if suffix.as_os_str().is_empty() {
559                    PathBuf::from(name)
560                } else {
561                    PathBuf::from(name).join(&suffix)
562                };
563            }
564            None => break,
565        }
566        match ancestor.parent() {
567            Some(parent) if !parent.as_os_str().is_empty() => {
568                ancestor = parent.to_path_buf();
569            }
570            _ => break,
571        }
572    }
573
574    Ok(candidate)
575}
576
577#[cfg(test)]
578mod tests {
579    use std::time::{SystemTime, UNIX_EPOCH};
580
581    use super::{
582        edit_file, glob_search, grep_search, read_file, write_file, GrepOutputMode, GrepSearchInput,
583    };
584
585    fn workspace_dir() -> std::path::PathBuf {
586        std::env::temp_dir().join("codineer-test-workspace")
587    }
588
589    fn temp_path(name: &str) -> std::path::PathBuf {
590        let unique = SystemTime::now()
591            .duration_since(UNIX_EPOCH)
592            .expect("time should move forward")
593            .as_nanos();
594        workspace_dir().join(format!("codineer-native-{name}-{unique}"))
595    }
596
597    fn allow_temp_workspace() {
598        let ws = workspace_dir();
599        std::fs::create_dir_all(&ws).ok();
600        let ws = ws.canonicalize().unwrap_or(ws);
601        std::env::set_var("CODINEER_WORKSPACE_ROOT", ws);
602    }
603
604    #[test]
605    fn reads_and_writes_files() {
606        allow_temp_workspace();
607        let path = temp_path("read-write.txt");
608        let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
609            .expect("write should succeed");
610        assert_eq!(write_output.kind, "create");
611
612        let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
613            .expect("read should succeed");
614        assert_eq!(read_output.file.content, "two");
615    }
616
617    #[test]
618    fn edits_file_contents() {
619        allow_temp_workspace();
620        let path = temp_path("edit.txt");
621        write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
622            .expect("initial write should succeed");
623        let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
624            .expect("edit should succeed");
625        assert!(output.replace_all);
626    }
627
628    #[test]
629    fn globs_and_greps_directory() {
630        allow_temp_workspace();
631        let dir = temp_path("search-dir");
632        std::fs::create_dir_all(&dir).expect("directory should be created");
633        let file = dir.join("demo.rs");
634        write_file(
635            file.to_string_lossy().as_ref(),
636            "fn main() {\n println!(\"hello\");\n}\n",
637        )
638        .expect("file write should succeed");
639
640        let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
641            .expect("glob should succeed");
642        assert_eq!(globbed.num_files, 1);
643
644        let grep_output = grep_search(&GrepSearchInput {
645            pattern: String::from("hello"),
646            path: Some(dir.to_string_lossy().into_owned()),
647            glob: Some(String::from("**/*.rs")),
648            output_mode: Some(GrepOutputMode::Content),
649            before: None,
650            after: None,
651            context_short: None,
652            context: None,
653            line_numbers: Some(true),
654            case_insensitive: Some(false),
655            file_type: None,
656            head_limit: Some(10),
657            offset: Some(0),
658            multiline: Some(false),
659        })
660        .expect("grep should succeed");
661        assert!(grep_output.content.unwrap_or_default().contains("hello"));
662    }
663
664    #[test]
665    fn rejects_absolute_path_outside_workspace() {
666        allow_temp_workspace();
667        let result = read_file("/etc/passwd", None, None);
668        assert!(result.is_err());
669        let err = result.unwrap_err();
670        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
671    }
672
673    #[test]
674    fn rejects_relative_path_traversal_above_workspace() {
675        allow_temp_workspace();
676        let result = read_file("../../../etc/passwd", None, None);
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn rejects_write_outside_workspace() {
682        allow_temp_workspace();
683        let sentinel = std::env::temp_dir().join("codineer-test-outside-sentinel");
684        let sentinel_str = sentinel.to_string_lossy().to_string();
685        let result = write_file(&sentinel_str, "malicious");
686        let denied = result.is_err()
687            && result
688                .as_ref()
689                .unwrap_err()
690                .kind()
691                .eq(&std::io::ErrorKind::PermissionDenied);
692        if !denied {
693            let _ = std::fs::remove_file(&sentinel);
694        }
695        assert!(denied, "write outside workspace must be denied");
696    }
697
698    #[test]
699    fn allows_operations_within_workspace() {
700        allow_temp_workspace();
701        let path = temp_path("inside-workspace.txt");
702        write_file(path.to_string_lossy().as_ref(), "safe content")
703            .expect("write within workspace should succeed");
704        let read_output = read_file(path.to_string_lossy().as_ref(), None, None)
705            .expect("read within workspace should succeed");
706        assert_eq!(read_output.file.content, "safe content");
707    }
708
709    #[test]
710    fn edit_rejects_identical_old_and_new_string() {
711        allow_temp_workspace();
712        let path = temp_path("edit-reject.txt");
713        write_file(path.to_string_lossy().as_ref(), "content").expect("write");
714        let result = edit_file(path.to_string_lossy().as_ref(), "content", "content", false);
715        assert!(result.is_err());
716        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
717    }
718
719    #[test]
720    fn edit_rejects_missing_old_string() {
721        allow_temp_workspace();
722        let path = temp_path("edit-missing.txt");
723        write_file(path.to_string_lossy().as_ref(), "alpha beta").expect("write");
724        let result = edit_file(path.to_string_lossy().as_ref(), "gamma", "delta", false);
725        assert!(result.is_err());
726        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
727    }
728}