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