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