Skip to main content

hematite/tools/
file_ops.rs

1use serde_json::Value;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6use walkdir::WalkDir;
7
8// ── Ghost Ledger ──────────────────────────────────────────────────────────────
9
10const MAX_GHOST_BACKUPS: usize = 8;
11
12fn prune_ghost_backups(ghost_dir: &Path) {
13    let Ok(entries) = fs::read_dir(ghost_dir) else {
14        return;
15    };
16
17    let mut backups: Vec<_> = entries
18        .filter_map(Result::ok)
19        .filter(|entry| {
20            entry
21                .path()
22                .extension()
23                .and_then(|ext| ext.to_str())
24                .map(|ext| ext.eq_ignore_ascii_case("bak"))
25                .unwrap_or(false)
26        })
27        .collect();
28
29    backups.sort_by_key(|entry| entry.metadata().and_then(|meta| meta.modified()).ok());
30    backups.reverse();
31
32    let retained: std::collections::HashSet<String> = backups
33        .iter()
34        .take(MAX_GHOST_BACKUPS)
35        .map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
36        .collect();
37
38    for entry in backups.into_iter().skip(MAX_GHOST_BACKUPS) {
39        let _ = fs::remove_file(entry.path());
40    }
41
42    let ledger_path = ghost_dir.join("ledger.txt");
43    let Ok(content) = fs::read_to_string(&ledger_path) else {
44        return;
45    };
46
47    let filtered_lines: Vec<String> = content
48        .lines()
49        .filter_map(|line| {
50            let parts: Vec<&str> = line.splitn(2, '|').collect();
51            if parts.len() != 2 {
52                return None;
53            }
54
55            let backup_path = parts[1].replace('\\', "/");
56            if retained.contains(&backup_path) {
57                Some(line.to_string())
58            } else {
59                None
60            }
61        })
62        .collect();
63
64    let rewritten = if filtered_lines.is_empty() {
65        String::new()
66    } else {
67        filtered_lines.join("\n") + "\n"
68    };
69    let _ = fs::write(ledger_path, rewritten);
70}
71
72fn save_ghost_backup(target_path: &str, content: &str) {
73    let ws = workspace_root();
74
75    // Phase 1: Try Git Ghost Snapshot
76    if crate::agent::git::is_git_repo(&ws) {
77        let _ = crate::agent::git::create_ghost_snapshot(&ws);
78    }
79
80    // Phase 2: Fallback to local file backup (Ghost Ledger)
81    let ghost_dir = ws.join(".hematite").join("ghost");
82    let _ = fs::create_dir_all(&ghost_dir);
83    let ts = std::time::SystemTime::now()
84        .duration_since(std::time::UNIX_EPOCH)
85        .unwrap()
86        .as_millis();
87    let safe_name = Path::new(target_path)
88        .file_name()
89        .unwrap_or_default()
90        .to_string_lossy();
91    let backup_file = ghost_dir.join(format!("{}_{}.bak", ts, safe_name));
92
93    if fs::write(&backup_file, content).is_ok() {
94        use std::io::Write;
95        if let Ok(mut f) = fs::OpenOptions::new()
96            .create(true)
97            .append(true)
98            .open(ghost_dir.join("ledger.txt"))
99        {
100            let _ = writeln!(f, "{}|{}", target_path, backup_file.display());
101        }
102        prune_ghost_backups(&ghost_dir);
103    }
104}
105
106pub fn pop_ghost_ledger() -> Result<String, String> {
107    let ws = workspace_root();
108    let ghost_dir = ws.join(".hematite").join("ghost");
109    let ledger_path = ghost_dir.join("ledger.txt");
110
111    if !ledger_path.exists() {
112        return Err("Ghost Ledger is empty — no edits to undo".into());
113    }
114
115    let content = fs::read_to_string(&ledger_path).map_err(|e| e.to_string())?;
116    let mut lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
117
118    if lines.is_empty() {
119        return Err("Ghost Ledger is empty".into());
120    }
121
122    let last_line = lines.pop().unwrap();
123    let parts: Vec<&str> = last_line.splitn(2, '|').collect();
124    if parts.len() != 2 {
125        return Err("Corrupted ledger entry".into());
126    }
127
128    let target_path = parts[0];
129    let backup_path = parts[1];
130
131    // Priority 1: Try Git Rollback
132    if crate::agent::git::is_git_repo(&ws) {
133        if let Ok(msg) = crate::agent::git::revert_from_ghost(&ws, target_path) {
134            let _ = fs::remove_file(backup_path);
135            let new_ledger = lines.join("\n");
136            let _ = fs::write(
137                &ledger_path,
138                if new_ledger.is_empty() {
139                    String::new()
140                } else {
141                    new_ledger + "\n"
142                },
143            );
144            return Ok(msg);
145        }
146    }
147
148    // Priority 2: Standard File Rollback
149    let original_content =
150        fs::read_to_string(backup_path).map_err(|e| format!("Failed to read backup: {e}"))?;
151    let abs_target = ws.join(target_path);
152    fs::write(&abs_target, original_content).map_err(|e| format!("Failed to restore file: {e}"))?;
153
154    let new_ledger = lines.join("\n");
155    let _ = fs::write(
156        &ledger_path,
157        if new_ledger.is_empty() {
158            String::new()
159        } else {
160            new_ledger + "\n"
161        },
162    );
163    let _ = fs::remove_file(backup_path);
164
165    Ok(format!("Restored {} from Ghost Ledger", target_path))
166}
167
168// ── read_file ─────────────────────────────────────────────────────────────────
169
170pub async fn read_file(args: &Value) -> Result<String, String> {
171    let path = require_str(args, "path")?;
172    let offset = get_usize_arg(args, "offset");
173    let limit = get_usize_arg(args, "limit");
174
175    let abs = safe_path(path)?;
176    let raw = fs::read_to_string(&abs).map_err(|e| format!("read_file: {e} ({path})"))?;
177
178    let lines: Vec<&str> = raw.lines().collect();
179    let total = lines.len();
180    let start = offset.unwrap_or(0).min(total);
181    let end = limit.map(|n| (start + n).min(total)).unwrap_or(total);
182
183    let mut content = lines[start..end].join("\n");
184    if end < total {
185        content.push_str("\n\n--- [TRUNCATION WARNING] ---\n");
186        content.push_str(&format!("This file has {} more lines below. ", total - end));
187        content.push_str("To read more, use `read_file` with a higher `offset` OR use `inspect_lines` to find relevant blocks. \
188                         Do NOT attempt to read the entire large file at once if it keeps truncating.");
189    }
190
191    Ok(format!(
192        "[{path}  lines {}-{} of {}]\n{}",
193        start + 1,
194        end,
195        total,
196        content
197    ))
198}
199
200// ── inspect_lines ─────────────────────────────────────────────────────────────
201
202pub async fn inspect_lines(args: &Value) -> Result<String, String> {
203    let path = require_str(args, "path")?;
204    let start_line = get_usize_arg(args, "start_line").unwrap_or(1);
205    let end_line = get_usize_arg(args, "end_line");
206
207    let abs = safe_path(path)?;
208    let raw = fs::read_to_string(&abs).map_err(|e| format!("inspect_lines: {e} ({path})"))?;
209
210    let lines: Vec<&str> = raw.lines().collect();
211    let total = lines.len();
212
213    let start = start_line.saturating_sub(1).min(total);
214    let end = end_line.unwrap_or(total).min(total);
215
216    if start >= end && total > 0 {
217        return Err(format!(
218            "inspect_lines: start_line ({start_line}) must be <= end_line ({})",
219            end_line.unwrap_or(total)
220        ));
221    }
222
223    let mut output = format!(
224        "[inspect_lines: {path} lines {}-{} of {}]\n",
225        start + 1,
226        end,
227        total
228    );
229    for i in start..end {
230        output.push_str(&format!("[{:>4}] | {}\n", i + 1, lines[i]));
231    }
232
233    Ok(output)
234}
235
236// ── tail_file ─────────────────────────────────────────────────────────────────
237
238pub async fn tail_file(args: &Value) -> Result<String, String> {
239    let path = require_str(args, "path")?;
240    let n = args
241        .get("lines")
242        .and_then(|v| v.as_u64())
243        .unwrap_or(50)
244        .min(500) as usize;
245    let grep_pat = args.get("grep").and_then(|v| v.as_str());
246
247    let abs = safe_path(path)?;
248    let raw = fs::read_to_string(&abs).map_err(|e| format!("tail_file: {e} ({path})"))?;
249
250    let all_lines: Vec<&str> = raw.lines().collect();
251    let total = all_lines.len();
252
253    // Apply optional grep filter before slicing — model asks for the last N
254    // matching lines, not the last N lines containing maybe 0 matches.
255    let filtered: Vec<(usize, &str)> = if let Some(pat) = grep_pat {
256        let re = regex::Regex::new(pat)
257            .map_err(|e| format!("tail_file: invalid grep pattern '{pat}': {e}"))?;
258        all_lines
259            .iter()
260            .enumerate()
261            .filter(|(_, l)| re.is_match(l))
262            .map(|(i, l)| (i, *l))
263            .collect()
264    } else {
265        all_lines.iter().enumerate().map(|(i, l)| (i, *l)).collect()
266    };
267
268    let total_filtered = filtered.len();
269    let skip = total_filtered.saturating_sub(n);
270    let window = &filtered[skip..];
271
272    if window.is_empty() {
273        let note = if grep_pat.is_some() {
274            format!(" matching '{}'", grep_pat.unwrap())
275        } else {
276            String::new()
277        };
278        return Ok(format!(
279            "[tail_file: {path} — no lines{note} found (total {total} lines)]"
280        ));
281    }
282
283    let first_abs = window[0].0 + 1;
284    let last_abs = window[window.len() - 1].0 + 1;
285    let mut out = format!(
286        "[tail_file: {path} — lines {first_abs}–{last_abs} of {total} (last {n} of {total_filtered} matched)]\n"
287    );
288    for (abs_idx, line) in window {
289        out.push_str(&format!("[{:>5}] {}\n", abs_idx + 1, line));
290    }
291
292    Ok(out)
293}
294
295// ── write_file ────────────────────────────────────────────────────────────────
296
297pub async fn write_file(args: &Value) -> Result<String, String> {
298    let path = require_str(args, "path")?;
299    let content = require_str(args, "content")?;
300
301    let abs = safe_path_allow_new(path)?;
302    if let Some(parent) = abs.parent() {
303        fs::create_dir_all(parent)
304            .map_err(|e| format!("write_file: could not create dirs: {e}"))?;
305    }
306
307    let existed = abs.exists();
308    if existed {
309        if let Ok(orig) = fs::read_to_string(&abs) {
310            save_ghost_backup(path, &orig);
311        }
312    }
313
314    fs::write(&abs, content).map_err(|e| format!("write_file: {e} ({path})"))?;
315
316    let action = if existed { "Updated" } else { "Created" };
317    Ok(format!("{action} {path}  ({} bytes)", content.len()))
318}
319
320// ── edit_file ─────────────────────────────────────────────────────────────────
321
322pub async fn edit_file(args: &Value) -> Result<String, String> {
323    let path = require_str(args, "path")?;
324    let search = require_str(args, "search")?;
325    let replace = require_str(args, "replace")?;
326    let replace_all = args
327        .get("replace_all")
328        .and_then(|v| v.as_bool())
329        .unwrap_or(false);
330
331    if search == replace {
332        return Err("edit_file: 'search' and 'replace' are identical — no change needed".into());
333    }
334
335    let abs = safe_path(path)?;
336    let raw = fs::read_to_string(&abs).map_err(|e| format!("edit_file: {e} ({path})"))?;
337    // Normalize CRLF → LF so search strings from the model (always LF) match on Windows.
338    let original = raw.replace("\r\n", "\n");
339
340    save_ghost_backup(path, &original);
341
342    let search_trimmed = search.trim();
343    let search_non_ws_len = search_trimmed
344        .chars()
345        .filter(|c| !c.is_whitespace())
346        .count();
347    let search_line_count = search_trimmed.lines().count();
348    if search_non_ws_len < 12 && search_line_count <= 1 {
349        return Err(format!(
350            "edit_file: search string is too short or generic for a safe mutation in {path}.\n\
351             Provide a more specific anchor (prefer a full line, multiple lines, or use `inspect_lines` + `patch_hunk`)."
352        ));
353    }
354
355    // ── Exact match first ────────────────────────────────────────────────────
356    let (effective_search, was_repaired) = if original.contains(search) {
357        let exact_match_count = original.matches(search).count();
358        if exact_match_count > 1 && !replace_all {
359            return Err(format!(
360                "edit_file: search string matched {} times in {path}.\n\
361                 Provide a more specific unique anchor or use `inspect_lines` + `patch_hunk`.",
362                exact_match_count
363            ));
364        }
365        (search.to_string(), false)
366    } else {
367        // ── Fuzzy repair: progressive normalisation ───────────────────────
368        // Level 1: rstrip only — preserves indentation, strips trailing spaces.
369        // Level 2: full strip — corrects indentation mismatches.
370        // Level 3: cross-file hint — tells the model which file has the string.
371        let span =
372            rstrip_find_span(&original, search).or_else(|| fuzzy_find_span(&original, search));
373        match span {
374            Some(span) => {
375                let real_slice = original[span.clone()].to_string();
376                (real_slice, true)
377            }
378            None => {
379                let hint = nearest_lines(&original, search);
380                let cross_hint = find_search_in_workspace(search, path)
381                    .map(|found| format!("\nNote: search string found in '{found}' — did you mean to edit that file?"))
382                    .unwrap_or_default();
383                return Err(format!(
384                    "edit_file: search string not found in {path}.\n\
385                     The 'search' value must match the file content exactly \
386                     (including whitespace/indentation).\n\
387                     {hint}{cross_hint}"
388                ));
389            }
390        }
391    };
392
393    // When a fuzzy match was used, adjust the replace string's indentation to
394    // match the file's actual indent level (not the model's potentially-wrong indent).
395    let effective_replace = if was_repaired {
396        adjust_replace_indent(search, effective_search.as_str(), replace)
397    } else {
398        replace.to_string()
399    };
400
401    let updated = if replace_all {
402        original.replace(effective_search.as_str(), effective_replace.as_str())
403    } else {
404        original.replacen(effective_search.as_str(), effective_replace.as_str(), 1)
405    };
406
407    fs::write(&abs, &updated).map_err(|e| format!("edit_file: write failed: {e}"))?;
408
409    let removed = original.lines().count();
410    let added = updated.lines().count();
411    let repair_note = if was_repaired {
412        "  [indent auto-corrected]"
413    } else {
414        ""
415    };
416
417    let mut diff_block = String::new();
418    diff_block.push_str("\n--- DIFF \n");
419    for line in effective_search.lines() {
420        diff_block.push_str(&format!("- {}\n", line));
421    }
422    for line in effective_replace.lines() {
423        diff_block.push_str(&format!("+ {}\n", line));
424    }
425
426    Ok(format!(
427        "Edited {path}  ({} -> {} lines){repair_note}{}",
428        removed, added, diff_block
429    ))
430}
431
432// ── patch_hunk ────────────────────────────────────────────────────────────────
433
434pub async fn patch_hunk(args: &Value) -> Result<String, String> {
435    let path = require_str(args, "path")?;
436    let start_line = require_usize(args, "start_line")?;
437    let end_line = require_usize(args, "end_line")?;
438    let replacement = require_str(args, "replacement")?;
439
440    let abs = safe_path(path)?;
441    let original = fs::read_to_string(&abs).map_err(|e| format!("patch_hunk: {e} ({path})"))?;
442
443    save_ghost_backup(path, &original);
444
445    let lines: Vec<String> = original.lines().map(|s| s.to_string()).collect();
446    let total = lines.len();
447
448    if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
449        return Err(format!(
450            "patch_hunk: invalid line range {}-{} for file with {} lines",
451            start_line, end_line, total
452        ));
453    }
454
455    let mut updated_lines = Vec::new();
456    // 0-indexed adjustment
457    let s_idx = start_line - 1;
458    let e_idx = end_line; // inclusive in current logic from 1-based start_line..end_line
459
460    // 1. Lines before the hunk
461    updated_lines.extend_from_slice(&lines[0..s_idx]);
462
463    // 2. The hunk replacement
464    for line in replacement.lines() {
465        updated_lines.push(line.to_string());
466    }
467
468    // 3. Lines after the hunk
469    if e_idx < total {
470        updated_lines.extend_from_slice(&lines[e_idx..total]);
471    }
472
473    let updated_content = updated_lines.join("\n");
474    fs::write(&abs, &updated_content).map_err(|e| format!("patch_hunk: write failed: {e}"))?;
475
476    let mut diff = String::new();
477    diff.push_str("\n--- HUNK DIFF ---\n");
478    for i in s_idx..e_idx {
479        diff.push_str(&format!("- {}\n", lines[i].trim_end()));
480    }
481    for line in replacement.lines() {
482        diff.push_str(&format!("+ {}\n", line.trim_end()));
483    }
484
485    Ok(format!(
486        "Patched {path} lines {}-{} ({} -> {} lines){}",
487        start_line,
488        end_line,
489        (e_idx - s_idx),
490        replacement.lines().count(),
491        diff
492    ))
493}
494
495// ── multi_search_replace ──────────────────────────────────────────────────────
496
497#[derive(serde::Deserialize)]
498struct SearchReplaceHunk {
499    search: String,
500    replace: String,
501}
502
503pub async fn multi_search_replace(args: &Value) -> Result<String, String> {
504    let path = require_str(args, "path")?;
505    let hunks_val = args
506        .get("hunks")
507        .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
508
509    let hunks: Vec<SearchReplaceHunk> = serde_json::from_value(hunks_val.clone())
510        .map_err(|e| format!("multi_search_replace: invalid hunks array: {e}"))?;
511
512    if hunks.is_empty() {
513        return Err("multi_search_replace: hunks array is empty".to_string());
514    }
515
516    let abs = safe_path(path)?;
517    let raw =
518        fs::read_to_string(&abs).map_err(|e| format!("multi_search_replace: {e} ({path})"))?;
519    // Normalize CRLF → LF so search strings from the model (always LF) match on Windows.
520    let original = raw.replace("\r\n", "\n");
521
522    save_ghost_backup(path, &original);
523
524    let mut current_content = original.clone();
525    let mut diff = String::new();
526    diff.push_str("\n--- SEARCH & REPLACE DIFF ---\n");
527
528    let mut patched_hunks = 0;
529
530    for (i, hunk) in hunks.iter().enumerate() {
531        let match_count = current_content.matches(&hunk.search).count();
532
533        let (effective_search, effective_replace) = if match_count == 1 {
534            // Exact match — use as-is.
535            (hunk.search.clone(), hunk.replace.clone())
536        } else if match_count == 0 {
537            // Progressive fuzzy fallback: rstrip → full-strip.
538            let span = rstrip_find_span(&current_content, &hunk.search)
539                .or_else(|| fuzzy_find_span(&current_content, &hunk.search));
540            match span {
541                Some(span) => {
542                    let real_slice = current_content[span].to_string();
543                    let adjusted_replace =
544                        adjust_replace_indent(&hunk.search, &real_slice, &hunk.replace);
545                    (real_slice, adjusted_replace)
546                }
547                None => {
548                    return Err(format!(
549                        "multi_search_replace: hunk {} search string not found in file.",
550                        i
551                    ));
552                }
553            }
554        } else {
555            return Err(format!(
556                "multi_search_replace: hunk {} search string matched {} times. Provide more context to make it unique.",
557                i, match_count
558            ));
559        };
560
561        diff.push_str(&format!("\n@@ Hunk {} @@\n", i + 1));
562        for line in effective_search.lines() {
563            diff.push_str(&format!("- {}\n", line.trim_end()));
564        }
565        for line in effective_replace.lines() {
566            diff.push_str(&format!("+ {}\n", line.trim_end()));
567        }
568
569        current_content = current_content.replacen(&effective_search, &effective_replace, 1);
570        patched_hunks += 1;
571    }
572
573    fs::write(&abs, &current_content)
574        .map_err(|e| format!("multi_search_replace: write failed: {e}"))?;
575
576    Ok(format!(
577        "Modified {} hunks in {} using exact search-and-replace.{}",
578        patched_hunks, path, diff
579    ))
580}
581
582// ── list_files ────────────────────────────────────────────────────────────────
583
584pub async fn list_files(args: &Value) -> Result<String, String> {
585    let started = Instant::now();
586    let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
587    let ext_filter = args.get("extension").and_then(|v| v.as_str());
588
589    let base = safe_path(base_str)?;
590
591    let mut files: Vec<PathBuf> = Vec::new();
592    let mut scanned_count = 0;
593    for entry in WalkDir::new(&base).follow_links(false) {
594        scanned_count += 1;
595        if scanned_count > 25_000 {
596            return Err("list_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
597        }
598        let entry = entry.map_err(|e| format!("list_files: {e}"))?;
599        if !entry.file_type().is_file() {
600            continue;
601        }
602        let p = entry.path();
603
604        // Skip hidden dirs / target / node_modules
605        if path_has_hidden_segment(p) {
606            continue;
607        }
608
609        if let Some(ext) = ext_filter {
610            if p.extension().and_then(|s| s.to_str()) != Some(ext) {
611                continue;
612            }
613        }
614        files.push(p.to_path_buf());
615    }
616
617    // Sort by modification time (newest first).
618    files.sort_by_key(|p| {
619        fs::metadata(p)
620            .and_then(|m| m.modified())
621            .ok()
622            .map(std::cmp::Reverse)
623    });
624
625    let total = files.len();
626    const LIMIT: usize = 200;
627    let truncated = total > LIMIT;
628    let shown: Vec<String> = files
629        .into_iter()
630        .take(LIMIT)
631        .map(|p| p.display().to_string())
632        .collect();
633
634    let ms = started.elapsed().as_millis();
635    let mut out = format!(
636        "{} file(s) in {}  ({ms}ms){}",
637        total.min(LIMIT),
638        base_str,
639        if truncated {
640            "  [truncated at 200]"
641        } else {
642            ""
643        }
644    );
645    out.push('\n');
646    out.push_str(&shown.join("\n"));
647    Ok(out)
648}
649
650// ── create_directory ──────────────────────────────────────────────────────────
651
652pub async fn create_directory(args: &Value) -> Result<String, String> {
653    let path = require_str(args, "path")?;
654    let abs = safe_path_allow_new(path)?;
655
656    if abs.exists() {
657        if abs.is_dir() {
658            return Ok(format!("Directory already exists: {path}"));
659        } else {
660            return Err(format!("A file already exists at this path: {path}"));
661        }
662    }
663
664    fs::create_dir_all(&abs).map_err(|e| format!("create_directory: {e} ({path})"))?;
665    Ok(format!("Created directory: {path}"))
666}
667
668// ── grep_files ────────────────────────────────────────────────────────────────
669
670pub async fn grep_files(args: &Value) -> Result<String, String> {
671    let pattern = require_str(args, "pattern")?;
672    let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
673    let ext_filter = args.get("extension").and_then(|v| v.as_str());
674    let case_insensitive = args
675        .get("case_insensitive")
676        .and_then(|v| v.as_bool())
677        .unwrap_or(true);
678    let files_only = args.get("mode").and_then(|v| v.as_str()) == Some("files_only");
679    let head_limit = get_usize_arg(args, "head_limit").unwrap_or(50);
680    let offset = get_usize_arg(args, "offset").unwrap_or(0);
681
682    // Context lines: `context` sets both before+after; `before`/`after` override individually.
683    let ctx_default = get_usize_arg(args, "context").unwrap_or(0);
684    let before = get_usize_arg(args, "before").unwrap_or(ctx_default);
685    let after = get_usize_arg(args, "after").unwrap_or(ctx_default);
686
687    let base = safe_path(base_str)?;
688
689    let regex = regex::RegexBuilder::new(pattern)
690        .case_insensitive(case_insensitive)
691        .build()
692        .map_err(|e| format!("grep_files: invalid pattern '{pattern}': {e}"))?;
693
694    // ── files_only mode ───────────────────────────────────────────────────────
695    if files_only {
696        let mut matched_files: Vec<String> = Vec::new();
697        let mut scanned_count = 0;
698
699        for entry in WalkDir::new(&base).follow_links(false) {
700            scanned_count += 1;
701            if scanned_count > 25_000 {
702                return Err("grep_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
703            }
704            let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
705            if !entry.file_type().is_file() {
706                continue;
707            }
708            let p = entry.path();
709            if path_has_hidden_segment(p) {
710                continue;
711            }
712            if let Some(ext) = ext_filter {
713                if p.extension().and_then(|s| s.to_str()) != Some(ext) {
714                    continue;
715                }
716            }
717            let Ok(contents) = fs::read_to_string(p) else {
718                continue;
719            };
720            if contents.lines().any(|line| regex.is_match(line)) {
721                matched_files.push(p.display().to_string());
722            }
723        }
724
725        if matched_files.is_empty() {
726            return Ok(format!("No files matching '{pattern}' in {base_str}"));
727        }
728
729        let total = matched_files.len();
730        let page: Vec<_> = matched_files
731            .into_iter()
732            .skip(offset)
733            .take(head_limit)
734            .collect();
735        let showing = page.len();
736        let mut out = format!("{total} file(s) match '{pattern}'");
737        if offset > 0 || showing < total {
738            out.push_str(&format!(
739                " [showing {}-{} of {total}]",
740                offset + 1,
741                offset + showing
742            ));
743        }
744        out.push('\n');
745        out.push_str(&page.join("\n"));
746        return Ok(out);
747    }
748
749    // ── content mode with optional context lines ──────────────────────────────
750
751    // A "hunk" is a contiguous run of lines to display for one or more nearby matches.
752    struct Hunk {
753        path: String,
754        /// (line_number_1_indexed, line_text, is_match)
755        lines: Vec<(usize, String, bool)>,
756    }
757
758    let mut hunks: Vec<Hunk> = Vec::new();
759    let mut total_matches = 0usize;
760    let mut files_matched = 0usize;
761    let mut scanned_count = 0;
762
763    for entry in WalkDir::new(&base).follow_links(false) {
764        scanned_count += 1;
765        if scanned_count > 25_000 {
766            return Err("grep_files: Too many files scanned (>25,000). The path is too broad. Narrow your search path or run Hematite directly in a project directory.".into());
767        }
768        let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
769        if !entry.file_type().is_file() {
770            continue;
771        }
772        let p = entry.path();
773        if path_has_hidden_segment(p) {
774            continue;
775        }
776        if let Some(ext) = ext_filter {
777            if p.extension().and_then(|s| s.to_str()) != Some(ext) {
778                continue;
779            }
780        }
781        let Ok(contents) = fs::read_to_string(p) else {
782            continue;
783        };
784        let all_lines: Vec<&str> = contents.lines().collect();
785        let n = all_lines.len();
786
787        // Find all match indices in this file.
788        let match_idxs: Vec<usize> = all_lines
789            .iter()
790            .enumerate()
791            .filter(|(_, line)| regex.is_match(line))
792            .map(|(i, _)| i)
793            .collect();
794
795        if match_idxs.is_empty() {
796            continue;
797        }
798        files_matched += 1;
799        total_matches += match_idxs.len();
800
801        // Merge overlapping ranges into hunks.
802        let path_str = p.display().to_string();
803        let mut ranges: Vec<(usize, usize)> = match_idxs
804            .iter()
805            .map(|&i| {
806                (
807                    i.saturating_sub(before),
808                    (i + after).min(n.saturating_sub(1)),
809                )
810            })
811            .collect();
812
813        // Sort and merge overlapping ranges.
814        ranges.sort_unstable();
815        let mut merged: Vec<(usize, usize)> = Vec::new();
816        for (s, e) in ranges {
817            if let Some(last) = merged.last_mut() {
818                if s <= last.1 + 1 {
819                    last.1 = last.1.max(e);
820                    continue;
821                }
822            }
823            merged.push((s, e));
824        }
825
826        // Build hunks from merged ranges.
827        let match_set: std::collections::HashSet<usize> = match_idxs.into_iter().collect();
828        for (start, end) in merged {
829            let mut hunk_lines = Vec::new();
830            for i in start..=end {
831                hunk_lines.push((i + 1, all_lines[i].to_string(), match_set.contains(&i)));
832            }
833            hunks.push(Hunk {
834                path: path_str.clone(),
835                lines: hunk_lines,
836            });
837        }
838    }
839
840    if hunks.is_empty() {
841        return Ok(format!("No matches for '{pattern}' in {base_str}"));
842    }
843
844    let total_hunks = hunks.len();
845    let page_hunks: Vec<_> = hunks.into_iter().skip(offset).take(head_limit).collect();
846    let showing = page_hunks.len();
847
848    let mut out =
849        format!("{total_matches} match(es) across {files_matched} file(s), {total_hunks} hunk(s)");
850    if offset > 0 || showing < total_hunks {
851        out.push_str(&format!(
852            " [hunks {}-{} of {total_hunks}]",
853            offset + 1,
854            offset + showing
855        ));
856    }
857    out.push('\n');
858
859    for (i, hunk) in page_hunks.iter().enumerate() {
860        if i > 0 {
861            out.push_str("\n--\n");
862        }
863        for (lineno, text, is_match) in &hunk.lines {
864            if *is_match {
865                out.push_str(&format!("{}:{}:{}\n", hunk.path, lineno, text));
866            } else {
867                out.push_str(&format!("{}: {}-{}\n", hunk.path, lineno, text));
868            }
869        }
870    }
871
872    Ok(out.trim_end().to_string())
873}
874
875// ── Argument helpers ──────────────────────────────────────────────────────────
876
877fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
878    args.get(key)
879        .and_then(|v| v.as_str())
880        .ok_or_else(|| format!("Missing required argument: '{key}'"))
881}
882
883fn get_usize_arg(args: &Value, key: &str) -> Option<usize> {
884    args.get(key).and_then(value_as_usize)
885}
886
887fn require_usize(args: &Value, key: &str) -> Result<usize, String> {
888    get_usize_arg(args, key).ok_or_else(|| format!("Missing required numeric argument: '{key}'"))
889}
890
891fn value_as_usize(value: &Value) -> Option<usize> {
892    if let Some(v) = value.as_u64() {
893        return usize::try_from(v).ok();
894    }
895
896    if let Some(v) = value.as_i64() {
897        return if v >= 0 {
898            usize::try_from(v as u64).ok()
899        } else {
900            None
901        };
902    }
903
904    if let Some(v) = value.as_f64() {
905        if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= (usize::MAX as f64) {
906            return Some(v as usize);
907        }
908        return None;
909    }
910
911    value.as_str().and_then(|s| s.trim().parse::<usize>().ok())
912}
913
914// ── Path helpers ──────────────────────────────────────────────────────────────
915
916/// Resolve a path that must already exist, and check it's inside the workspace.
917fn safe_path(path: &str) -> Result<PathBuf, String> {
918    let candidate = resolve_candidate(path);
919    canonicalize_safe(&candidate, path)
920}
921
922/// Resolve a path that may not exist yet (for write_file).
923fn safe_path_allow_new(path: &str) -> Result<PathBuf, String> {
924    let candidate = resolve_candidate(path);
925
926    // Try canonical first.
927    if let Ok(abs) = candidate.canonicalize() {
928        check_workspace_bounds(&abs, path)?;
929        return Ok(abs);
930    }
931
932    // File doesn't exist yet — canonicalize the parent, append the filename.
933    let parent = candidate.parent().unwrap_or(Path::new("."));
934    let name = candidate
935        .file_name()
936        .ok_or_else(|| format!("invalid path: {path}"))?;
937    let abs_parent = parent
938        .canonicalize()
939        .map_err(|_| format!("safe_path: parent dir doesn't exist for {path}"))?;
940    let abs = abs_parent.join(name);
941    check_workspace_bounds(&abs, path)?;
942    Ok(abs)
943}
944
945pub(crate) fn resolve_candidate(path: &str) -> PathBuf {
946    // 1. Handle Special Sovereign Tokens
947    let upper = path.to_uppercase();
948
949    // Helper to resolve via dirs crate
950    let resolved = if upper.starts_with("@DESKTOP/") {
951        dirs::desktop_dir().map(|p| p.join(&path[9..]))
952    } else if upper.starts_with("@DOCUMENTS/") {
953        dirs::document_dir().map(|p| p.join(&path[11..]))
954    } else if upper.starts_with("@DOWNLOADS/") {
955        dirs::download_dir().map(|p| p.join(&path[11..]))
956    } else if upper.starts_with("@PICTURES/") || upper.starts_with("@IMAGES/") {
957        let offset = if upper.starts_with("@PICTURES/") {
958            10
959        } else {
960            8
961        };
962        dirs::picture_dir().map(|p| p.join(&path[offset..]))
963    } else if upper.starts_with("@VIDEOS/") || upper.starts_with("@MOVIES/") {
964        let offset = if upper.starts_with("@VIDEOS/") { 8 } else { 8 };
965        dirs::video_dir().map(|p| p.join(&path[offset..]))
966    } else if upper.starts_with("@MUSIC/") || upper.starts_with("@AUDIO/") {
967        let offset = if upper.starts_with("@MUSIC/") { 7 } else { 7 };
968        dirs::audio_dir().map(|p| p.join(&path[offset..]))
969    } else if upper.starts_with("@HOME/") || upper.starts_with("~/") {
970        let offset = if upper.starts_with("@HOME/") { 6 } else { 2 };
971        dirs::home_dir().map(|p| p.join(&path[offset..]))
972    } else if upper.starts_with("@TEMP/") {
973        Some(std::env::temp_dir().join(&path[6..]))
974    } else if upper.starts_with("@CACHE/") {
975        dirs::cache_dir().map(|p| p.join(&path[7..]))
976    } else if upper.starts_with("@CONFIG/") {
977        dirs::config_dir().map(|p| p.join(&path[8..]))
978    } else if upper.starts_with("@DATA/") {
979        dirs::data_dir().map(|p| p.join(&path[6..]))
980    } else {
981        None
982    };
983
984    if let Some(p) = resolved {
985        return p;
986    }
987
988    // 2. Fallback to Standard Resolution
989    let p = Path::new(path);
990    if p.is_absolute() {
991        p.to_path_buf()
992    } else {
993        std::env::current_dir()
994            .unwrap_or_else(|_| PathBuf::from("."))
995            .join(p)
996    }
997}
998
999fn canonicalize_safe(candidate: &Path, original: &str) -> Result<PathBuf, String> {
1000    let abs = candidate
1001        .canonicalize()
1002        .map_err(|e: io::Error| format!("safe_path: {e} ({original})"))?;
1003    check_workspace_bounds(&abs, original)?;
1004    Ok(abs)
1005}
1006
1007fn check_workspace_bounds(abs: &Path, original: &str) -> Result<(), String> {
1008    // Delegate to the existing guard for blacklist + traversal checks.
1009    let workspace = std::env::current_dir().map_err(|e| format!("could not read cwd: {e}"))?;
1010    super::guard::path_is_safe(&workspace, abs)
1011        .map(|_| ())
1012        .map_err(|e| format!("file access denied for '{original}': {e}"))
1013}
1014
1015/// Returns true if the path contains a segment that should be skipped (.git, target, node_modules, etc.)
1016fn path_has_hidden_segment(p: &Path) -> bool {
1017    p.components().any(|c| {
1018        let s = c.as_os_str().to_string_lossy();
1019        if s == ".hematite" || s == ".git" || s == "." || s == ".." {
1020            return false;
1021        }
1022        s.starts_with('.') || s == "target" || s == "node_modules" || s == "__pycache__"
1023    })
1024}
1025
1026/// Show the lines nearest to where the search string *almost* matched,
1027/// so the model can see the real indentation/content and self-correct.
1028fn nearest_lines(content: &str, search: &str) -> String {
1029    // Try to find the best-matching line by the first non-empty search line.
1030    let first_search_line = search
1031        .lines()
1032        .map(|l| l.trim())
1033        .find(|l| !l.is_empty())
1034        .unwrap_or("");
1035
1036    let lines: Vec<&str> = content.lines().collect();
1037    if lines.is_empty() {
1038        return "(file is empty)".into();
1039    }
1040
1041    // Find the line in the file that contains the most chars from the search line.
1042    let best_idx = if first_search_line.is_empty() {
1043        0
1044    } else {
1045        lines
1046            .iter()
1047            .enumerate()
1048            .max_by_key(|(_, l)| {
1049                let lt = l.trim();
1050                // Score: length of longest common prefix after trimming.
1051                first_search_line
1052                    .chars()
1053                    .zip(lt.chars())
1054                    .take_while(|(a, b)| a == b)
1055                    .count()
1056            })
1057            .map(|(i, _)| i)
1058            .unwrap_or(0)
1059    };
1060
1061    let start = best_idx.saturating_sub(3);
1062    let end = (best_idx + 5).min(lines.len());
1063    let snippet = lines[start..end]
1064        .iter()
1065        .enumerate()
1066        .map(|(i, l)| format!("{:>4} | {}", start + i + 1, l))
1067        .collect::<Vec<_>>()
1068        .join("\n");
1069
1070    format!(
1071        "Nearest matching lines ({}:{}):\n{}",
1072        best_idx + 1,
1073        end,
1074        snippet
1075    )
1076}
1077
1078/// Core span-mapping logic shared by both fuzzy match levels.
1079/// Given a normalisation function, finds `search` inside `content` after
1080/// applying that function to both, then maps the result back to a byte
1081/// range in the original (un-normalised) `content`.
1082fn find_span_normalised(
1083    content: &str,
1084    search: &str,
1085    normalise: impl Fn(&str) -> String,
1086) -> Option<std::ops::Range<usize>> {
1087    let norm_content = normalise(content);
1088    let norm_search = normalise(search)
1089        .trim_start_matches('\n')
1090        .trim_end_matches('\n')
1091        .to_string();
1092
1093    if norm_search.is_empty() {
1094        return None;
1095    }
1096
1097    let norm_pos = norm_content.find(&norm_search)?;
1098
1099    let lines_before = norm_content[..norm_pos]
1100        .as_bytes()
1101        .iter()
1102        .filter(|&&b| b == b'\n')
1103        .count();
1104    let search_lines = norm_search
1105        .as_bytes()
1106        .iter()
1107        .filter(|&&b| b == b'\n')
1108        .count()
1109        + 1;
1110
1111    let orig_lines: Vec<&str> = content.lines().collect();
1112
1113    let mut current_pos = 0;
1114    for i in 0..lines_before {
1115        if i < orig_lines.len() {
1116            current_pos += orig_lines[i].len() + 1;
1117        }
1118    }
1119    let byte_start = current_pos;
1120
1121    let mut byte_len = 0;
1122    for i in 0..search_lines {
1123        let idx = lines_before + i;
1124        if idx < orig_lines.len() {
1125            byte_len += orig_lines[idx].len();
1126            if i < search_lines - 1 {
1127                byte_len += 1;
1128            }
1129        }
1130    }
1131
1132    if byte_start + byte_len > content.len() {
1133        return None;
1134    }
1135
1136    let candidate = &content[byte_start..byte_start + byte_len];
1137    if normalise(candidate).trim_end_matches('\n') == norm_search.as_str() {
1138        Some(byte_start..byte_start + byte_len)
1139    } else {
1140        None
1141    }
1142}
1143
1144/// Level 1 fuzzy: rstrip only — removes trailing whitespace per line but
1145/// preserves leading indentation. Catches trailing-space mismatches where
1146/// the model's indentation is actually correct.
1147fn rstrip_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1148    find_span_normalised(content, search, |s| {
1149        s.lines()
1150            .map(|l| l.trim_end())
1151            .collect::<Vec<_>>()
1152            .join("\n")
1153    })
1154}
1155
1156/// Level 2 fuzzy: full strip — trims all leading and trailing whitespace
1157/// per line. Catches indentation mismatches where the model wrote the
1158/// correct content but with wrong indent level.
1159fn fuzzy_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1160    find_span_normalised(content, search, |s| {
1161        s.lines().map(|l| l.trim()).collect::<Vec<_>>().join("\n")
1162    })
1163}
1164
1165/// Scan source files in the workspace for a search string that failed to
1166/// match in the intended target file. Returns the first file path where
1167/// the string is found (after CRLF normalisation), capped at 100 files.
1168/// Used to generate a "did you mean this file?" hint in edit errors.
1169fn find_search_in_workspace(search: &str, skip_path: &str) -> Option<String> {
1170    let root = workspace_root();
1171    let norm_search = search.replace("\r\n", "\n");
1172    let mut checked = 0usize;
1173
1174    let walker = ignore::WalkBuilder::new(&root)
1175        .hidden(true)
1176        .ignore(true)
1177        .git_ignore(true)
1178        .build();
1179
1180    for entry in walker.flatten() {
1181        if checked >= 100 {
1182            break;
1183        }
1184        let path = entry.path();
1185        if !path.is_file() {
1186            continue;
1187        }
1188        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1189        if !matches!(
1190            ext,
1191            "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "c" | "cpp" | "h"
1192        ) {
1193            continue;
1194        }
1195        let rel = path
1196            .strip_prefix(&root)
1197            .unwrap_or(path)
1198            .to_string_lossy()
1199            .replace('\\', "/");
1200        if rel == skip_path {
1201            continue;
1202        }
1203        checked += 1;
1204        if let Ok(content) = std::fs::read_to_string(path) {
1205            let normalised = content.replace("\r\n", "\n");
1206            if normalised.contains(&norm_search) {
1207                return Some(rel);
1208            }
1209        }
1210    }
1211    None
1212}
1213
1214// ── Indent-aware replacement ──────────────────────────────────────────────────
1215
1216/// When the model's search string has different indentation than the actual file
1217/// content (fuzzy match succeeded), apply the same indentation delta to the
1218/// replace string so the replacement lands with correct indentation.
1219///
1220/// Example: model wrote search/replace with 0-space indent, file uses 8 spaces.
1221/// Delta = +8. Every line of replace gets 8 spaces prepended.
1222fn adjust_replace_indent(search: &str, file_span: &str, replace: &str) -> String {
1223    fn first_indent(s: &str) -> usize {
1224        s.lines()
1225            .find(|l| !l.trim().is_empty())
1226            .map(|l| l.len() - l.trim_start_matches(' ').len())
1227            .unwrap_or(0)
1228    }
1229
1230    let search_indent = first_indent(search);
1231    let file_indent = first_indent(file_span);
1232
1233    if search_indent == file_indent {
1234        return replace.to_string();
1235    }
1236
1237    let delta: i64 = file_indent as i64 - search_indent as i64;
1238    let trailing_newline = replace.ends_with('\n');
1239
1240    let adjusted: Vec<String> = replace
1241        .lines()
1242        .map(|line| {
1243            if line.trim().is_empty() {
1244                // Preserve blank lines as-is
1245                line.to_string()
1246            } else {
1247                let current_indent = line.len() - line.trim_start_matches(' ').len();
1248                let new_indent = (current_indent as i64 + delta).max(0) as usize;
1249                format!("{}{}", " ".repeat(new_indent), line.trim_start_matches(' '))
1250            }
1251        })
1252        .collect();
1253
1254    let mut result = adjusted.join("\n");
1255    if trailing_newline {
1256        result.push('\n');
1257    }
1258    result
1259}
1260
1261// ── Diff preview helpers (read-only, no writes) ───────────────────────────────
1262
1263/// Return a formatted diff string for an edit_file operation without applying it.
1264/// Lines prefixed "- " are removals, "+ " are additions.  Returns Err if the
1265/// search string cannot be located (caller falls through to normal tool dispatch).
1266pub fn compute_edit_file_diff(args: &Value) -> Result<String, String> {
1267    let path = require_str(args, "path")?;
1268    let search = require_str(args, "search")?;
1269    let replace = require_str(args, "replace")?;
1270
1271    let abs = safe_path(path)?;
1272    let raw = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1273    let original = raw.replace("\r\n", "\n");
1274
1275    let (effective_search, effective_replace): (String, String) = if original.contains(search) {
1276        (search.to_string(), replace.to_string())
1277    } else {
1278        let span =
1279            rstrip_find_span(&original, search).or_else(|| fuzzy_find_span(&original, search));
1280        match span {
1281            Some(span) => {
1282                let real_slice = original[span].to_string();
1283                let adjusted = adjust_replace_indent(search, &real_slice, replace);
1284                (real_slice, adjusted)
1285            }
1286            None => return Err("search string not found — diff preview unavailable".into()),
1287        }
1288    };
1289
1290    let mut diff = String::new();
1291    for line in effective_search.lines() {
1292        diff.push_str(&format!("- {}\n", line));
1293    }
1294    for line in effective_replace.lines() {
1295        diff.push_str(&format!("+ {}\n", line));
1296    }
1297    Ok(diff)
1298}
1299
1300/// Return a formatted diff string for a patch_hunk operation without applying it.
1301pub fn compute_patch_hunk_diff(args: &Value) -> Result<String, String> {
1302    let path = require_str(args, "path")?;
1303    let start_line = require_usize(args, "start_line")?;
1304    let end_line = require_usize(args, "end_line")?;
1305    let replacement = require_str(args, "replacement")?;
1306
1307    let abs = safe_path(path)?;
1308    let original = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1309    let lines: Vec<&str> = original.lines().collect();
1310    let total = lines.len();
1311
1312    if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
1313        return Err(format!(
1314            "patch_hunk: invalid line range {}-{} for file with {} lines",
1315            start_line, end_line, total
1316        ));
1317    }
1318
1319    let s_idx = start_line - 1;
1320    let e_idx = end_line;
1321
1322    let mut diff = format!("@@ lines {}-{} @@\n", start_line, end_line);
1323    for i in s_idx..e_idx {
1324        diff.push_str(&format!("- {}\n", lines[i].trim_end()));
1325    }
1326    for line in replacement.lines() {
1327        diff.push_str(&format!("+ {}\n", line.trim_end()));
1328    }
1329    Ok(diff)
1330}
1331
1332/// Return a formatted diff string for a multi_search_replace operation without applying it.
1333pub fn compute_msr_diff(args: &Value) -> Result<String, String> {
1334    let hunks_val = args
1335        .get("hunks")
1336        .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
1337
1338    #[derive(serde::Deserialize)]
1339    struct PreviewHunk {
1340        search: String,
1341        replace: String,
1342    }
1343    let hunks: Vec<PreviewHunk> = serde_json::from_value(hunks_val.clone())
1344        .map_err(|e| format!("compute_msr_diff: invalid hunks: {e}"))?;
1345
1346    let mut diff = String::new();
1347    for (i, hunk) in hunks.iter().enumerate() {
1348        if hunks.len() > 1 {
1349            diff.push_str(&format!("@@ hunk {} @@\n", i + 1));
1350        }
1351        for line in hunk.search.lines() {
1352            diff.push_str(&format!("- {}\n", line.trim_end()));
1353        }
1354        for line in hunk.replace.lines() {
1355            diff.push_str(&format!("+ {}\n", line.trim_end()));
1356        }
1357    }
1358    Ok(diff)
1359}
1360
1361/// Resolve the workspace root by looking upward for common markers.
1362pub fn workspace_root() -> PathBuf {
1363    let mut current = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1364    loop {
1365        if current.join(".git").exists()
1366            || current.join("Cargo.toml").exists()
1367            || current.join("package.json").exists()
1368        {
1369            return current;
1370        }
1371        if !current.pop() {
1372            break;
1373        }
1374    }
1375    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1376}
1377
1378/// Returns true if the workspace root looks like a real project.
1379/// A bare `.git` alone (e.g. accidental `git init` in the home folder) doesn't
1380/// count — at least one explicit build/package marker must also be present.
1381pub fn is_project_workspace() -> bool {
1382    let root = workspace_root();
1383    let has_explicit_marker = root.join("Cargo.toml").exists()
1384        || root.join("package.json").exists()
1385        || root.join("pyproject.toml").exists()
1386        || root.join("go.mod").exists()
1387        || root.join("setup.py").exists()
1388        || root.join("pom.xml").exists()
1389        || root.join("build.gradle").exists()
1390        || root.join("CMakeLists.txt").exists();
1391    has_explicit_marker || (root.join(".git").exists() && root.join("src").exists())
1392}
1393
1394// ── open_in_system_editor ───────────────────────────────────────────────────
1395
1396pub fn open_in_system_editor(path: &std::path::Path) -> Result<(), String> {
1397    if !path.exists() {
1398        return Err(format!("File not found: {}", path.display()));
1399    }
1400
1401    #[cfg(target_os = "windows")]
1402    {
1403        // On Windows, 'start' is the most reliable way to open a file in the default associated app.
1404        // We use cmd /c start so it handles spaces and associations properly.
1405        let status = std::process::Command::new("cmd")
1406            .args(["/c", "start", "", &path.to_string_lossy()])
1407            .status()
1408            .map_err(|e| format!("Failed to launch editor: {e}"))?;
1409
1410        if !status.success() {
1411            return Err("Editor command failed to start.".into());
1412        }
1413    }
1414
1415    #[cfg(target_os = "macos")]
1416    {
1417        let status = std::process::Command::new("open")
1418            .arg(path)
1419            .status()
1420            .map_err(|e| format!("Failed to launch editor: {e}"))?;
1421
1422        if !status.success() {
1423            return Err("open command failed.".into());
1424        }
1425    }
1426
1427    #[cfg(all(unix, not(target_os = "macos")))]
1428    {
1429        // Try xdg-open on Linux
1430        let status = std::process::Command::new("xdg-open")
1431            .arg(path)
1432            .status()
1433            .map_err(|e| format!("Failed to launch editor: {e}"))?;
1434
1435        if !status.success() {
1436            return Err("xdg-open failed.".into());
1437        }
1438    }
1439
1440    Ok(())
1441}