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 = 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 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 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
169pub async fn read_file(args: &Value, budget_tokens: usize) -> 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
186 let budget_chars = budget_tokens.saturating_mul(4);
188 let char_limit = if budget_tokens == 0 {
189 100_000
190 } else {
191 budget_chars.min(100_000).max(2000)
192 };
193
194 if content.len() > char_limit {
195 content.truncate(char_limit);
196 content.push_str("\n\n--- [PREDICTIVE TRUNCATION: CONTEXT BUDGET REACHED] ---\n");
197 content.push_str(&format!(
198 "Output truncated at {} chars to prevent context window flooding. ",
199 char_limit
200 ));
201 content
202 .push_str("To see more, use `read_file` with a higher `offset` and a smaller `limit`.");
203 } else if end < total {
204 content.push_str("\n\n--- [TRUNCATION WARNING] ---\n");
205 content.push_str(&format!("This file has {} more lines below. ", total - end));
206 content.push_str("To read more, use `read_file` with a higher `offset` OR use `inspect_lines` to find relevant blocks. \
207 Do NOT attempt to read the entire large file at once if it keeps truncating.");
208 }
209
210 Ok(format!(
211 "[{path} lines {}-{} of {}]\n{}",
212 start + 1,
213 end,
214 total,
215 content
216 ))
217}
218
219pub async fn inspect_lines(args: &Value) -> Result<String, String> {
222 let path = require_str(args, "path")?;
223 let start_line = get_usize_arg(args, "start_line").unwrap_or(1);
224 let end_line = get_usize_arg(args, "end_line");
225
226 let abs = safe_path(path)?;
227 let raw = fs::read_to_string(&abs).map_err(|e| format!("inspect_lines: {e} ({path})"))?;
228
229 let lines: Vec<&str> = raw.lines().collect();
230 let total_lines = lines.len();
231
232 if start_line > total_lines && total_lines > 0 {
234 return Err(format!(
235 "Invalid line range: You requested line {}, but the file only has {} lines. Try `read_file` on a smaller range or the whole file.",
236 start_line, total_lines
237 ));
238 }
239
240 let start = start_line.saturating_sub(1).min(total_lines);
241 let end = end_line.unwrap_or(total_lines).min(total_lines);
242
243 if start >= end && total_lines > 0 {
244 return Err(format!(
245 "inspect_lines: start_line ({start_line}) must be <= end_line ({})",
246 end_line.unwrap_or(total_lines)
247 ));
248 }
249
250 let mut output = format!(
251 "[inspect_lines: {path} lines {}-{} of {}]\n",
252 start + 1,
253 end,
254 total_lines
255 );
256 for i in start..end {
257 output.push_str(&format!("[{:>4}] | {}\n", i + 1, lines[i]));
258 }
259
260 Ok(output)
261}
262
263pub async fn tail_file(args: &Value) -> Result<String, String> {
266 let path = require_str(args, "path")?;
267 let n = args
268 .get("lines")
269 .and_then(|v| v.as_u64())
270 .unwrap_or(50)
271 .min(500) as usize;
272 let grep_pat = args.get("grep").and_then(|v| v.as_str());
273
274 let abs = safe_path(path)?;
275 let raw = fs::read_to_string(&abs).map_err(|e| format!("tail_file: {e} ({path})"))?;
276
277 let all_lines: Vec<&str> = raw.lines().collect();
278 let total = all_lines.len();
279
280 let filtered: Vec<(usize, &str)> = if let Some(pat) = grep_pat {
283 let re = regex::Regex::new(pat)
284 .map_err(|e| format!("tail_file: invalid grep pattern '{pat}': {e}"))?;
285 all_lines
286 .iter()
287 .enumerate()
288 .filter(|(_, l)| re.is_match(l))
289 .map(|(i, l)| (i, *l))
290 .collect()
291 } else {
292 all_lines.iter().enumerate().map(|(i, l)| (i, *l)).collect()
293 };
294
295 let total_filtered = filtered.len();
296 let skip = total_filtered.saturating_sub(n);
297 let window = &filtered[skip..];
298
299 if window.is_empty() {
300 let note = if grep_pat.is_some() {
301 format!(" matching '{}'", grep_pat.unwrap())
302 } else {
303 String::new()
304 };
305 return Ok(format!(
306 "[tail_file: {path} — no lines{note} found (total {total} lines)]"
307 ));
308 }
309
310 let first_abs = window[0].0 + 1;
311 let last_abs = window[window.len() - 1].0 + 1;
312 let mut out = format!(
313 "[tail_file: {path} — lines {first_abs}–{last_abs} of {total} (last {n} of {total_filtered} matched)]\n"
314 );
315 for (abs_idx, line) in window {
316 out.push_str(&format!("[{:>5}] {}\n", abs_idx + 1, line));
317 }
318
319 Ok(out)
320}
321
322pub async fn write_file(args: &Value) -> Result<String, String> {
325 let path = require_str(args, "path")?;
326 let content = require_str(args, "content")?;
327
328 let abs = safe_path_allow_new(path)?;
329 if let Some(parent) = abs.parent() {
330 fs::create_dir_all(parent)
331 .map_err(|e| format!("write_file: could not create dirs: {e}"))?;
332 }
333
334 let existed = abs.exists();
335 if existed {
336 if let Ok(orig) = fs::read_to_string(&abs) {
337 save_ghost_backup(path, &orig);
338 }
339 }
340
341 fs::write(&abs, content).map_err(|e| format!("write_file: {e} ({path})"))?;
342
343 let action = if existed { "Updated" } else { "Created" };
344 Ok(format!("{action} {path} ({} bytes)", content.len()))
345}
346
347pub async fn edit_file(args: &Value) -> Result<String, String> {
350 let path = require_str(args, "path")?;
351 let search = require_str(args, "search")?;
352 let replace = require_str(args, "replace")?;
353 let replace_all = args
354 .get("replace_all")
355 .and_then(|v| v.as_bool())
356 .unwrap_or(false);
357
358 if search == replace {
359 return Err("edit_file: 'search' and 'replace' are identical — no change needed".into());
360 }
361
362 let abs = safe_path(path)?;
363 let raw = fs::read_to_string(&abs).map_err(|e| format!("edit_file: {e} ({path})"))?;
364 let original = raw.replace("\r\n", "\n");
366
367 save_ghost_backup(path, &original);
368
369 let search_trimmed = search.trim();
370 let search_non_ws_len = search_trimmed
371 .chars()
372 .filter(|c| !c.is_whitespace())
373 .count();
374 let search_line_count = search_trimmed.lines().count();
375 if search_non_ws_len < 12 && search_line_count <= 1 {
376 return Err(format!(
377 "edit_file: search string is too short or generic for a safe mutation in {path}.\n\
378 Provide a more specific anchor (prefer a full line, multiple lines, or use `inspect_lines` + `patch_hunk`)."
379 ));
380 }
381
382 let (effective_search, was_repaired) = if original.contains(search) {
384 let exact_match_count = original.matches(search).count();
385 if exact_match_count > 1 && !replace_all {
386 return Err(format!(
387 "edit_file: search string matched {} times in {path}.\n\
388 Provide a more specific unique anchor or use `inspect_lines` + `patch_hunk`.",
389 exact_match_count
390 ));
391 }
392 (search.to_string(), false)
393 } else {
394 let span = rstrip_find_span(&original, search)
399 .or_else(|| indent_flexible_find_span(&original, search))
400 .or_else(|| fuzzy_find_span(&original, search));
401 match span {
402 Some(span) => {
403 let real_slice = original[span.clone()].to_string();
404 (real_slice, true)
405 }
406 None => {
407 let hint = nearest_lines(&original, search);
408 let cross_hint = find_search_in_workspace(search, path)
409 .map(|found| format!("\nNote: search string found in '{found}' — did you mean to edit that file?"))
410 .unwrap_or_default();
411 return Err(format!(
412 "edit_file: search string not found in {path}.\n\
413 The 'search' value must match the file content exactly \
414 (including whitespace/indentation).\n\
415 {hint}{cross_hint}"
416 ));
417 }
418 }
419 };
420
421 let effective_replace = if was_repaired {
424 adjust_replace_indent(search, effective_search.as_str(), replace)
425 } else {
426 replace.to_string()
427 };
428
429 let updated = if replace_all {
430 original.replace(effective_search.as_str(), effective_replace.as_str())
431 } else {
432 original.replacen(effective_search.as_str(), effective_replace.as_str(), 1)
433 };
434
435 fs::write(&abs, &updated).map_err(|e| format!("edit_file: write failed: {e}"))?;
436
437 let removed = original.lines().count();
438 let added = updated.lines().count();
439 let repair_note = if was_repaired {
440 " [indent auto-corrected]"
441 } else {
442 ""
443 };
444
445 let mut diff_block = String::new();
446 diff_block.push_str("\n--- DIFF \n");
447 for line in effective_search.lines() {
448 diff_block.push_str(&format!("- {}\n", line));
449 }
450 for line in effective_replace.lines() {
451 diff_block.push_str(&format!("+ {}\n", line));
452 }
453
454 Ok(format!(
455 "Edited {path} ({} -> {} lines){repair_note}{}",
456 removed, added, diff_block
457 ))
458}
459
460pub async fn patch_hunk(args: &Value) -> Result<String, String> {
463 let path = require_str(args, "path")?;
464 let start_line = require_usize(args, "start_line")?;
465 let end_line = require_usize(args, "end_line")?;
466 let replacement = require_str(args, "replacement")?;
467
468 let abs = safe_path(path)?;
469 let original = fs::read_to_string(&abs).map_err(|e| format!("patch_hunk: {e} ({path})"))?;
470
471 save_ghost_backup(path, &original);
472
473 let lines: Vec<String> = original.lines().map(|s| s.to_string()).collect();
474 let total = lines.len();
475
476 if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
477 return Err(format!(
478 "patch_hunk: invalid line range {}-{} for file with {} lines",
479 start_line, end_line, total
480 ));
481 }
482
483 let mut updated_lines = Vec::new();
484 let s_idx = start_line - 1;
486 let e_idx = end_line; updated_lines.extend_from_slice(&lines[0..s_idx]);
490
491 for line in replacement.lines() {
493 updated_lines.push(line.to_string());
494 }
495
496 if e_idx < total {
498 updated_lines.extend_from_slice(&lines[e_idx..total]);
499 }
500
501 let updated_content = updated_lines.join("\n");
502 fs::write(&abs, &updated_content).map_err(|e| format!("patch_hunk: write failed: {e}"))?;
503
504 let mut diff = String::new();
505 diff.push_str("\n--- HUNK DIFF ---\n");
506 for i in s_idx..e_idx {
507 diff.push_str(&format!("- {}\n", lines[i].trim_end()));
508 }
509 for line in replacement.lines() {
510 diff.push_str(&format!("+ {}\n", line.trim_end()));
511 }
512
513 Ok(format!(
514 "Patched {path} lines {}-{} ({} -> {} lines){}",
515 start_line,
516 end_line,
517 (e_idx - s_idx),
518 replacement.lines().count(),
519 diff
520 ))
521}
522
523#[derive(serde::Deserialize)]
526struct SearchReplaceHunk {
527 search: String,
528 replace: String,
529}
530
531pub async fn multi_search_replace(args: &Value) -> Result<String, String> {
532 let path = require_str(args, "path")?;
533 let hunks_val = args
534 .get("hunks")
535 .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
536
537 let hunks: Vec<SearchReplaceHunk> = serde_json::from_value(hunks_val.clone())
538 .map_err(|e| format!("multi_search_replace: invalid hunks array: {e}"))?;
539
540 if hunks.is_empty() {
541 return Err("multi_search_replace: hunks array is empty".to_string());
542 }
543
544 let abs = safe_path(path)?;
545 let raw =
546 fs::read_to_string(&abs).map_err(|e| format!("multi_search_replace: {e} ({path})"))?;
547 let original = raw.replace("\r\n", "\n");
549
550 save_ghost_backup(path, &original);
551
552 let mut current_content = original.clone();
553 let mut diff = String::new();
554 diff.push_str("\n--- SEARCH & REPLACE DIFF ---\n");
555
556 let mut patched_hunks = 0;
557
558 for (i, hunk) in hunks.iter().enumerate() {
559 let match_count = current_content.matches(&hunk.search).count();
560
561 let (effective_search, effective_replace) = if match_count == 1 {
562 (hunk.search.clone(), hunk.replace.clone())
564 } else if match_count == 0 {
565 let span = rstrip_find_span(¤t_content, &hunk.search)
567 .or_else(|| indent_flexible_find_span(¤t_content, &hunk.search))
568 .or_else(|| fuzzy_find_span(¤t_content, &hunk.search));
569 match span {
570 Some(span) => {
571 let real_slice = current_content[span].to_string();
572 let adjusted_replace =
573 adjust_replace_indent(&hunk.search, &real_slice, &hunk.replace);
574 (real_slice, adjusted_replace)
575 }
576 None => {
577 return Err(format!(
578 "multi_search_replace: hunk {} search string not found in file.",
579 i
580 ));
581 }
582 }
583 } else {
584 return Err(format!(
585 "multi_search_replace: hunk {} search string matched {} times. Provide more context to make it unique.",
586 i, match_count
587 ));
588 };
589
590 diff.push_str(&format!("\n@@ Hunk {} @@\n", i + 1));
591 for line in effective_search.lines() {
592 diff.push_str(&format!("- {}\n", line.trim_end()));
593 }
594 for line in effective_replace.lines() {
595 diff.push_str(&format!("+ {}\n", line.trim_end()));
596 }
597
598 current_content = current_content.replacen(&effective_search, &effective_replace, 1);
599 patched_hunks += 1;
600 }
601
602 fs::write(&abs, ¤t_content)
603 .map_err(|e| format!("multi_search_replace: write failed: {e}"))?;
604
605 Ok(format!(
606 "Modified {} hunks in {} using exact search-and-replace.{}",
607 patched_hunks, path, diff
608 ))
609}
610
611pub async fn list_files(args: &Value, budget: usize) -> Result<String, String> {
614 let char_budget = budget * 4; let started = Instant::now();
616 let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
617 let ext_filter = args.get("extension").and_then(|v| v.as_str());
618
619 let base = safe_path(base_str)?;
620
621 let mut files: Vec<PathBuf> = Vec::new();
622 let mut scanned_count = 0;
623 for entry in WalkDir::new(&base).follow_links(false) {
624 scanned_count += 1;
625 if scanned_count > 25_000 {
626 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());
627 }
628 let entry = entry.map_err(|e| format!("list_files: {e}"))?;
629 if !entry.file_type().is_file() {
630 continue;
631 }
632 let p = entry.path();
633
634 if path_has_hidden_segment(p) {
636 continue;
637 }
638
639 if let Some(ext) = ext_filter {
640 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
641 continue;
642 }
643 }
644 files.push(p.to_path_buf());
645 }
646
647 files.sort_by_key(|p| {
649 fs::metadata(p)
650 .and_then(|m| m.modified())
651 .ok()
652 .map(std::cmp::Reverse)
653 });
654
655 let mut current_chars = 0;
656 let mut shown = Vec::new();
657 let mut truncated_by_budget = false;
658
659 let total_scanned = files.len();
660 for f in files {
661 let f_str = f.display().to_string();
662 if current_chars + f_str.len() + 1 > char_budget {
663 truncated_by_budget = true;
664 break;
665 }
666 current_chars += f_str.len() + 1;
667 shown.push(f_str);
668 if shown.len() >= 200 {
669 break;
670 }
671 }
672
673 let truncated = total_scanned > shown.len();
674
675 let ms = started.elapsed().as_millis();
676 let mut out = format!(
677 "{} file(s) in {} ({ms}ms){}",
678 shown.len(),
679 base_str,
680 if truncated {
681 if truncated_by_budget {
682 " [truncated by token budget]"
683 } else {
684 " [truncated at 200]"
685 }
686 } else {
687 ""
688 }
689 );
690 out.push('\n');
691 out.push_str(&shown.join("\n"));
692 Ok(out)
693}
694
695pub async fn create_directory(args: &Value) -> Result<String, String> {
698 let path = require_str(args, "path")?;
699 let abs = safe_path_allow_new(path)?;
700
701 if abs.exists() {
702 if abs.is_dir() {
703 return Ok(format!("Directory already exists: {path}"));
704 } else {
705 return Err(format!("A file already exists at this path: {path}"));
706 }
707 }
708
709 fs::create_dir_all(&abs).map_err(|e| format!("create_directory: {e} ({path})"))?;
710 Ok(format!("Created directory: {path}"))
711}
712
713pub async fn grep_files(args: &Value, budget: usize) -> Result<String, String> {
716 let char_budget = budget * 4;
717 let pattern = require_str(args, "pattern")?;
718 let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
719 let ext_filter = args.get("extension").and_then(|v| v.as_str());
720 let case_insensitive = args
721 .get("case_insensitive")
722 .and_then(|v| v.as_bool())
723 .unwrap_or(true);
724 let files_only = args.get("mode").and_then(|v| v.as_str()) == Some("files_only");
725 let head_limit = get_usize_arg(args, "head_limit").unwrap_or(50);
726 let offset = get_usize_arg(args, "offset").unwrap_or(0);
727
728 let ctx_default = get_usize_arg(args, "context").unwrap_or(0);
730 let before = get_usize_arg(args, "before").unwrap_or(ctx_default);
731 let after = get_usize_arg(args, "after").unwrap_or(ctx_default);
732
733 let base = safe_path(base_str)?;
734
735 let regex = regex::RegexBuilder::new(pattern)
736 .case_insensitive(case_insensitive)
737 .build()
738 .map_err(|e| format!("grep_files: invalid pattern '{pattern}': {e}"))?;
739
740 if files_only {
742 let mut matched_files: Vec<String> = Vec::new();
743 let mut scanned_count = 0;
744
745 for entry in WalkDir::new(&base).follow_links(false) {
746 scanned_count += 1;
747 if scanned_count > 25_000 {
748 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());
749 }
750 let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
751 if !entry.file_type().is_file() {
752 continue;
753 }
754 let p = entry.path();
755 if path_has_hidden_segment(p) {
756 continue;
757 }
758 if let Some(ext) = ext_filter {
759 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
760 continue;
761 }
762 }
763 let Ok(contents) = fs::read_to_string(p) else {
764 continue;
765 };
766 if contents.lines().any(|line| regex.is_match(line)) {
767 matched_files.push(p.display().to_string());
768 }
769 }
770
771 if matched_files.is_empty() {
772 return Ok(format!("No files matching '{pattern}' in {base_str}"));
773 }
774
775 let total = matched_files.len();
776 let page: Vec<_> = matched_files
777 .into_iter()
778 .skip(offset)
779 .take(head_limit)
780 .collect();
781 let showing = page.len();
782
783 let mut out = format!("{total} file(s) match '{pattern}'");
784 if offset > 0 || showing < total {
785 out.push_str(&format!(
786 " [showing {}-{} of {total}]",
787 offset + 1,
788 offset + showing
789 ));
790 }
791 out.push('\n');
792
793 let mut current_chars = out.len();
794 let mut shown_pages = Vec::new();
795 for p in page {
796 if current_chars + p.len() + 1 > char_budget {
797 out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
798 break;
799 }
800 current_chars += p.len() + 1;
801 shown_pages.push(p);
802 }
803 out.push_str(&shown_pages.join("\n"));
804 return Ok(out);
805 }
806
807 struct Hunk {
811 path: String,
812 lines: Vec<(usize, String, bool)>,
814 }
815
816 let mut hunks: Vec<Hunk> = Vec::new();
817 let mut total_matches = 0usize;
818 let mut files_matched = 0usize;
819 let mut scanned_count = 0;
820
821 for entry in WalkDir::new(&base).follow_links(false) {
822 scanned_count += 1;
823 if scanned_count > 25_000 {
824 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());
825 }
826 let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
827 if !entry.file_type().is_file() {
828 continue;
829 }
830 let p = entry.path();
831 if path_has_hidden_segment(p) {
832 continue;
833 }
834 if let Some(ext) = ext_filter {
835 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
836 continue;
837 }
838 }
839 let Ok(contents) = fs::read_to_string(p) else {
840 continue;
841 };
842 let all_lines: Vec<&str> = contents.lines().collect();
843 let n = all_lines.len();
844
845 let match_idxs: Vec<usize> = all_lines
847 .iter()
848 .enumerate()
849 .filter(|(_, line)| regex.is_match(line))
850 .map(|(i, _)| i)
851 .collect();
852
853 if match_idxs.is_empty() {
854 continue;
855 }
856 files_matched += 1;
857 total_matches += match_idxs.len();
858
859 let path_str = p.display().to_string();
861 let mut ranges: Vec<(usize, usize)> = match_idxs
862 .iter()
863 .map(|&i| {
864 (
865 i.saturating_sub(before),
866 (i + after).min(n.saturating_sub(1)),
867 )
868 })
869 .collect();
870
871 ranges.sort_unstable();
873 let mut merged: Vec<(usize, usize)> = Vec::new();
874 for (s, e) in ranges {
875 if let Some(last) = merged.last_mut() {
876 if s <= last.1 + 1 {
877 last.1 = last.1.max(e);
878 continue;
879 }
880 }
881 merged.push((s, e));
882 }
883
884 let match_set: std::collections::HashSet<usize> = match_idxs.into_iter().collect();
886 for (start, end) in merged {
887 let mut hunk_lines = Vec::new();
888 for i in start..=end {
889 hunk_lines.push((i + 1, all_lines[i].to_string(), match_set.contains(&i)));
890 }
891 hunks.push(Hunk {
892 path: path_str.clone(),
893 lines: hunk_lines,
894 });
895 }
896 }
897
898 if hunks.is_empty() {
899 return Ok(format!("No matches for '{pattern}' in {base_str}"));
900 }
901
902 let total_hunks = hunks.len();
903 let page_hunks: Vec<_> = hunks.into_iter().skip(offset).take(head_limit).collect();
904 let showing = page_hunks.len();
905
906 let mut out =
907 format!("{total_matches} match(es) across {files_matched} file(s), {total_hunks} hunk(s)");
908 if offset > 0 || showing < total_hunks {
909 out.push_str(&format!(
910 " [hunks {}-{} of {total_hunks}]",
911 offset + 1,
912 offset + showing
913 ));
914 }
915 out.push('\n');
916
917 let mut current_chars = out.len();
918 let mut truncated_by_budget = false;
919
920 for (i, hunk) in page_hunks.iter().enumerate() {
921 let mut hunk_out = String::new();
922 if i > 0 {
923 hunk_out.push_str("\n--\n");
924 }
925 for (lineno, text, is_match) in &hunk.lines {
926 if *is_match {
927 hunk_out.push_str(&format!("{}:{}:{}\n", hunk.path, lineno, text));
928 } else {
929 hunk_out.push_str(&format!("{}: {}-{}\n", hunk.path, lineno, text));
930 }
931 }
932
933 if current_chars + hunk_out.len() > char_budget {
934 truncated_by_budget = true;
935 break;
936 }
937 current_chars += hunk_out.len();
938 out.push_str(&hunk_out);
939 }
940
941 if truncated_by_budget {
942 out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
943 }
944
945 Ok(out.trim_end().to_string())
946}
947
948fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
951 args.get(key)
952 .and_then(|v| v.as_str())
953 .ok_or_else(|| format!("Missing required argument: '{key}'"))
954}
955
956fn get_usize_arg(args: &Value, key: &str) -> Option<usize> {
957 args.get(key).and_then(value_as_usize)
958}
959
960fn require_usize(args: &Value, key: &str) -> Result<usize, String> {
961 get_usize_arg(args, key).ok_or_else(|| format!("Missing required numeric argument: '{key}'"))
962}
963
964fn value_as_usize(value: &Value) -> Option<usize> {
965 if let Some(v) = value.as_u64() {
966 return usize::try_from(v).ok();
967 }
968
969 if let Some(v) = value.as_i64() {
970 return if v >= 0 {
971 usize::try_from(v as u64).ok()
972 } else {
973 None
974 };
975 }
976
977 if let Some(v) = value.as_f64() {
978 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= (usize::MAX as f64) {
979 return Some(v as usize);
980 }
981 return None;
982 }
983
984 value.as_str().and_then(|s| s.trim().parse::<usize>().ok())
985}
986
987fn safe_path(path: &str) -> Result<PathBuf, String> {
991 let candidate = resolve_candidate(path);
992 match canonicalize_safe(&candidate, path) {
993 Ok(abs) => Ok(abs),
994 Err(e) => {
995 if e.contains("The system cannot find the file specified") || e.contains("os error 2") {
996 if let Some(suggestion) = suggest_better_path(path) {
997 return Err(format!("{e}. Did you mean '{suggestion}'?"));
998 }
999 }
1000 Err(e)
1001 }
1002 }
1003}
1004
1005fn suggest_better_path(original: &str) -> Option<String> {
1006 let path = Path::new(original);
1007 let filename = path.file_name()?.to_str()?.to_lowercase();
1008 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1009
1010 let abs_parent = resolve_candidate(&parent.to_string_lossy())
1012 .canonicalize()
1013 .ok()?;
1014
1015 let mut best_match = None;
1016 let mut best_score = 0;
1017
1018 if let Ok(entries) = fs::read_dir(abs_parent) {
1019 for entry in entries.flatten() {
1020 if let Some(candidate_name) = entry.file_name().to_str() {
1021 let lower_candidate = candidate_name.to_lowercase();
1022 if lower_candidate == filename {
1023 continue;
1024 }
1025
1026 let mut score = 0;
1027 if lower_candidate.starts_with(&filename) || filename.starts_with(&lower_candidate)
1028 {
1029 score += 10;
1030 }
1031 if (filename.ends_with('s') && filename[..filename.len() - 1] == lower_candidate)
1033 || (lower_candidate.ends_with('s')
1034 && lower_candidate[..lower_candidate.len() - 1] == filename)
1035 {
1036 score += 20;
1037 }
1038
1039 if score > best_score {
1040 best_score = score;
1041 best_match = Some(candidate_name.to_string());
1042 }
1043 }
1044 }
1045 }
1046
1047 if best_score >= 10 {
1048 best_match
1049 } else {
1050 None
1051 }
1052}
1053
1054fn safe_path_allow_new(path: &str) -> Result<PathBuf, String> {
1056 let candidate = resolve_candidate(path);
1057
1058 if let Ok(abs) = candidate.canonicalize() {
1060 check_workspace_bounds(&abs, path)?;
1061 return Ok(abs);
1062 }
1063
1064 let parent = candidate.parent().unwrap_or(Path::new("."));
1066 let name = candidate
1067 .file_name()
1068 .ok_or_else(|| format!("invalid path: {path}"))?;
1069 let abs_parent = parent
1070 .canonicalize()
1071 .map_err(|_| format!("safe_path: parent dir doesn't exist for {path}"))?;
1072 let abs = abs_parent.join(name);
1073 check_workspace_bounds(&abs, path)?;
1074 Ok(abs)
1075}
1076
1077pub(crate) fn resolve_candidate(path: &str) -> PathBuf {
1078 let upper = path.to_uppercase();
1080
1081 let bare = upper.trim_end_matches('/').trim_start_matches('@');
1084 let bare_resolved = match bare {
1085 "DESKTOP" => dirs::desktop_dir(),
1086 "DOWNLOADS" | "DOWNLOAD" => dirs::download_dir(),
1087 "DOCUMENTS" | "DOCS" => dirs::document_dir(),
1088 "PICTURES" | "IMAGES" => dirs::picture_dir(),
1089 "VIDEOS" | "MOVIES" => dirs::video_dir(),
1090 "MUSIC" | "AUDIO" => dirs::audio_dir(),
1091 "HOME" => dirs::home_dir(),
1092 "TEMP" | "TMP" => Some(std::env::temp_dir()),
1093 "CACHE" => dirs::cache_dir(),
1094 "CONFIG" => dirs::config_dir(),
1095 "DATA" => dirs::data_dir(),
1096 _ => None,
1097 };
1098 let bare_resolved = bare_resolved.or_else(|| {
1100 if path == "~" || path == "~/" {
1101 dirs::home_dir()
1102 } else {
1103 None
1104 }
1105 });
1106 if let Some(p) = bare_resolved {
1107 return p;
1108 }
1109
1110 let resolved = if upper.starts_with("@DESKTOP/") {
1112 dirs::desktop_dir().map(|p| p.join(&path[9..]))
1113 } else if upper.starts_with("@DOCUMENTS/") {
1114 dirs::document_dir().map(|p| p.join(&path[11..]))
1115 } else if upper.starts_with("@DOWNLOADS/") {
1116 dirs::download_dir().map(|p| p.join(&path[11..]))
1117 } else if upper.starts_with("@PICTURES/") || upper.starts_with("@IMAGES/") {
1118 let offset = if upper.starts_with("@PICTURES/") {
1119 10
1120 } else {
1121 8
1122 };
1123 dirs::picture_dir().map(|p| p.join(&path[offset..]))
1124 } else if upper.starts_with("@VIDEOS/") || upper.starts_with("@MOVIES/") {
1125 let offset = if upper.starts_with("@VIDEOS/") { 8 } else { 8 };
1126 dirs::video_dir().map(|p| p.join(&path[offset..]))
1127 } else if upper.starts_with("@MUSIC/") || upper.starts_with("@AUDIO/") {
1128 let offset = if upper.starts_with("@MUSIC/") { 7 } else { 7 };
1129 dirs::audio_dir().map(|p| p.join(&path[offset..]))
1130 } else if upper.starts_with("@HOME/") || upper.starts_with("~/") {
1131 let offset = if upper.starts_with("@HOME/") { 6 } else { 2 };
1132 dirs::home_dir().map(|p| p.join(&path[offset..]))
1133 } else if upper.starts_with("@TEMP/") {
1134 Some(std::env::temp_dir().join(&path[6..]))
1135 } else if upper.starts_with("@CACHE/") {
1136 dirs::cache_dir().map(|p| p.join(&path[7..]))
1137 } else if upper.starts_with("@CONFIG/") {
1138 dirs::config_dir().map(|p| p.join(&path[8..]))
1139 } else if upper.starts_with("@DATA/") {
1140 dirs::data_dir().map(|p| p.join(&path[6..]))
1141 } else {
1142 None
1143 };
1144
1145 if let Some(p) = resolved {
1146 return p;
1147 }
1148
1149 let p = Path::new(path);
1151 if p.is_absolute() {
1152 p.to_path_buf()
1153 } else {
1154 std::env::current_dir()
1155 .unwrap_or_else(|_| PathBuf::from("."))
1156 .join(p)
1157 }
1158}
1159
1160fn canonicalize_safe(candidate: &Path, original: &str) -> Result<PathBuf, String> {
1161 let abs = candidate
1162 .canonicalize()
1163 .map_err(|e: io::Error| format!("safe_path: {e} ({original})"))?;
1164 check_workspace_bounds(&abs, original)?;
1165 Ok(abs)
1166}
1167
1168fn is_allowed_plan_sidecar(workspace: &Path, abs: &Path) -> bool {
1169 let normalized = abs
1170 .to_string_lossy()
1171 .trim_start_matches(r"\\?\")
1172 .to_lowercase()
1173 .replace('\\', "/");
1174 let workspace_norm = workspace
1175 .to_string_lossy()
1176 .trim_start_matches(r"\\?\")
1177 .to_lowercase()
1178 .replace('\\', "/");
1179
1180 if !normalized.starts_with(&workspace_norm) {
1181 return false;
1182 }
1183
1184 normalized.ends_with("/.hematite/task.md")
1185 || normalized.ends_with("/.hematite/plan.md")
1186 || normalized.ends_with("/.hematite/walkthrough.md")
1187}
1188
1189fn check_workspace_bounds(abs: &Path, original: &str) -> Result<(), String> {
1190 let workspace = std::env::current_dir().map_err(|e| format!("could not read cwd: {e}"))?;
1191 if is_allowed_plan_sidecar(&workspace, abs) {
1192 return Ok(());
1193 }
1194
1195 super::guard::path_is_safe(&workspace, abs)
1197 .map(|_| ())
1198 .map_err(|e| format!("file access denied for '{original}': {e}"))
1199}
1200
1201fn path_has_hidden_segment(p: &Path) -> bool {
1203 p.components().any(|c| {
1204 let s = c.as_os_str().to_string_lossy();
1205 if s == ".hematite" || s == ".git" || s == "." || s == ".." {
1206 return false;
1207 }
1208 s.starts_with('.') || s == "target" || s == "node_modules" || s == "__pycache__"
1209 })
1210}
1211
1212fn nearest_lines(content: &str, search: &str) -> String {
1215 let first_search_line = search
1217 .lines()
1218 .map(|l| l.trim())
1219 .find(|l| !l.is_empty())
1220 .unwrap_or("");
1221
1222 let lines: Vec<&str> = content.lines().collect();
1223 if lines.is_empty() {
1224 return "(file is empty)".into();
1225 }
1226
1227 let best_idx = if first_search_line.is_empty() {
1229 0
1230 } else {
1231 lines
1232 .iter()
1233 .enumerate()
1234 .max_by_key(|(_, l)| {
1235 let lt = l.trim();
1236 first_search_line
1238 .chars()
1239 .zip(lt.chars())
1240 .take_while(|(a, b)| a == b)
1241 .count()
1242 })
1243 .map(|(i, _)| i)
1244 .unwrap_or(0)
1245 };
1246
1247 let start = best_idx.saturating_sub(3);
1248 let end = (best_idx + 5).min(lines.len());
1249 let snippet = lines[start..end]
1250 .iter()
1251 .enumerate()
1252 .map(|(i, l)| format!("{:>4} | {}", start + i + 1, l))
1253 .collect::<Vec<_>>()
1254 .join("\n");
1255
1256 format!(
1257 "Nearest matching lines ({}:{}):\n{}",
1258 best_idx + 1,
1259 end,
1260 snippet
1261 )
1262}
1263
1264fn find_span_normalised(
1269 content: &str,
1270 search: &str,
1271 normalise: impl Fn(&str) -> String,
1272) -> Option<std::ops::Range<usize>> {
1273 let norm_content = normalise(content);
1274 let norm_search = normalise(search)
1275 .trim_start_matches('\n')
1276 .trim_end_matches('\n')
1277 .to_string();
1278
1279 if norm_search.is_empty() {
1280 return None;
1281 }
1282
1283 let norm_pos = norm_content.find(&norm_search)?;
1284
1285 let lines_before = norm_content[..norm_pos]
1286 .as_bytes()
1287 .iter()
1288 .filter(|&&b| b == b'\n')
1289 .count();
1290 let search_lines = norm_search
1291 .as_bytes()
1292 .iter()
1293 .filter(|&&b| b == b'\n')
1294 .count()
1295 + 1;
1296
1297 let orig_lines: Vec<&str> = content.lines().collect();
1298
1299 let mut current_pos = 0;
1300 for i in 0..lines_before {
1301 if i < orig_lines.len() {
1302 current_pos += orig_lines[i].len() + 1;
1303 }
1304 }
1305 let byte_start = current_pos;
1306
1307 let mut byte_len = 0;
1308 for i in 0..search_lines {
1309 let idx = lines_before + i;
1310 if idx < orig_lines.len() {
1311 byte_len += orig_lines[idx].len();
1312 if i < search_lines - 1 {
1313 byte_len += 1;
1314 }
1315 }
1316 }
1317
1318 if byte_start + byte_len > content.len() {
1319 return None;
1320 }
1321
1322 let candidate = &content[byte_start..byte_start + byte_len];
1323 if normalise(candidate).trim_end_matches('\n') == norm_search.as_str() {
1324 Some(byte_start..byte_start + byte_len)
1325 } else {
1326 None
1327 }
1328}
1329
1330fn rstrip_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1334 find_span_normalised(content, search, |s| {
1335 s.lines()
1336 .map(|l| l.trim_end())
1337 .collect::<Vec<_>>()
1338 .join("\n")
1339 })
1340}
1341
1342fn indent_flexible_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1347 let norm_search = dedent(search.trim_matches('\n'));
1348 if norm_search.trim().is_empty() {
1349 return None;
1350 }
1351 let search_line_count = norm_search.lines().count();
1352 let content_lines: Vec<&str> = content.lines().collect();
1353 if content_lines.len() < search_line_count {
1354 return None;
1355 }
1356
1357 let mut line_starts: Vec<usize> = Vec::with_capacity(content_lines.len() + 1);
1359 let mut pos = 0usize;
1360 for line in &content_lines {
1361 line_starts.push(pos);
1362 pos += line.len() + 1; }
1364 line_starts.push(pos);
1365
1366 for start in 0..=(content_lines.len() - search_line_count) {
1367 let window = content_lines[start..start + search_line_count].join("\n");
1368 if dedent(&window) == norm_search {
1369 let byte_start = line_starts[start];
1370 let end_line = start + search_line_count;
1371 let byte_end = if end_line < content_lines.len() {
1372 line_starts[end_line] - 1 } else {
1374 content.len()
1375 };
1376 return Some(byte_start..byte_end);
1377 }
1378 }
1379 None
1380}
1381
1382fn fuzzy_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1385 find_span_normalised(content, search, |s| {
1386 s.lines().map(|l| l.trim()).collect::<Vec<_>>().join("\n")
1387 })
1388}
1389
1390fn find_search_in_workspace(search: &str, skip_path: &str) -> Option<String> {
1395 let root = workspace_root();
1396 let norm_search = search.replace("\r\n", "\n");
1397 let mut checked = 0usize;
1398
1399 let walker = ignore::WalkBuilder::new(&root)
1400 .hidden(true)
1401 .ignore(true)
1402 .git_ignore(true)
1403 .build();
1404
1405 for entry in walker.flatten() {
1406 if checked >= 100 {
1407 break;
1408 }
1409 let path = entry.path();
1410 if !path.is_file() {
1411 continue;
1412 }
1413 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1414 if !matches!(
1415 ext,
1416 "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "c" | "cpp" | "h"
1417 ) {
1418 continue;
1419 }
1420 let rel = path
1421 .strip_prefix(&root)
1422 .unwrap_or(path)
1423 .to_string_lossy()
1424 .replace('\\', "/");
1425 if rel == skip_path {
1426 continue;
1427 }
1428 checked += 1;
1429 if let Ok(content) = std::fs::read_to_string(path) {
1430 let normalised = content.replace("\r\n", "\n");
1431 if normalised.contains(&norm_search) {
1432 return Some(rel);
1433 }
1434 }
1435 }
1436 None
1437}
1438
1439fn dedent(s: &str) -> String {
1445 let expanded: Vec<String> = s.lines().map(|l| l.replace('\t', " ")).collect();
1446 let min_indent = expanded
1447 .iter()
1448 .filter(|l| !l.trim().is_empty())
1449 .map(|l| l.len() - l.trim_start_matches(' ').len())
1450 .min()
1451 .unwrap_or(0);
1452 expanded
1453 .iter()
1454 .map(|l| {
1455 if l.trim().is_empty() {
1456 String::new()
1457 } else {
1458 l.get(min_indent..).unwrap_or(l).trim_end().to_string()
1459 }
1460 })
1461 .collect::<Vec<_>>()
1462 .join("\n")
1463}
1464
1465fn adjust_replace_indent(search: &str, file_span: &str, replace: &str) -> String {
1472 fn first_indent(s: &str) -> usize {
1473 s.lines()
1474 .find(|l| !l.trim().is_empty())
1475 .map(|l| l.len() - l.trim_start_matches(' ').len())
1476 .unwrap_or(0)
1477 }
1478
1479 let search_indent = first_indent(search);
1480 let file_indent = first_indent(file_span);
1481
1482 if search_indent == file_indent {
1483 return replace.to_string();
1484 }
1485
1486 let delta: i64 = file_indent as i64 - search_indent as i64;
1487 let trailing_newline = replace.ends_with('\n');
1488
1489 let adjusted: Vec<String> = replace
1490 .lines()
1491 .map(|line| {
1492 if line.trim().is_empty() {
1493 line.to_string()
1495 } else {
1496 let current_indent = line.len() - line.trim_start_matches(' ').len();
1497 let new_indent = (current_indent as i64 + delta).max(0) as usize;
1498 format!("{}{}", " ".repeat(new_indent), line.trim_start_matches(' '))
1499 }
1500 })
1501 .collect();
1502
1503 let mut result = adjusted.join("\n");
1504 if trailing_newline {
1505 result.push('\n');
1506 }
1507 result
1508}
1509
1510pub fn compute_edit_file_diff(args: &Value) -> Result<String, String> {
1516 let path = require_str(args, "path")?;
1517 let search = require_str(args, "search")?;
1518 let replace = require_str(args, "replace")?;
1519
1520 let abs = safe_path(path)?;
1521 let raw = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1522 let original = raw.replace("\r\n", "\n");
1523
1524 let (effective_search, effective_replace): (String, String) = if original.contains(search) {
1525 (search.to_string(), replace.to_string())
1526 } else {
1527 let span = rstrip_find_span(&original, search)
1528 .or_else(|| indent_flexible_find_span(&original, search))
1529 .or_else(|| fuzzy_find_span(&original, search));
1530 match span {
1531 Some(span) => {
1532 let real_slice = original[span].to_string();
1533 let adjusted = adjust_replace_indent(search, &real_slice, replace);
1534 (real_slice, adjusted)
1535 }
1536 None => return Err("search string not found — diff preview unavailable".into()),
1537 }
1538 };
1539
1540 let mut diff = String::new();
1541 for line in effective_search.lines() {
1542 diff.push_str(&format!("- {}\n", line));
1543 }
1544 for line in effective_replace.lines() {
1545 diff.push_str(&format!("+ {}\n", line));
1546 }
1547 Ok(diff)
1548}
1549
1550pub fn compute_patch_hunk_diff(args: &Value) -> Result<String, String> {
1552 let path = require_str(args, "path")?;
1553 let start_line = require_usize(args, "start_line")?;
1554 let end_line = require_usize(args, "end_line")?;
1555 let replacement = require_str(args, "replacement")?;
1556
1557 let abs = safe_path(path)?;
1558 let original = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1559 let lines: Vec<&str> = original.lines().collect();
1560 let total = lines.len();
1561
1562 if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
1563 return Err(format!(
1564 "patch_hunk: invalid line range {}-{} for file with {} lines",
1565 start_line, end_line, total
1566 ));
1567 }
1568
1569 let s_idx = start_line - 1;
1570 let e_idx = end_line;
1571
1572 let mut diff = format!("@@ lines {}-{} @@\n", start_line, end_line);
1573 for i in s_idx..e_idx {
1574 diff.push_str(&format!("- {}\n", lines[i].trim_end()));
1575 }
1576 for line in replacement.lines() {
1577 diff.push_str(&format!("+ {}\n", line.trim_end()));
1578 }
1579 Ok(diff)
1580}
1581
1582pub fn compute_msr_diff(args: &Value) -> Result<String, String> {
1584 let hunks_val = args
1585 .get("hunks")
1586 .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
1587
1588 #[derive(serde::Deserialize)]
1589 struct PreviewHunk {
1590 search: String,
1591 replace: String,
1592 }
1593 let hunks: Vec<PreviewHunk> = serde_json::from_value(hunks_val.clone())
1594 .map_err(|e| format!("compute_msr_diff: invalid hunks: {e}"))?;
1595
1596 let mut diff = String::new();
1597 for (i, hunk) in hunks.iter().enumerate() {
1598 if hunks.len() > 1 {
1599 diff.push_str(&format!("@@ hunk {} @@\n", i + 1));
1600 }
1601 for line in hunk.search.lines() {
1602 diff.push_str(&format!("- {}\n", line.trim_end()));
1603 }
1604 for line in hunk.replace.lines() {
1605 diff.push_str(&format!("+ {}\n", line.trim_end()));
1606 }
1607 }
1608 Ok(diff)
1609}
1610
1611pub fn compute_write_file_diff(args: &Value) -> Result<String, String> {
1614 let path = require_str(args, "path")?;
1615 let new_content = require_str(args, "content")?;
1616
1617 let abs = safe_path(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
1618 let old_content = fs::read_to_string(&abs)
1619 .map(|s| s.replace("\r\n", "\n"))
1620 .unwrap_or_default();
1621
1622 let mut diff = String::new();
1623 if !old_content.is_empty() {
1624 for line in old_content.lines() {
1625 diff.push_str(&format!("- {}\n", line));
1626 }
1627 }
1628 for line in new_content.lines() {
1629 diff.push_str(&format!("+ {}\n", line));
1630 }
1631 if diff.is_empty() {
1632 return Err("empty content — diff preview unavailable".into());
1633 }
1634 Ok(diff)
1635}
1636
1637pub fn workspace_root() -> PathBuf {
1639 let mut current = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1640 loop {
1641 if current.join(".git").exists()
1642 || current.join("Cargo.toml").exists()
1643 || current.join("package.json").exists()
1644 {
1645 return current;
1646 }
1647 if !current.pop() {
1648 break;
1649 }
1650 }
1651 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1652}
1653
1654pub fn is_os_shortcut_directory(path: &Path) -> bool {
1658 let candidates = [
1659 dirs::desktop_dir(),
1660 dirs::download_dir(),
1661 dirs::document_dir(),
1662 dirs::picture_dir(),
1663 dirs::video_dir(),
1664 dirs::audio_dir(),
1665 ];
1666 candidates
1667 .iter()
1668 .filter_map(|d| d.as_deref())
1669 .any(|d| d == path)
1670}
1671
1672pub fn hematite_dir() -> PathBuf {
1678 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1679 if is_os_shortcut_directory(&cwd) {
1680 if let Some(home) = dirs::home_dir() {
1681 return home.join(".hematite");
1682 }
1683 }
1684 workspace_root().join(".hematite")
1685}
1686
1687pub fn is_project_workspace() -> bool {
1691 let root = workspace_root();
1692 let has_explicit_marker = root.join("Cargo.toml").exists()
1693 || root.join("package.json").exists()
1694 || root.join("pyproject.toml").exists()
1695 || root.join("go.mod").exists()
1696 || root.join("setup.py").exists()
1697 || root.join("pom.xml").exists()
1698 || root.join("build.gradle").exists()
1699 || root.join("CMakeLists.txt").exists()
1700 || root.join("index.html").exists()
1701 || root.join("style.css").exists()
1702 || root.join("script.js").exists();
1703 has_explicit_marker || (root.join(".git").exists() && root.join("src").exists())
1704}
1705
1706pub fn open_in_system_editor(path: &std::path::Path) -> Result<(), String> {
1709 if !path.exists() {
1710 return Err(format!("File not found: {}", path.display()));
1711 }
1712
1713 #[cfg(target_os = "windows")]
1714 {
1715 let status = std::process::Command::new("cmd")
1718 .args(["/c", "start", "", &path.to_string_lossy()])
1719 .status()
1720 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1721
1722 if !status.success() {
1723 return Err("Editor command failed to start.".into());
1724 }
1725 }
1726
1727 #[cfg(target_os = "macos")]
1728 {
1729 let status = std::process::Command::new("open")
1730 .arg(path)
1731 .status()
1732 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1733
1734 if !status.success() {
1735 return Err("open command failed.".into());
1736 }
1737 }
1738
1739 #[cfg(all(unix, not(target_os = "macos")))]
1740 {
1741 let status = std::process::Command::new("xdg-open")
1743 .arg(path)
1744 .status()
1745 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1746
1747 if !status.success() {
1748 return Err("xdg-open failed.".into());
1749 }
1750 }
1751
1752 Ok(())
1753}
1754
1755#[cfg(test)]
1756mod tests {
1757 use super::*;
1758
1759 #[test]
1760 fn safe_path_allows_plan_sidecars_inside_workspace() {
1761 let temp = tempfile::tempdir().unwrap();
1762 let root = temp.path();
1763 std::fs::create_dir_all(root.join(".hematite")).unwrap();
1764 std::fs::write(root.join(".hematite").join("TASK.md"), "# Task Ledger\n").unwrap();
1765
1766 let previous = std::env::current_dir().unwrap();
1767 std::env::set_current_dir(root).unwrap();
1768 let resolved = safe_path(".hematite/TASK.md").unwrap();
1769 std::env::set_current_dir(previous).unwrap();
1770
1771 assert!(resolved.ends_with(Path::new(".hematite").join("TASK.md")));
1772 }
1773}