1use std::fmt::Write as _;
2
3use crate::agent::truncation::safe_head;
4use serde_json::Value;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::Instant;
9use walkdir::WalkDir;
10
11const MAX_GHOST_BACKUPS: usize = 8;
14
15fn prune_ghost_backups(ghost_dir: &Path) {
16 let Ok(entries) = fs::read_dir(ghost_dir) else {
17 return;
18 };
19
20 let mut backups: Vec<_> = entries
21 .filter_map(Result::ok)
22 .filter(|entry| {
23 entry
24 .path()
25 .extension()
26 .and_then(|ext| ext.to_str())
27 .map(|ext| ext.eq_ignore_ascii_case("bak"))
28 .unwrap_or(false)
29 })
30 .collect();
31
32 backups.sort_by_key(|entry| entry.metadata().and_then(|meta| meta.modified()).ok());
33 backups.reverse();
34
35 let retained: std::collections::HashSet<String> = backups
36 .iter()
37 .take(MAX_GHOST_BACKUPS)
38 .map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
39 .collect();
40
41 for entry in backups.into_iter().skip(MAX_GHOST_BACKUPS) {
42 let _ = fs::remove_file(entry.path());
43 }
44
45 let ledger_path = ghost_dir.join("ledger.txt");
46 let Ok(content) = fs::read_to_string(&ledger_path) else {
47 return;
48 };
49
50 let mut rewritten = String::with_capacity(content.len());
51 for line in content.lines() {
52 let mut parts = line.splitn(2, '|');
53 if parts.next().is_some() {
54 if let Some(rest) = parts.next() {
55 let backup_path = rest.replace('\\', "/");
56 if retained.contains(&backup_path) {
57 rewritten.push_str(line);
58 rewritten.push('\n');
59 }
60 }
61 }
62 }
63 let _ = fs::write(ledger_path, rewritten);
64}
65
66fn save_ghost_backup(target_path: &str, content: &str) {
67 let ws = workspace_root();
68
69 if crate::agent::git::is_git_repo(&ws) {
71 let _ = crate::agent::git::create_ghost_snapshot(&ws);
72 }
73
74 let ghost_dir = hematite_dir().join("ghost");
76 let _ = fs::create_dir_all(&ghost_dir);
77 let ts = std::time::SystemTime::now()
78 .duration_since(std::time::UNIX_EPOCH)
79 .unwrap()
80 .as_millis();
81 let safe_name = Path::new(target_path)
82 .file_name()
83 .unwrap_or_default()
84 .to_string_lossy();
85 let backup_file = ghost_dir.join(format!("{}_{}.bak", ts, safe_name));
86
87 if fs::write(&backup_file, content).is_ok() {
88 use std::io::Write;
89 if let Ok(mut f) = fs::OpenOptions::new()
90 .create(true)
91 .append(true)
92 .open(ghost_dir.join("ledger.txt"))
93 {
94 let _ = writeln!(f, "{}|{}", target_path, backup_file.display());
95 }
96 prune_ghost_backups(&ghost_dir);
97 }
98}
99
100pub fn pop_ghost_ledger() -> Result<String, String> {
101 let ghost_dir = hematite_dir().join("ghost");
102 let ledger_path = ghost_dir.join("ledger.txt");
103
104 if !ledger_path.exists() {
105 return Err("Ghost Ledger is empty — no edits to undo".into());
106 }
107
108 let content = fs::read_to_string(&ledger_path).map_err(|e| e.to_string())?;
109 let mut lines: Vec<&str> = content.lines().filter(|l| !l.is_empty()).collect();
110
111 if lines.is_empty() {
112 return Err("Ghost Ledger is empty".into());
113 }
114
115 let last_line = lines.pop().unwrap();
116 let Some((target_path, backup_path)) = last_line.split_once('|') else {
117 return Err("Corrupted ledger entry".into());
118 };
119
120 let ws = workspace_root();
121
122 if crate::agent::git::is_git_repo(&ws) {
124 if let Ok(msg) = crate::agent::git::revert_from_ghost(&ws, target_path) {
125 let _ = fs::remove_file(backup_path);
126 let new_ledger = lines.join("\n");
127 let _ = fs::write(
128 &ledger_path,
129 if new_ledger.is_empty() {
130 String::new()
131 } else {
132 new_ledger + "\n"
133 },
134 );
135 return Ok(msg);
136 }
137 }
138
139 let original_content =
141 fs::read_to_string(backup_path).map_err(|e| format!("Failed to read backup: {e}"))?;
142 let abs_target = ws.join(target_path);
143 fs::write(&abs_target, original_content).map_err(|e| format!("Failed to restore file: {e}"))?;
144
145 let new_ledger = lines.join("\n");
146 let _ = fs::write(
147 &ledger_path,
148 if new_ledger.is_empty() {
149 String::new()
150 } else {
151 new_ledger + "\n"
152 },
153 );
154 let _ = fs::remove_file(backup_path);
155
156 Ok(format!("Restored {} from Ghost Ledger", target_path))
157}
158
159pub async fn read_file(args: &Value, budget_tokens: usize) -> Result<String, String> {
162 let path = require_str(args, "path")?;
163 let offset = get_usize_arg(args, "offset");
164 let limit = get_usize_arg(args, "limit");
165
166 let abs = safe_path(path)?;
167 let raw = fs::read_to_string(&abs).map_err(|e| format!("read_file: {e} ({path})"))?;
168
169 let lines: Vec<&str> = raw.lines().collect();
170 let total = lines.len();
171 let start = offset.unwrap_or(0).min(total);
172 let end = limit.map(|n| (start + n).min(total)).unwrap_or(total);
173
174 let mut content = lines[start..end].join("\n");
175
176 let budget_chars = budget_tokens.saturating_mul(4);
178 let char_limit = if budget_tokens == 0 {
179 100_000
180 } else {
181 budget_chars.clamp(2000, 100_000)
182 };
183
184 if content.len() > char_limit {
185 let safe_end = safe_head(&content, char_limit).len();
186 content.truncate(safe_end);
187 content.push_str("\n\n--- [PREDICTIVE TRUNCATION: CONTEXT BUDGET REACHED] ---\n");
188 let _ = write!(
189 content,
190 "Output truncated at {} chars to prevent context window flooding. ",
191 char_limit
192 );
193 content
194 .push_str("To see more, use `read_file` with a higher `offset` and a smaller `limit`.");
195 } else if end < total {
196 content.push_str("\n\n--- [TRUNCATION WARNING] ---\n");
197 let _ = write!(content, "This file has {} more lines below. ", total - end);
198 content.push_str("To read more, use `read_file` with a higher `offset` OR use `inspect_lines` to find relevant blocks. \
199 Do NOT attempt to read the entire large file at once if it keeps truncating.");
200 }
201
202 Ok(format!(
203 "[{path} lines {}-{} of {}]\n{}",
204 start + 1,
205 end,
206 total,
207 content
208 ))
209}
210
211pub async fn inspect_lines(args: &Value) -> Result<String, String> {
214 let path = require_str(args, "path")?;
215 let start_line = get_usize_arg(args, "start_line").unwrap_or(1);
216 let end_line = get_usize_arg(args, "end_line");
217
218 let abs = safe_path(path)?;
219 let raw = fs::read_to_string(&abs).map_err(|e| format!("inspect_lines: {e} ({path})"))?;
220
221 let lines: Vec<&str> = raw.lines().collect();
222 let total_lines = lines.len();
223
224 if start_line > total_lines && total_lines > 0 {
226 return Err(format!(
227 "Invalid line range: You requested line {}, but the file only has {} lines. Try `read_file` on a smaller range or the whole file.",
228 start_line, total_lines
229 ));
230 }
231
232 let start = start_line.saturating_sub(1).min(total_lines);
233 let end = end_line.unwrap_or(total_lines).min(total_lines);
234
235 if start >= end && total_lines > 0 {
236 return Err(format!(
237 "inspect_lines: start_line ({start_line}) must be <= end_line ({})",
238 end_line.unwrap_or(total_lines)
239 ));
240 }
241
242 let mut output = format!(
243 "[inspect_lines: {path} lines {}-{} of {}]\n",
244 start + 1,
245 end,
246 total_lines
247 );
248 for (offset, line) in lines[start..end].iter().enumerate() {
249 let _ = writeln!(output, "[{:>4}] | {}", start + offset + 1, line);
250 }
251
252 Ok(output)
253}
254
255pub async fn tail_file(args: &Value) -> Result<String, String> {
258 let path = require_str(args, "path")?;
259 let n = args
260 .get("lines")
261 .and_then(|v| v.as_u64())
262 .unwrap_or(50)
263 .min(500) as usize;
264 let grep_pat = args.get("grep").and_then(|v| v.as_str());
265
266 let abs = safe_path(path)?;
267 let raw = fs::read_to_string(&abs).map_err(|e| format!("tail_file: {e} ({path})"))?;
268
269 let all_lines: Vec<&str> = raw.lines().collect();
270 let total = all_lines.len();
271
272 let filtered: Vec<(usize, &str)> = if let Some(pat) = grep_pat {
275 let re = regex::Regex::new(pat)
276 .map_err(|e| format!("tail_file: invalid grep pattern '{pat}': {e}"))?;
277 all_lines
278 .iter()
279 .enumerate()
280 .filter(|(_, l)| re.is_match(l))
281 .map(|(i, l)| (i, *l))
282 .collect()
283 } else {
284 all_lines.iter().enumerate().map(|(i, l)| (i, *l)).collect()
285 };
286
287 let total_filtered = filtered.len();
288 let skip = total_filtered.saturating_sub(n);
289 let window = &filtered[skip..];
290
291 if window.is_empty() {
292 let note = if let Some(pat) = grep_pat {
293 format!(" matching '{pat}'")
294 } else {
295 String::new()
296 };
297 return Ok(format!(
298 "[tail_file: {path} — no lines{note} found (total {total} lines)]"
299 ));
300 }
301
302 let first_abs = window[0].0 + 1;
303 let last_abs = window[window.len() - 1].0 + 1;
304 let mut out = format!(
305 "[tail_file: {path} — lines {first_abs}–{last_abs} of {total} (last {n} of {total_filtered} matched)]\n"
306 );
307 for (abs_idx, line) in window {
308 let _ = writeln!(out, "[{:>5}] {}", abs_idx + 1, line);
309 }
310
311 Ok(out)
312}
313
314pub async fn write_file(args: &Value) -> Result<String, String> {
317 let path = require_str(args, "path")?;
318 let content = require_str(args, "content")?;
319
320 let abs = safe_path_allow_new(path)?;
321 if let Some(parent) = abs.parent() {
322 fs::create_dir_all(parent)
323 .map_err(|e| format!("write_file: could not create dirs: {e}"))?;
324 }
325
326 let existed = abs.exists();
327 if existed {
328 if let Ok(orig) = fs::read_to_string(&abs) {
329 save_ghost_backup(path, &orig);
330 }
331 }
332
333 fs::write(&abs, content).map_err(|e| format!("write_file: {e} ({path})"))?;
334
335 let action = if existed { "Updated" } else { "Created" };
336 Ok(format!("{action} {path} ({} bytes)", content.len()))
337}
338
339pub async fn edit_file(args: &Value) -> Result<String, String> {
342 let path = require_str(args, "path")?;
343 let search = require_str(args, "search")?;
344 let replace = require_str(args, "replace")?;
345 let replace_all = args
346 .get("replace_all")
347 .and_then(|v| v.as_bool())
348 .unwrap_or(false);
349
350 if search == replace {
351 return Err("edit_file: 'search' and 'replace' are identical — no change needed".into());
352 }
353
354 let abs = safe_path(path)?;
355 let raw = fs::read_to_string(&abs).map_err(|e| format!("edit_file: {e} ({path})"))?;
356 let original = raw.replace("\r\n", "\n");
358
359 save_ghost_backup(path, &original);
360
361 let search_trimmed = search.trim();
362 let search_non_ws_len = search_trimmed
363 .chars()
364 .filter(|c| !c.is_whitespace())
365 .count();
366 let search_line_count = search_trimmed.lines().count();
367 if search_non_ws_len < 12 && search_line_count <= 1 {
368 return Err(format!(
369 "edit_file: search string is too short or generic for a safe mutation in {path}.\n\
370 Provide a more specific anchor (prefer a full line, multiple lines, or use `inspect_lines` + `patch_hunk`)."
371 ));
372 }
373
374 let (effective_search, was_repaired) = if original.contains(search) {
376 let exact_match_count = original.matches(search).count();
377 if exact_match_count > 1 && !replace_all {
378 return Err(format!(
379 "edit_file: search string matched {} times in {path}.\n\
380 Provide a more specific unique anchor or use `inspect_lines` + `patch_hunk`.",
381 exact_match_count
382 ));
383 }
384 (search.to_string(), false)
385 } else {
386 let span = rstrip_find_span(&original, search)
391 .or_else(|| indent_flexible_find_span(&original, search))
392 .or_else(|| fuzzy_find_span(&original, search));
393 match span {
394 Some(span) => {
395 let real_slice = original[span.clone()].to_string();
396 (real_slice, true)
397 }
398 None => {
399 let hint = nearest_lines(&original, search);
400 let cross_hint = find_search_in_workspace(search, path)
401 .map(|found| format!("\nNote: search string found in '{found}' — did you mean to edit that file?"))
402 .unwrap_or_default();
403 return Err(format!(
404 "edit_file: search string not found in {path}.\n\
405 The 'search' value must match the file content exactly \
406 (including whitespace/indentation).\n\
407 {hint}{cross_hint}"
408 ));
409 }
410 }
411 };
412
413 let effective_replace = if was_repaired {
416 adjust_replace_indent(search, effective_search.as_str(), replace)
417 } else {
418 replace.to_string()
419 };
420
421 let updated = if replace_all {
422 original.replace(effective_search.as_str(), effective_replace.as_str())
423 } else {
424 original.replacen(effective_search.as_str(), effective_replace.as_str(), 1)
425 };
426
427 fs::write(&abs, &updated).map_err(|e| format!("edit_file: write failed: {e}"))?;
428
429 let removed = original.lines().count();
430 let added = updated.lines().count();
431 let repair_note = if was_repaired {
432 " [indent auto-corrected]"
433 } else {
434 ""
435 };
436
437 let mut diff_block =
438 String::with_capacity(effective_search.len() + effective_replace.len() + 32);
439 diff_block.push_str("\n--- DIFF \n");
440 for line in effective_search.lines() {
441 let _ = writeln!(diff_block, "- {}", line);
442 }
443 for line in effective_replace.lines() {
444 let _ = writeln!(diff_block, "+ {}", line);
445 }
446
447 Ok(format!(
448 "Edited {path} ({} -> {} lines){repair_note}{}",
449 removed, added, diff_block
450 ))
451}
452
453pub async fn patch_hunk(args: &Value) -> Result<String, String> {
456 let path = require_str(args, "path")?;
457 let start_line = require_usize(args, "start_line")?;
458 let end_line = require_usize(args, "end_line")?;
459 let replacement = require_str(args, "replacement")?;
460
461 let abs = safe_path(path)?;
462 let original = fs::read_to_string(&abs).map_err(|e| format!("patch_hunk: {e} ({path})"))?;
463
464 save_ghost_backup(path, &original);
465
466 let lines: Vec<String> = original.lines().map(|s| s.to_string()).collect();
467 let total = lines.len();
468
469 if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
470 return Err(format!(
471 "patch_hunk: invalid line range {}-{} for file with {} lines",
472 start_line, end_line, total
473 ));
474 }
475
476 let mut updated_lines = Vec::with_capacity(total);
477 let s_idx = start_line - 1;
479 let e_idx = end_line; updated_lines.extend_from_slice(&lines[0..s_idx]);
483
484 for line in replacement.lines() {
486 updated_lines.push(line.to_string());
487 }
488
489 if e_idx < total {
491 updated_lines.extend_from_slice(&lines[e_idx..total]);
492 }
493
494 let updated_content = updated_lines.join("\n");
495 fs::write(&abs, &updated_content).map_err(|e| format!("patch_hunk: write failed: {e}"))?;
496
497 let mut diff = String::with_capacity(replacement.len() + (e_idx - s_idx) * 64 + 32);
498 diff.push_str("\n--- HUNK DIFF ---\n");
499 for line in &lines[s_idx..e_idx] {
500 let _ = writeln!(diff, "- {}", line.trim_end());
501 }
502 for line in replacement.lines() {
503 let _ = writeln!(diff, "+ {}", line.trim_end());
504 }
505
506 Ok(format!(
507 "Patched {path} lines {}-{} ({} -> {} lines){}",
508 start_line,
509 end_line,
510 (e_idx - s_idx),
511 replacement.lines().count(),
512 diff
513 ))
514}
515
516#[derive(serde::Deserialize)]
519struct SearchReplaceHunk {
520 search: String,
521 replace: String,
522}
523
524pub async fn multi_search_replace(args: &Value) -> Result<String, String> {
525 let path = require_str(args, "path")?;
526 let hunks_val = args
527 .get("hunks")
528 .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
529
530 let hunks: Vec<SearchReplaceHunk> = serde_json::from_value(hunks_val.clone())
531 .map_err(|e| format!("multi_search_replace: invalid hunks array: {e}"))?;
532
533 if hunks.is_empty() {
534 return Err("multi_search_replace: hunks array is empty".to_string());
535 }
536
537 let abs = safe_path(path)?;
538 let raw =
539 fs::read_to_string(&abs).map_err(|e| format!("multi_search_replace: {e} ({path})"))?;
540 let original = raw.replace("\r\n", "\n");
542
543 save_ghost_backup(path, &original);
544
545 let mut current_content = original.clone();
546 let mut diff = String::with_capacity(hunks.len() * 128 + 32);
547 diff.push_str("\n--- SEARCH & REPLACE DIFF ---\n");
548
549 let mut patched_hunks = 0;
550
551 for (i, hunk) in hunks.iter().enumerate() {
552 let match_count = current_content.matches(&hunk.search).count();
553
554 let (effective_search, effective_replace) = if match_count == 1 {
555 (hunk.search.clone(), hunk.replace.clone())
557 } else if match_count == 0 {
558 let span = rstrip_find_span(¤t_content, &hunk.search)
560 .or_else(|| indent_flexible_find_span(¤t_content, &hunk.search))
561 .or_else(|| fuzzy_find_span(¤t_content, &hunk.search));
562 match span {
563 Some(span) => {
564 let real_slice = current_content[span].to_string();
565 let adjusted_replace =
566 adjust_replace_indent(&hunk.search, &real_slice, &hunk.replace);
567 (real_slice, adjusted_replace)
568 }
569 None => {
570 return Err(format!(
571 "multi_search_replace: hunk {} search string not found in file.",
572 i
573 ));
574 }
575 }
576 } else {
577 return Err(format!(
578 "multi_search_replace: hunk {} search string matched {} times. Provide more context to make it unique.",
579 i, match_count
580 ));
581 };
582
583 let _ = write!(diff, "\n@@ Hunk {} @@\n", i + 1);
584 for line in effective_search.lines() {
585 let _ = writeln!(diff, "- {}", line.trim_end());
586 }
587 for line in effective_replace.lines() {
588 let _ = writeln!(diff, "+ {}", line.trim_end());
589 }
590
591 current_content = current_content.replacen(&effective_search, &effective_replace, 1);
592 patched_hunks += 1;
593 }
594
595 fs::write(&abs, ¤t_content)
596 .map_err(|e| format!("multi_search_replace: write failed: {e}"))?;
597
598 Ok(format!(
599 "Modified {} hunks in {} using exact search-and-replace.{}",
600 patched_hunks, path, diff
601 ))
602}
603
604pub async fn list_files(args: &Value, budget: usize) -> Result<String, String> {
607 let char_budget = budget * 4; let started = Instant::now();
609 let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
610 let ext_filter = args.get("extension").and_then(|v| v.as_str());
611
612 let base = safe_path(base_str)?;
613
614 let mut files: Vec<PathBuf> = Vec::new();
615 let mut scanned_count = 0;
616 for entry in WalkDir::new(&base).follow_links(false) {
617 scanned_count += 1;
618 if scanned_count > 25_000 {
619 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());
620 }
621 let entry = entry.map_err(|e| format!("list_files: {e}"))?;
622 if !entry.file_type().is_file() {
623 continue;
624 }
625 let p = entry.path();
626
627 if path_has_hidden_segment(p) {
629 continue;
630 }
631
632 if let Some(ext) = ext_filter {
633 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
634 continue;
635 }
636 }
637 files.push(p.to_path_buf());
638 }
639
640 files.sort_by_key(|p| {
642 fs::metadata(p)
643 .and_then(|m| m.modified())
644 .ok()
645 .map(std::cmp::Reverse)
646 });
647
648 let mut current_chars = 0;
649 let mut shown = Vec::with_capacity(files.len().min(200));
650 let mut truncated_by_budget = false;
651
652 let total_scanned = files.len();
653 for f in files {
654 let f_str = f.display().to_string();
655 if current_chars + f_str.len() + 1 > char_budget {
656 truncated_by_budget = true;
657 break;
658 }
659 current_chars += f_str.len() + 1;
660 shown.push(f_str);
661 if shown.len() >= 200 {
662 break;
663 }
664 }
665
666 let truncated = total_scanned > shown.len();
667
668 let ms = started.elapsed().as_millis();
669 let mut out = format!(
670 "{} file(s) in {} ({ms}ms){}",
671 shown.len(),
672 base_str,
673 if truncated {
674 if truncated_by_budget {
675 " [truncated by token budget]"
676 } else {
677 " [truncated at 200]"
678 }
679 } else {
680 ""
681 }
682 );
683 out.push('\n');
684 out.push_str(&shown.join("\n"));
685 Ok(out)
686}
687
688pub async fn create_directory(args: &Value) -> Result<String, String> {
691 let path = require_str(args, "path")?;
692 let abs = safe_path_allow_new(path)?;
693
694 if abs.exists() {
695 if abs.is_dir() {
696 return Ok(format!("Directory already exists: {path}"));
697 } else {
698 return Err(format!("A file already exists at this path: {path}"));
699 }
700 }
701
702 fs::create_dir_all(&abs).map_err(|e| format!("create_directory: {e} ({path})"))?;
703 Ok(format!("Created directory: {path}"))
704}
705
706pub async fn grep_files(args: &Value, budget: usize) -> Result<String, String> {
709 let char_budget = budget * 4;
710 let pattern = require_str(args, "pattern")?;
711 let base_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
712 let ext_filter = args.get("extension").and_then(|v| v.as_str());
713 let case_insensitive = args
714 .get("case_insensitive")
715 .and_then(|v| v.as_bool())
716 .unwrap_or(true);
717 let files_only = args.get("mode").and_then(|v| v.as_str()) == Some("files_only");
718 let head_limit = get_usize_arg(args, "head_limit").unwrap_or(50);
719 let offset = get_usize_arg(args, "offset").unwrap_or(0);
720
721 let ctx_default = get_usize_arg(args, "context").unwrap_or(0);
723 let before = get_usize_arg(args, "before").unwrap_or(ctx_default);
724 let after = get_usize_arg(args, "after").unwrap_or(ctx_default);
725
726 let base = safe_path(base_str)?;
727
728 let regex = regex::RegexBuilder::new(pattern)
729 .case_insensitive(case_insensitive)
730 .build()
731 .map_err(|e| format!("grep_files: invalid pattern '{pattern}': {e}"))?;
732
733 if files_only {
735 let mut matched_files: Vec<String> = Vec::new();
736 let mut scanned_count = 0;
737
738 for entry in WalkDir::new(&base).follow_links(false) {
739 scanned_count += 1;
740 if scanned_count > 25_000 {
741 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());
742 }
743 let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
744 if !entry.file_type().is_file() {
745 continue;
746 }
747 let p = entry.path();
748 if path_has_hidden_segment(p) {
749 continue;
750 }
751 if let Some(ext) = ext_filter {
752 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
753 continue;
754 }
755 }
756 let Ok(contents) = fs::read_to_string(p) else {
757 continue;
758 };
759 if contents.lines().any(|line| regex.is_match(line)) {
760 matched_files.push(p.display().to_string());
761 }
762 }
763
764 if matched_files.is_empty() {
765 return Ok(format!("No files matching '{pattern}' in {base_str}"));
766 }
767
768 let total = matched_files.len();
769 let page: Vec<_> = matched_files
770 .into_iter()
771 .skip(offset)
772 .take(head_limit)
773 .collect();
774 let showing = page.len();
775
776 let mut out = format!("{total} file(s) match '{pattern}'");
777 if offset > 0 || showing < total {
778 let _ = write!(
779 out,
780 " [showing {}-{} of {total}]",
781 offset + 1,
782 offset + showing
783 );
784 }
785 out.push('\n');
786
787 let mut current_chars = out.len();
788 let mut shown_pages = Vec::with_capacity(page.len());
789 for p in page {
790 if current_chars + p.len() + 1 > char_budget {
791 out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
792 break;
793 }
794 current_chars += p.len() + 1;
795 shown_pages.push(p);
796 }
797 out.push_str(&shown_pages.join("\n"));
798 return Ok(out);
799 }
800
801 struct Hunk {
805 path: String,
806 lines: Vec<(usize, String, bool)>,
808 }
809
810 let mut hunks: Vec<Hunk> = Vec::new();
811 let mut total_matches = 0usize;
812 let mut files_matched = 0usize;
813 let mut scanned_count = 0;
814
815 for entry in WalkDir::new(&base).follow_links(false) {
816 scanned_count += 1;
817 if scanned_count > 25_000 {
818 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());
819 }
820 let entry = entry.map_err(|e| format!("grep_files: {e}"))?;
821 if !entry.file_type().is_file() {
822 continue;
823 }
824 let p = entry.path();
825 if path_has_hidden_segment(p) {
826 continue;
827 }
828 if let Some(ext) = ext_filter {
829 if p.extension().and_then(|s| s.to_str()) != Some(ext) {
830 continue;
831 }
832 }
833 let Ok(contents) = fs::read_to_string(p) else {
834 continue;
835 };
836 let all_lines: Vec<&str> = contents.lines().collect();
837 let n = all_lines.len();
838
839 let match_idxs: Vec<usize> = all_lines
841 .iter()
842 .enumerate()
843 .filter(|(_, line)| regex.is_match(line))
844 .map(|(i, _)| i)
845 .collect();
846
847 if match_idxs.is_empty() {
848 continue;
849 }
850 files_matched += 1;
851 total_matches += match_idxs.len();
852
853 let path_str = p.display().to_string();
855 let mut ranges: Vec<(usize, usize)> = match_idxs
856 .iter()
857 .map(|&i| {
858 (
859 i.saturating_sub(before),
860 (i + after).min(n.saturating_sub(1)),
861 )
862 })
863 .collect();
864
865 ranges.sort_unstable();
867 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len());
868 for (s, e) in ranges {
869 if let Some(last) = merged.last_mut() {
870 if s <= last.1 + 1 {
871 last.1 = last.1.max(e);
872 continue;
873 }
874 }
875 merged.push((s, e));
876 }
877
878 let match_set: std::collections::HashSet<usize> = match_idxs.into_iter().collect();
880 for (start, end) in merged {
881 let mut hunk_lines = Vec::with_capacity(end - start + 1);
882 for (offset, line) in all_lines[start..=end].iter().enumerate() {
883 hunk_lines.push((
884 start + offset + 1,
885 line.to_string(),
886 match_set.contains(&(start + offset)),
887 ));
888 }
889 hunks.push(Hunk {
890 path: path_str.clone(),
891 lines: hunk_lines,
892 });
893 }
894 }
895
896 if hunks.is_empty() {
897 return Ok(format!("No matches for '{pattern}' in {base_str}"));
898 }
899
900 let total_hunks = hunks.len();
901 let page_hunks: Vec<_> = hunks.into_iter().skip(offset).take(head_limit).collect();
902 let showing = page_hunks.len();
903
904 let mut out =
905 format!("{total_matches} match(es) across {files_matched} file(s), {total_hunks} hunk(s)");
906 if offset > 0 || showing < total_hunks {
907 let _ = write!(
908 out,
909 " [hunks {}-{} of {total_hunks}]",
910 offset + 1,
911 offset + showing
912 );
913 }
914 out.push('\n');
915
916 let mut current_chars = out.len();
917 let mut truncated_by_budget = false;
918
919 for (i, hunk) in page_hunks.iter().enumerate() {
920 let mut hunk_out = String::with_capacity(hunk.lines.len() * 64 + 8);
921 if i > 0 {
922 hunk_out.push_str("\n--\n");
923 }
924 for (lineno, text, is_match) in &hunk.lines {
925 if *is_match {
926 let _ = writeln!(hunk_out, "{}:{}:{}", hunk.path, lineno, text);
927 } else {
928 let _ = writeln!(hunk_out, "{}: {}-{}", hunk.path, lineno, text);
929 }
930 }
931
932 if current_chars + hunk_out.len() > char_budget {
933 truncated_by_budget = true;
934 break;
935 }
936 current_chars += hunk_out.len();
937 out.push_str(&hunk_out);
938 }
939
940 if truncated_by_budget {
941 out.push_str("\n[TRUNCATED BY TOKEN BUDGET]");
942 }
943
944 Ok(out.trim_end().to_string())
945}
946
947fn require_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
950 args.get(key)
951 .and_then(|v| v.as_str())
952 .ok_or_else(|| format!("Missing required argument: '{key}'"))
953}
954
955fn get_usize_arg(args: &Value, key: &str) -> Option<usize> {
956 args.get(key).and_then(value_as_usize)
957}
958
959fn require_usize(args: &Value, key: &str) -> Result<usize, String> {
960 get_usize_arg(args, key).ok_or_else(|| format!("Missing required numeric argument: '{key}'"))
961}
962
963fn value_as_usize(value: &Value) -> Option<usize> {
964 if let Some(v) = value.as_u64() {
965 return usize::try_from(v).ok();
966 }
967
968 if let Some(v) = value.as_i64() {
969 return if v >= 0 {
970 usize::try_from(v as u64).ok()
971 } else {
972 None
973 };
974 }
975
976 if let Some(v) = value.as_f64() {
977 if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= (usize::MAX as f64) {
978 return Some(v as usize);
979 }
980 return None;
981 }
982
983 value.as_str().and_then(|s| s.trim().parse::<usize>().ok())
984}
985
986fn safe_path(path: &str) -> Result<PathBuf, String> {
990 let candidate = resolve_candidate(path);
991 match canonicalize_safe(&candidate, path) {
992 Ok(abs) => Ok(abs),
993 Err(e) => {
994 if e.contains("The system cannot find the file specified") || e.contains("os error 2") {
995 if let Some(suggestion) = suggest_better_path(path) {
996 return Err(format!("{e}. Did you mean '{suggestion}'?"));
997 }
998 }
999 Err(e)
1000 }
1001 }
1002}
1003
1004fn suggest_better_path(original: &str) -> Option<String> {
1005 let path = Path::new(original);
1006 let filename = path.file_name()?.to_str()?.to_lowercase();
1007 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1008
1009 let abs_parent = resolve_candidate(&parent.to_string_lossy())
1011 .canonicalize()
1012 .ok()?;
1013
1014 let mut best_match = None;
1015 let mut best_score = 0;
1016
1017 if let Ok(entries) = fs::read_dir(abs_parent) {
1018 for entry in entries.flatten() {
1019 if let Some(candidate_name) = entry.file_name().to_str() {
1020 let lower_candidate = candidate_name.to_lowercase();
1021 if lower_candidate == filename {
1022 continue;
1023 }
1024
1025 let mut score = 0;
1026 if lower_candidate.starts_with(&filename) || filename.starts_with(&lower_candidate)
1027 {
1028 score += 10;
1029 }
1030 if (filename.ends_with('s') && filename[..filename.len() - 1] == lower_candidate)
1032 || (lower_candidate.ends_with('s')
1033 && lower_candidate[..lower_candidate.len() - 1] == filename)
1034 {
1035 score += 20;
1036 }
1037
1038 if score > best_score {
1039 best_score = score;
1040 best_match = Some(candidate_name.to_string());
1041 }
1042 }
1043 }
1044 }
1045
1046 if best_score >= 10 {
1047 best_match
1048 } else {
1049 None
1050 }
1051}
1052
1053fn safe_path_allow_new(path: &str) -> Result<PathBuf, String> {
1055 let candidate = resolve_candidate(path);
1056
1057 if let Ok(abs) = candidate.canonicalize() {
1059 check_workspace_bounds(&abs, path)?;
1060 return Ok(abs);
1061 }
1062
1063 let parent = candidate.parent().unwrap_or(Path::new("."));
1065 let name = candidate
1066 .file_name()
1067 .ok_or_else(|| format!("invalid path: {path}"))?;
1068 let abs_parent = parent
1069 .canonicalize()
1070 .map_err(|_| format!("safe_path: parent dir doesn't exist for {path}"))?;
1071 let abs = abs_parent.join(name);
1072 check_workspace_bounds(&abs, path)?;
1073 Ok(abs)
1074}
1075
1076pub(crate) fn resolve_candidate(path: &str) -> PathBuf {
1077 let upper = path.to_uppercase();
1079
1080 let bare = upper.trim_end_matches('/').trim_start_matches('@');
1083 let bare_resolved = match bare {
1084 "DESKTOP" => dirs::desktop_dir(),
1085 "DOWNLOADS" | "DOWNLOAD" => dirs::download_dir(),
1086 "DOCUMENTS" | "DOCS" => dirs::document_dir(),
1087 "PICTURES" | "IMAGES" => dirs::picture_dir(),
1088 "VIDEOS" | "MOVIES" => dirs::video_dir(),
1089 "MUSIC" | "AUDIO" => dirs::audio_dir(),
1090 "HOME" => dirs::home_dir(),
1091 "TEMP" | "TMP" => Some(std::env::temp_dir()),
1092 "CACHE" => dirs::cache_dir(),
1093 "CONFIG" => dirs::config_dir(),
1094 "DATA" => dirs::data_dir(),
1095 _ => None,
1096 };
1097 let bare_resolved = bare_resolved.or_else(|| {
1099 if path == "~" || path == "~/" {
1100 dirs::home_dir()
1101 } else {
1102 None
1103 }
1104 });
1105 if let Some(p) = bare_resolved {
1106 return p;
1107 }
1108
1109 let resolved = if upper.starts_with("@DESKTOP/") {
1111 dirs::desktop_dir().map(|p| p.join(&path[9..]))
1112 } else if upper.starts_with("@DOCUMENTS/") {
1113 dirs::document_dir().map(|p| p.join(&path[11..]))
1114 } else if upper.starts_with("@DOWNLOADS/") {
1115 dirs::download_dir().map(|p| p.join(&path[11..]))
1116 } else if upper.starts_with("@PICTURES/") || upper.starts_with("@IMAGES/") {
1117 let offset = if upper.starts_with("@PICTURES/") {
1118 10
1119 } else {
1120 8
1121 };
1122 dirs::picture_dir().map(|p| p.join(&path[offset..]))
1123 } else if upper.starts_with("@VIDEOS/") || upper.starts_with("@MOVIES/") {
1124 let offset = 8;
1125 dirs::video_dir().map(|p| p.join(&path[offset..]))
1126 } else if upper.starts_with("@MUSIC/") || upper.starts_with("@AUDIO/") {
1127 let offset = 7;
1128 dirs::audio_dir().map(|p| p.join(&path[offset..]))
1129 } else if upper.starts_with("@HOME/") || upper.starts_with("~/") {
1130 let offset = if upper.starts_with("@HOME/") { 6 } else { 2 };
1131 dirs::home_dir().map(|p| p.join(&path[offset..]))
1132 } else if upper.starts_with("@TEMP/") {
1133 Some(std::env::temp_dir().join(&path[6..]))
1134 } else if upper.starts_with("@CACHE/") {
1135 dirs::cache_dir().map(|p| p.join(&path[7..]))
1136 } else if upper.starts_with("@CONFIG/") {
1137 dirs::config_dir().map(|p| p.join(&path[8..]))
1138 } else if upper.starts_with("@DATA/") {
1139 dirs::data_dir().map(|p| p.join(&path[6..]))
1140 } else {
1141 None
1142 };
1143
1144 if let Some(p) = resolved {
1145 return p;
1146 }
1147
1148 let p = Path::new(path);
1150 if p.is_absolute() {
1151 p.to_path_buf()
1152 } else {
1153 std::env::current_dir()
1154 .unwrap_or_else(|_| PathBuf::from("."))
1155 .join(p)
1156 }
1157}
1158
1159fn canonicalize_safe(candidate: &Path, original: &str) -> Result<PathBuf, String> {
1160 let abs = candidate
1161 .canonicalize()
1162 .map_err(|e: io::Error| format!("safe_path: {e} ({original})"))?;
1163 check_workspace_bounds(&abs, original)?;
1164 Ok(abs)
1165}
1166
1167fn is_allowed_plan_sidecar(workspace: &Path, abs: &Path) -> bool {
1168 let canonical_workspace = workspace
1174 .canonicalize()
1175 .unwrap_or_else(|_| workspace.to_path_buf());
1176
1177 if !abs.starts_with(&canonical_workspace) {
1178 return false;
1179 }
1180
1181 let path_lower = abs.to_string_lossy().to_lowercase().replace('\\', "/");
1182 path_lower.ends_with("/.hematite/task.md")
1183 || path_lower.ends_with("/.hematite/plan.md")
1184 || path_lower.ends_with("/.hematite/walkthrough.md")
1185}
1186
1187fn check_workspace_bounds(abs: &Path, original: &str) -> Result<(), String> {
1188 let workspace = std::env::current_dir().map_err(|e| format!("could not read cwd: {e}"))?;
1189 if is_allowed_plan_sidecar(&workspace, abs) {
1190 return Ok(());
1191 }
1192
1193 super::guard::path_is_safe(&workspace, abs)
1195 .map(|_| ())
1196 .map_err(|e| format!("file access denied for '{original}': {e}"))
1197}
1198
1199fn path_has_hidden_segment(p: &Path) -> bool {
1201 p.components().any(|c| {
1202 let s = c.as_os_str().to_string_lossy();
1203 if s == ".hematite" || s == ".git" || s == "." || s == ".." {
1204 return false;
1205 }
1206 s.starts_with('.') || s == "target" || s == "node_modules" || s == "__pycache__"
1207 })
1208}
1209
1210fn nearest_lines(content: &str, search: &str) -> String {
1213 let first_search_line = search
1215 .lines()
1216 .map(|l| l.trim())
1217 .find(|l| !l.is_empty())
1218 .unwrap_or("");
1219
1220 let lines: Vec<&str> = content.lines().collect();
1221 if lines.is_empty() {
1222 return "(file is empty)".into();
1223 }
1224
1225 let best_idx = if first_search_line.is_empty() {
1227 0
1228 } else {
1229 lines
1230 .iter()
1231 .enumerate()
1232 .max_by_key(|(_, l)| {
1233 let lt = l.trim();
1234 first_search_line
1236 .chars()
1237 .zip(lt.chars())
1238 .take_while(|(a, b)| a == b)
1239 .count()
1240 })
1241 .map(|(i, _)| i)
1242 .unwrap_or(0)
1243 };
1244
1245 let start = best_idx.saturating_sub(3);
1246 let end = (best_idx + 5).min(lines.len());
1247 let count = end - start;
1248 let mut snippet = String::with_capacity(count * 60);
1249 for (i, l) in lines[start..end].iter().enumerate() {
1250 if i > 0 {
1251 snippet.push('\n');
1252 }
1253 let _ = write!(snippet, "{:>4} | {}", start + i + 1, l);
1254 }
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.as_bytes()[..norm_pos]
1286 .iter()
1287 .filter(|&&b| b == b'\n')
1288 .count();
1289 let search_lines = norm_search
1290 .as_bytes()
1291 .iter()
1292 .filter(|&&b| b == b'\n')
1293 .count()
1294 + 1;
1295
1296 let orig_lines: Vec<&str> = content.lines().collect();
1297
1298 let mut current_pos = 0;
1299 for i in 0..lines_before {
1300 if i < orig_lines.len() {
1301 current_pos += orig_lines[i].len() + 1;
1302 }
1303 }
1304 let byte_start = current_pos;
1305
1306 let mut byte_len = 0;
1307 for i in 0..search_lines {
1308 let idx = lines_before + i;
1309 if idx < orig_lines.len() {
1310 byte_len += orig_lines[idx].len();
1311 if i < search_lines - 1 {
1312 byte_len += 1;
1313 }
1314 }
1315 }
1316
1317 if byte_start + byte_len > content.len() {
1318 return None;
1319 }
1320
1321 let candidate = &content[byte_start..byte_start + byte_len];
1322 if normalise(candidate).trim_end_matches('\n') == norm_search.as_str() {
1323 Some(byte_start..byte_start + byte_len)
1324 } else {
1325 None
1326 }
1327}
1328
1329fn rstrip_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1333 find_span_normalised(content, search, |s| {
1334 let mut out = String::with_capacity(s.len());
1335 for (i, l) in s.lines().enumerate() {
1336 if i > 0 {
1337 out.push('\n');
1338 }
1339 out.push_str(l.trim_end());
1340 }
1341 out
1342 })
1343}
1344
1345fn indent_flexible_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1350 let norm_search = dedent(search.trim_matches('\n'));
1351 if norm_search.trim().is_empty() {
1352 return None;
1353 }
1354 let search_line_count = norm_search.lines().count();
1355 let content_lines: Vec<&str> = content.lines().collect();
1356 if content_lines.len() < search_line_count {
1357 return None;
1358 }
1359
1360 let mut line_starts: Vec<usize> = Vec::with_capacity(content_lines.len() + 1);
1362 let mut pos = 0usize;
1363 for line in &content_lines {
1364 line_starts.push(pos);
1365 pos += line.len() + 1; }
1367 line_starts.push(pos);
1368
1369 for start in 0..=(content_lines.len() - search_line_count) {
1370 let window = content_lines[start..start + search_line_count].join("\n");
1371 if dedent(&window) == norm_search {
1372 let byte_start = line_starts[start];
1373 let end_line = start + search_line_count;
1374 let byte_end = if end_line < content_lines.len() {
1375 line_starts[end_line] - 1 } else {
1377 content.len()
1378 };
1379 return Some(byte_start..byte_end);
1380 }
1381 }
1382 None
1383}
1384
1385fn fuzzy_find_span(content: &str, search: &str) -> Option<std::ops::Range<usize>> {
1388 find_span_normalised(content, search, |s| {
1389 let mut result = String::with_capacity(s.len());
1390 for (i, l) in s.lines().enumerate() {
1391 if i > 0 {
1392 result.push('\n');
1393 }
1394 result.push_str(l.trim());
1395 }
1396 result
1397 })
1398}
1399
1400fn find_search_in_workspace(search: &str, skip_path: &str) -> Option<String> {
1405 let root = workspace_root();
1406 let norm_search = search.replace("\r\n", "\n");
1407 let mut checked = 0usize;
1408
1409 let walker = ignore::WalkBuilder::new(&root)
1410 .hidden(true)
1411 .ignore(true)
1412 .git_ignore(true)
1413 .build();
1414
1415 for entry in walker.flatten() {
1416 if checked >= 100 {
1417 break;
1418 }
1419 let path = entry.path();
1420 if !path.is_file() {
1421 continue;
1422 }
1423 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1424 if !matches!(
1425 ext,
1426 "rs" | "py" | "ts" | "tsx" | "js" | "jsx" | "go" | "c" | "cpp" | "h"
1427 ) {
1428 continue;
1429 }
1430 let rel = path
1431 .strip_prefix(&root)
1432 .unwrap_or(path)
1433 .to_string_lossy()
1434 .replace('\\', "/");
1435 if rel == skip_path {
1436 continue;
1437 }
1438 checked += 1;
1439 if let Ok(content) = std::fs::read_to_string(path) {
1440 let normalised = content.replace("\r\n", "\n");
1441 if normalised.contains(&norm_search) {
1442 return Some(rel);
1443 }
1444 }
1445 }
1446 None
1447}
1448
1449fn dedent(s: &str) -> String {
1455 let expanded: Vec<String> = s.lines().map(|l| l.replace('\t', " ")).collect();
1456 let min_indent = expanded
1457 .iter()
1458 .filter(|l| !l.trim().is_empty())
1459 .map(|l| l.len() - l.trim_start_matches(' ').len())
1460 .min()
1461 .unwrap_or(0);
1462 let mut out = String::with_capacity(s.len());
1463 for (i, l) in expanded.iter().enumerate() {
1464 if i > 0 {
1465 out.push('\n');
1466 }
1467 if l.trim().is_empty() {
1468 } else {
1470 out.push_str(l.get(min_indent..).unwrap_or(l).trim_end());
1471 }
1472 }
1473 out
1474}
1475
1476fn adjust_replace_indent(search: &str, file_span: &str, replace: &str) -> String {
1483 fn first_indent(s: &str) -> usize {
1484 s.lines()
1485 .find(|l| !l.trim().is_empty())
1486 .map(|l| l.len() - l.trim_start_matches(' ').len())
1487 .unwrap_or(0)
1488 }
1489
1490 let search_indent = first_indent(search);
1491 let file_indent = first_indent(file_span);
1492
1493 if search_indent == file_indent {
1494 return replace.to_string();
1495 }
1496
1497 let delta: i64 = file_indent as i64 - search_indent as i64;
1498 let trailing_newline = replace.ends_with('\n');
1499
1500 let adjusted: Vec<String> = replace
1501 .lines()
1502 .map(|line| {
1503 if line.trim().is_empty() {
1504 line.to_string()
1506 } else {
1507 let current_indent = line.len() - line.trim_start_matches(' ').len();
1508 let new_indent = (current_indent as i64 + delta).max(0) as usize;
1509 format!("{}{}", " ".repeat(new_indent), line.trim_start_matches(' '))
1510 }
1511 })
1512 .collect();
1513
1514 let mut result = adjusted.join("\n");
1515 if trailing_newline {
1516 result.push('\n');
1517 }
1518 result
1519}
1520
1521pub fn compute_edit_file_diff(args: &Value) -> Result<String, String> {
1527 let path = require_str(args, "path")?;
1528 let search = require_str(args, "search")?;
1529 let replace = require_str(args, "replace")?;
1530
1531 let abs = safe_path(path)?;
1532 let raw = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1533 let original = raw.replace("\r\n", "\n");
1534
1535 let (effective_search, effective_replace): (String, String) = if original.contains(search) {
1536 (search.to_string(), replace.to_string())
1537 } else {
1538 let span = rstrip_find_span(&original, search)
1539 .or_else(|| indent_flexible_find_span(&original, search))
1540 .or_else(|| fuzzy_find_span(&original, search));
1541 match span {
1542 Some(span) => {
1543 let real_slice = original[span].to_string();
1544 let adjusted = adjust_replace_indent(search, &real_slice, replace);
1545 (real_slice, adjusted)
1546 }
1547 None => return Err("search string not found — diff preview unavailable".into()),
1548 }
1549 };
1550
1551 let mut diff = String::with_capacity(effective_search.len() + effective_replace.len() + 16);
1552 for line in effective_search.lines() {
1553 let _ = writeln!(diff, "- {}", line);
1554 }
1555 for line in effective_replace.lines() {
1556 let _ = writeln!(diff, "+ {}", line);
1557 }
1558 Ok(diff)
1559}
1560
1561pub fn compute_patch_hunk_diff(args: &Value) -> Result<String, String> {
1563 let path = require_str(args, "path")?;
1564 let start_line = require_usize(args, "start_line")?;
1565 let end_line = require_usize(args, "end_line")?;
1566 let replacement = require_str(args, "replacement")?;
1567
1568 let abs = safe_path(path)?;
1569 let original = fs::read_to_string(&abs).map_err(|e| format!("diff preview read: {e}"))?;
1570 let lines: Vec<&str> = original.lines().collect();
1571 let total = lines.len();
1572
1573 if start_line < 1 || start_line > total || end_line < start_line || end_line > total {
1574 return Err(format!(
1575 "patch_hunk: invalid line range {}-{} for file with {} lines",
1576 start_line, end_line, total
1577 ));
1578 }
1579
1580 let s_idx = start_line - 1;
1581 let e_idx = end_line;
1582
1583 let mut diff = format!("@@ lines {}-{} @@\n", start_line, end_line);
1584 for line in &lines[s_idx..e_idx] {
1585 let _ = writeln!(diff, "- {}", line.trim_end());
1586 }
1587 for line in replacement.lines() {
1588 let _ = writeln!(diff, "+ {}", line.trim_end());
1589 }
1590 Ok(diff)
1591}
1592
1593pub fn compute_msr_diff(args: &Value) -> Result<String, String> {
1595 let hunks_val = args
1596 .get("hunks")
1597 .ok_or_else(|| "multi_search_replace requires 'hunks' array".to_string())?;
1598
1599 #[derive(serde::Deserialize)]
1600 struct PreviewHunk {
1601 search: String,
1602 replace: String,
1603 }
1604 let hunks: Vec<PreviewHunk> = serde_json::from_value(hunks_val.clone())
1605 .map_err(|e| format!("compute_msr_diff: invalid hunks: {e}"))?;
1606
1607 let mut diff = String::with_capacity(hunks.len() * 128 + 16);
1608 for (i, hunk) in hunks.iter().enumerate() {
1609 if hunks.len() > 1 {
1610 let _ = writeln!(diff, "@@ hunk {} @@", i + 1);
1611 }
1612 for line in hunk.search.lines() {
1613 let _ = writeln!(diff, "- {}", line.trim_end());
1614 }
1615 for line in hunk.replace.lines() {
1616 let _ = writeln!(diff, "+ {}", line.trim_end());
1617 }
1618 }
1619 Ok(diff)
1620}
1621
1622pub fn compute_write_file_diff(args: &Value) -> Result<String, String> {
1625 let path = require_str(args, "path")?;
1626 let new_content = require_str(args, "content")?;
1627
1628 let abs = safe_path(path).unwrap_or_else(|_| std::path::PathBuf::from(path));
1629 let old_content = fs::read_to_string(&abs)
1630 .map(|s| s.replace("\r\n", "\n"))
1631 .unwrap_or_default();
1632
1633 let mut diff = String::with_capacity(old_content.len() + new_content.len() + 16);
1634 if !old_content.is_empty() {
1635 for line in old_content.lines() {
1636 let _ = writeln!(diff, "- {}", line);
1637 }
1638 }
1639 for line in new_content.lines() {
1640 let _ = writeln!(diff, "+ {}", line);
1641 }
1642 if diff.is_empty() {
1643 return Err("empty content — diff preview unavailable".into());
1644 }
1645 Ok(diff)
1646}
1647
1648pub fn workspace_root() -> PathBuf {
1650 let mut current = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1651 loop {
1652 if current.join(".git").exists()
1653 || current.join("Cargo.toml").exists()
1654 || current.join("package.json").exists()
1655 {
1656 return current;
1657 }
1658 if !current.pop() {
1659 break;
1660 }
1661 }
1662 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1663}
1664
1665pub fn is_os_shortcut_directory(path: &Path) -> bool {
1669 let candidates = [
1670 dirs::desktop_dir(),
1671 dirs::download_dir(),
1672 dirs::document_dir(),
1673 dirs::picture_dir(),
1674 dirs::video_dir(),
1675 dirs::audio_dir(),
1676 ];
1677 candidates
1678 .iter()
1679 .filter_map(|d| d.as_deref())
1680 .any(|d| d == path)
1681}
1682
1683pub fn hematite_dir() -> PathBuf {
1689 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1690 if is_os_shortcut_directory(&cwd) {
1691 if let Some(home) = dirs::home_dir() {
1692 return home.join(".hematite");
1693 }
1694 }
1695 workspace_root().join(".hematite")
1696}
1697
1698pub fn is_project_workspace() -> bool {
1702 let root = workspace_root();
1703 let has_explicit_marker = root.join("Cargo.toml").exists()
1704 || root.join("package.json").exists()
1705 || root.join("pyproject.toml").exists()
1706 || root.join("go.mod").exists()
1707 || root.join("setup.py").exists()
1708 || root.join("pom.xml").exists()
1709 || root.join("build.gradle").exists()
1710 || root.join("CMakeLists.txt").exists()
1711 || root.join("index.html").exists()
1712 || root.join("style.css").exists()
1713 || root.join("script.js").exists();
1714 has_explicit_marker || (root.join(".git").exists() && root.join("src").exists())
1715}
1716
1717pub fn open_in_system_editor(path: &std::path::Path) -> Result<(), String> {
1720 if !path.exists() {
1721 return Err(format!("File not found: {}", path.display()));
1722 }
1723
1724 #[cfg(target_os = "windows")]
1725 {
1726 let status = std::process::Command::new("cmd")
1729 .args(["/c", "start", "", &path.to_string_lossy()])
1730 .status()
1731 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1732
1733 if !status.success() {
1734 return Err("Editor command failed to start.".into());
1735 }
1736 }
1737
1738 #[cfg(target_os = "macos")]
1739 {
1740 let status = std::process::Command::new("open")
1741 .arg(path)
1742 .status()
1743 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1744
1745 if !status.success() {
1746 return Err("open command failed.".into());
1747 }
1748 }
1749
1750 #[cfg(all(unix, not(target_os = "macos")))]
1751 {
1752 let status = std::process::Command::new("xdg-open")
1754 .arg(path)
1755 .status()
1756 .map_err(|e| format!("Failed to launch editor: {e}"))?;
1757
1758 if !status.success() {
1759 return Err("xdg-open failed.".into());
1760 }
1761 }
1762
1763 Ok(())
1764}
1765
1766#[cfg(test)]
1767mod tests {
1768 use super::*;
1769
1770 #[test]
1771 fn safe_path_allows_plan_sidecars_inside_workspace() {
1772 let _cwd_lock = crate::TEST_CWD_LOCK
1773 .lock()
1774 .unwrap_or_else(|e| e.into_inner());
1775 let temp = tempfile::tempdir().unwrap();
1776 let root = temp.path();
1777 std::fs::create_dir_all(root.join(".hematite")).unwrap();
1778 std::fs::write(root.join(".hematite").join("TASK.md"), "# Task Ledger\n").unwrap();
1779
1780 let previous = env!("CARGO_MANIFEST_DIR");
1781 std::env::set_current_dir(root).unwrap();
1782 let resolved = safe_path(".hematite/TASK.md").unwrap();
1783 std::env::set_current_dir(previous).unwrap();
1784
1785 assert!(resolved.ends_with(Path::new(".hematite").join("TASK.md")));
1786 }
1787}