1use serde_json::Value;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6use walkdir::WalkDir;
7
8const 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 if crate::agent::git::is_git_repo(&ws) {
77 let _ = crate::agent::git::create_ghost_snapshot(&ws);
78 }
79
80 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 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 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
168pub 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
200pub 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
236pub 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 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
295pub 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
320pub 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 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 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 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 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
432pub 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 let s_idx = start_line - 1;
458 let e_idx = end_line; updated_lines.extend_from_slice(&lines[0..s_idx]);
462
463 for line in replacement.lines() {
465 updated_lines.push(line.to_string());
466 }
467
468 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#[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 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 (hunk.search.clone(), hunk.replace.clone())
536 } else if match_count == 0 {
537 let span = rstrip_find_span(¤t_content, &hunk.search)
539 .or_else(|| fuzzy_find_span(¤t_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, ¤t_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
582pub 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 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 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
650pub 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
668pub 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 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 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 struct Hunk {
753 path: String,
754 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 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 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 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 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
875fn 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
914fn safe_path(path: &str) -> Result<PathBuf, String> {
918 let candidate = resolve_candidate(path);
919 canonicalize_safe(&candidate, path)
920}
921
922fn safe_path_allow_new(path: &str) -> Result<PathBuf, String> {
924 let candidate = resolve_candidate(path);
925
926 if let Ok(abs) = candidate.canonicalize() {
928 check_workspace_bounds(&abs, path)?;
929 return Ok(abs);
930 }
931
932 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 let upper = path.to_uppercase();
948
949 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 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 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
1015fn 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
1026fn nearest_lines(content: &str, search: &str) -> String {
1029 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 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 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
1078fn 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
1144fn 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
1156fn 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
1165fn 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
1214fn 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 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
1261pub 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
1300pub 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
1332pub 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
1361pub 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
1378pub 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
1394pub 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 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 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}