1use std::cmp::Reverse;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use std::fmt;
8
9use glob::Pattern;
10use regex::RegexBuilder;
11use serde::{Deserialize, Serialize};
12use walkdir::WalkDir;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum GrepOutputMode {
17 #[default]
18 FilesWithMatches,
19 Content,
20 Count,
21}
22
23impl fmt::Display for GrepOutputMode {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Self::FilesWithMatches => f.write_str("files_with_matches"),
27 Self::Content => f.write_str("content"),
28 Self::Count => f.write_str("count"),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TextFilePayload {
35 #[serde(rename = "filePath")]
36 pub file_path: String,
37 pub content: String,
38 #[serde(rename = "numLines")]
39 pub num_lines: usize,
40 #[serde(rename = "startLine")]
41 pub start_line: usize,
42 #[serde(rename = "totalLines")]
43 pub total_lines: usize,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct ReadFileOutput {
48 #[serde(rename = "type")]
49 pub kind: String,
50 pub file: TextFilePayload,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct StructuredPatchHunk {
55 #[serde(rename = "oldStart")]
56 pub old_start: usize,
57 #[serde(rename = "oldLines")]
58 pub old_lines: usize,
59 #[serde(rename = "newStart")]
60 pub new_start: usize,
61 #[serde(rename = "newLines")]
62 pub new_lines: usize,
63 pub lines: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct WriteFileOutput {
68 #[serde(rename = "type")]
69 pub kind: String,
70 #[serde(rename = "filePath")]
71 pub file_path: String,
72 pub content: String,
73 #[serde(rename = "structuredPatch")]
74 pub structured_patch: Vec<StructuredPatchHunk>,
75 #[serde(rename = "originalFile")]
76 pub original_file: Option<String>,
77 #[serde(rename = "gitDiff")]
78 pub git_diff: Option<serde_json::Value>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct EditFileOutput {
83 #[serde(rename = "filePath")]
84 pub file_path: String,
85 #[serde(rename = "oldString")]
86 pub old_string: String,
87 #[serde(rename = "newString")]
88 pub new_string: String,
89 #[serde(rename = "originalFile")]
90 pub original_file: String,
91 #[serde(rename = "structuredPatch")]
92 pub structured_patch: Vec<StructuredPatchHunk>,
93 #[serde(rename = "userModified")]
94 pub user_modified: bool,
95 #[serde(rename = "replaceAll")]
96 pub replace_all: bool,
97 #[serde(rename = "gitDiff")]
98 pub git_diff: Option<serde_json::Value>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
102pub struct GlobSearchOutput {
103 #[serde(rename = "durationMs")]
104 pub duration_ms: u128,
105 #[serde(rename = "numFiles")]
106 pub num_files: usize,
107 pub filenames: Vec<String>,
108 pub truncated: bool,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct GrepSearchInput {
113 pub pattern: String,
114 pub path: Option<String>,
115 pub glob: Option<String>,
116 #[serde(rename = "output_mode")]
117 pub output_mode: Option<GrepOutputMode>,
118 #[serde(rename = "-B")]
119 pub before: Option<usize>,
120 #[serde(rename = "-A")]
121 pub after: Option<usize>,
122 #[serde(rename = "-C")]
123 pub context_short: Option<usize>,
124 pub context: Option<usize>,
125 #[serde(rename = "-n")]
126 pub line_numbers: Option<bool>,
127 #[serde(rename = "-i")]
128 pub case_insensitive: Option<bool>,
129 #[serde(rename = "type")]
130 pub file_type: Option<String>,
131 pub head_limit: Option<usize>,
132 pub offset: Option<usize>,
133 pub multiline: Option<bool>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct GrepSearchOutput {
138 pub mode: Option<GrepOutputMode>,
139 #[serde(rename = "numFiles")]
140 pub num_files: usize,
141 pub filenames: Vec<String>,
142 pub content: Option<String>,
143 #[serde(rename = "numLines")]
144 pub num_lines: Option<usize>,
145 #[serde(rename = "numMatches")]
146 pub num_matches: Option<usize>,
147 #[serde(rename = "appliedLimit")]
148 pub applied_limit: Option<usize>,
149 #[serde(rename = "appliedOffset")]
150 pub applied_offset: Option<usize>,
151}
152
153pub fn read_file(
154 path: &str,
155 offset: Option<usize>,
156 limit: Option<usize>,
157) -> io::Result<ReadFileOutput> {
158 let absolute_path = normalize_path(path)?;
159 let content = fs::read_to_string(&absolute_path)?;
160 let lines: Vec<&str> = content.lines().collect();
161 let start_index = offset.unwrap_or(0).min(lines.len());
162 let end_index = limit.map_or(lines.len(), |limit| {
163 start_index.saturating_add(limit).min(lines.len())
164 });
165 let selected = lines[start_index..end_index].join("\n");
166
167 Ok(ReadFileOutput {
168 kind: String::from("text"),
169 file: TextFilePayload {
170 file_path: absolute_path.to_string_lossy().into_owned(),
171 content: selected,
172 num_lines: end_index.saturating_sub(start_index),
173 start_line: start_index.saturating_add(1),
174 total_lines: lines.len(),
175 },
176 })
177}
178
179pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
180 let absolute_path = normalize_path_allow_missing(path)?;
181 let original_file = fs::read_to_string(&absolute_path).ok();
182 if let Some(parent) = absolute_path.parent() {
183 fs::create_dir_all(parent)?;
184 }
185 fs::write(&absolute_path, content)?;
186
187 Ok(WriteFileOutput {
188 kind: if original_file.is_some() {
189 String::from("update")
190 } else {
191 String::from("create")
192 },
193 file_path: absolute_path.to_string_lossy().into_owned(),
194 content: content.to_owned(),
195 structured_patch: make_patch(original_file.as_deref().unwrap_or(""), content),
196 original_file,
197 git_diff: None,
198 })
199}
200
201pub fn edit_file(
202 path: &str,
203 old_string: &str,
204 new_string: &str,
205 replace_all: bool,
206) -> io::Result<EditFileOutput> {
207 let absolute_path = normalize_path(path)?;
208 let original_file = fs::read_to_string(&absolute_path)?;
209 if old_string == new_string {
210 return Err(io::Error::new(
211 io::ErrorKind::InvalidInput,
212 "old_string and new_string must differ",
213 ));
214 }
215 if !original_file.contains(old_string) {
216 return Err(io::Error::new(
217 io::ErrorKind::NotFound,
218 "old_string not found in file",
219 ));
220 }
221
222 let updated = if replace_all {
223 original_file.replace(old_string, new_string)
224 } else {
225 original_file.replacen(old_string, new_string, 1)
226 };
227 fs::write(&absolute_path, &updated)?;
228
229 Ok(EditFileOutput {
230 file_path: absolute_path.to_string_lossy().into_owned(),
231 old_string: old_string.to_owned(),
232 new_string: new_string.to_owned(),
233 original_file: original_file.clone(),
234 structured_patch: make_patch(&original_file, &updated),
235 user_modified: false,
236 replace_all,
237 git_diff: None,
238 })
239}
240
241pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
242 let started = Instant::now();
243 let base_dir = path
244 .map(normalize_path)
245 .transpose()?
246 .unwrap_or(std::env::current_dir()?);
247 let search_pattern = if Path::new(pattern).is_absolute() {
248 enforce_workspace_boundary(Path::new(pattern))?;
249 pattern.to_owned()
250 } else {
251 base_dir.join(pattern).to_string_lossy().into_owned()
252 };
253
254 let mut matches = Vec::new();
255 let entries = glob::glob(&search_pattern)
256 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
257 for entry in entries.flatten() {
258 if entry.is_file() {
259 matches.push(entry);
260 }
261 }
262
263 matches.sort_by_key(|path| {
264 fs::metadata(path)
265 .and_then(|metadata| metadata.modified())
266 .ok()
267 .map(Reverse)
268 });
269
270 let truncated = matches.len() > 100;
271 let filenames = matches
272 .into_iter()
273 .take(100)
274 .map(|path| path.to_string_lossy().into_owned())
275 .collect::<Vec<_>>();
276
277 Ok(GlobSearchOutput {
278 duration_ms: started.elapsed().as_millis(),
279 num_files: filenames.len(),
280 filenames,
281 truncated,
282 })
283}
284
285pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
286 let base_path = input
287 .path
288 .as_deref()
289 .map(normalize_path)
290 .transpose()?
291 .unwrap_or(std::env::current_dir()?);
292
293 let regex = RegexBuilder::new(&input.pattern)
294 .case_insensitive(input.case_insensitive.unwrap_or(false))
295 .dot_matches_new_line(input.multiline.unwrap_or(false))
296 .build()
297 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
298
299 let glob_filter = input
300 .glob
301 .as_deref()
302 .map(Pattern::new)
303 .transpose()
304 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
305 let file_type = input.file_type.as_deref();
306 let output_mode = input.output_mode.unwrap_or_default();
307 let context = input.context.or(input.context_short).unwrap_or(0);
308
309 let mut filenames = Vec::new();
310 let mut content_lines = Vec::new();
311 let mut total_matches = 0usize;
312
313 for file_path in collect_search_files(&base_path)? {
314 if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
315 continue;
316 }
317
318 let Ok(file_contents) = fs::read_to_string(&file_path) else {
319 continue;
320 };
321
322 if output_mode == GrepOutputMode::Count {
323 let count = regex.find_iter(&file_contents).count();
324 if count > 0 {
325 filenames.push(file_path.to_string_lossy().into_owned());
326 total_matches += count;
327 }
328 continue;
329 }
330
331 let lines: Vec<&str> = file_contents.lines().collect();
332 let mut matched_lines = Vec::new();
333 for (index, line) in lines.iter().enumerate() {
334 if regex.is_match(line) {
335 total_matches += 1;
336 matched_lines.push(index);
337 }
338 }
339
340 if matched_lines.is_empty() {
341 continue;
342 }
343
344 filenames.push(file_path.to_string_lossy().into_owned());
345 if output_mode == GrepOutputMode::Content {
346 let mut emitted = std::collections::BTreeSet::new();
347 for index in matched_lines {
348 let start = index.saturating_sub(input.before.unwrap_or(context));
349 let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
350 for (current, line) in lines.iter().enumerate().take(end).skip(start) {
351 if !emitted.insert(current) {
352 continue;
353 }
354 let prefix = if input.line_numbers.unwrap_or(true) {
355 format!("{}:{}:", file_path.to_string_lossy(), current + 1)
356 } else {
357 format!("{}:", file_path.to_string_lossy())
358 };
359 content_lines.push(format!("{prefix}{line}"));
360 }
361 }
362 }
363 }
364
365 let (filenames, applied_limit, applied_offset) =
366 apply_limit(filenames, input.head_limit, input.offset);
367 let content_output = if output_mode == GrepOutputMode::Content {
368 let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
369 return Ok(GrepSearchOutput {
370 mode: Some(GrepOutputMode::Content),
371 num_files: filenames.len(),
372 filenames,
373 num_lines: Some(lines.len()),
374 content: Some(lines.join("\n")),
375 num_matches: Some(total_matches),
376 applied_limit: limit,
377 applied_offset: offset,
378 });
379 } else {
380 None
381 };
382
383 Ok(GrepSearchOutput {
384 mode: Some(output_mode),
385 num_files: filenames.len(),
386 filenames,
387 content: content_output,
388 num_lines: None,
389 num_matches: (output_mode == GrepOutputMode::Count).then_some(total_matches),
390 applied_limit,
391 applied_offset,
392 })
393}
394
395fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
396 if base_path.is_file() {
397 return Ok(vec![base_path.to_path_buf()]);
398 }
399
400 let mut files = Vec::new();
401 for entry in WalkDir::new(base_path) {
402 let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
403 if entry.file_type().is_file() {
404 files.push(entry.path().to_path_buf());
405 }
406 }
407 Ok(files)
408}
409
410fn matches_optional_filters(
411 path: &Path,
412 glob_filter: Option<&Pattern>,
413 file_type: Option<&str>,
414) -> bool {
415 if let Some(glob_filter) = glob_filter {
416 let path_string = path.to_string_lossy();
417 if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
418 return false;
419 }
420 }
421
422 if let Some(file_type) = file_type {
423 let extension = path.extension().and_then(|extension| extension.to_str());
424 if extension != Some(file_type) {
425 return false;
426 }
427 }
428
429 true
430}
431
432fn apply_limit<T>(
433 items: Vec<T>,
434 limit: Option<usize>,
435 offset: Option<usize>,
436) -> (Vec<T>, Option<usize>, Option<usize>) {
437 let offset_value = offset.unwrap_or(0);
438 let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
439 let explicit_limit = limit.unwrap_or(250);
440 if explicit_limit == 0 {
441 return (items, None, (offset_value > 0).then_some(offset_value));
442 }
443
444 let truncated = items.len() > explicit_limit;
445 items.truncate(explicit_limit);
446 (
447 items,
448 truncated.then_some(explicit_limit),
449 (offset_value > 0).then_some(offset_value),
450 )
451}
452
453fn make_patch(original: &str, updated: &str) -> Vec<StructuredPatchHunk> {
454 let mut lines = Vec::new();
455 for line in original.lines() {
456 lines.push(format!("-{line}"));
457 }
458 for line in updated.lines() {
459 lines.push(format!("+{line}"));
460 }
461
462 vec![StructuredPatchHunk {
463 old_start: 1,
464 old_lines: original.lines().count(),
465 new_start: 1,
466 new_lines: updated.lines().count(),
467 lines,
468 }]
469}
470
471fn workspace_root() -> io::Result<PathBuf> {
472 if let Ok(override_root) = std::env::var("CODINEER_WORKSPACE_ROOT") {
473 return Ok(PathBuf::from(override_root));
474 }
475 std::env::current_dir()
476}
477
478fn enforce_workspace_boundary(resolved: &Path) -> io::Result<()> {
479 let root = dunce::canonicalize(workspace_root()?).map_err(|e| {
480 io::Error::new(
481 io::ErrorKind::NotFound,
482 format!("cannot resolve workspace root: {e}"),
483 )
484 })?;
485 if root.as_os_str().is_empty() || !resolved.starts_with(&root) {
486 return Err(io::Error::new(
487 io::ErrorKind::PermissionDenied,
488 format!(
489 "path '{}' is outside the workspace root '{}'; access denied",
490 resolved.display(),
491 root.display(),
492 ),
493 ));
494 }
495 Ok(())
496}
497
498fn normalize_path(path: &str) -> io::Result<PathBuf> {
499 let candidate = if Path::new(path).is_absolute() {
500 PathBuf::from(path)
501 } else {
502 std::env::current_dir()?.join(path)
503 };
504 match dunce::canonicalize(&candidate) {
505 Ok(resolved) => {
506 enforce_workspace_boundary(&resolved)?;
507 Ok(resolved)
508 }
509 Err(err) if err.kind() == io::ErrorKind::NotFound => {
510 let root = workspace_root()?;
517 if !candidate.starts_with(&root) {
518 return Err(io::Error::new(
519 io::ErrorKind::PermissionDenied,
520 format!(
521 "path '{}' is outside the workspace root '{}'; access denied",
522 candidate.display(),
523 root.display(),
524 ),
525 ));
526 }
527 Err(err)
528 }
529 Err(err) => Err(err),
530 }
531}
532
533fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
534 let candidate = if Path::new(path).is_absolute() {
535 PathBuf::from(path)
536 } else {
537 std::env::current_dir()?.join(path)
538 };
539
540 if let Ok(canonical) = dunce::canonicalize(&candidate) {
541 enforce_workspace_boundary(&canonical)?;
542 return Ok(canonical);
543 }
544
545 let mut ancestor = candidate.clone();
550 let mut suffix = PathBuf::new();
551 loop {
552 if let Ok(canonical_ancestor) = dunce::canonicalize(&ancestor) {
553 enforce_workspace_boundary(&canonical_ancestor)?;
554 return Ok(if suffix.as_os_str().is_empty() {
555 canonical_ancestor
556 } else {
557 canonical_ancestor.join(&suffix)
558 });
559 }
560 match ancestor.file_name() {
561 Some(name) => {
562 suffix = if suffix.as_os_str().is_empty() {
563 PathBuf::from(name)
564 } else {
565 PathBuf::from(name).join(&suffix)
566 };
567 }
568 None => break,
569 }
570 match ancestor.parent() {
571 Some(parent) if !parent.as_os_str().is_empty() => {
572 ancestor = parent.to_path_buf();
573 }
574 _ => break,
575 }
576 }
577
578 Ok(candidate)
579}
580
581#[cfg(test)]
582mod tests {
583 use std::time::{SystemTime, UNIX_EPOCH};
584
585 use super::{
586 edit_file, glob_search, grep_search, read_file, write_file, GrepOutputMode, GrepSearchInput,
587 };
588
589 fn workspace_dir() -> std::path::PathBuf {
590 std::env::temp_dir().join("codineer-test-workspace")
591 }
592
593 fn temp_path(name: &str) -> std::path::PathBuf {
594 let unique = SystemTime::now()
595 .duration_since(UNIX_EPOCH)
596 .expect("time should move forward")
597 .as_nanos();
598 workspace_dir().join(format!("codineer-native-{name}-{unique}"))
599 }
600
601 fn allow_temp_workspace() {
602 let ws = workspace_dir();
603 std::fs::create_dir_all(&ws).ok();
604 let ws = ws.canonicalize().unwrap_or(ws);
605 std::env::set_var("CODINEER_WORKSPACE_ROOT", ws);
606 }
607
608 #[test]
609 fn reads_and_writes_files() {
610 allow_temp_workspace();
611 let path = temp_path("read-write.txt");
612 let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
613 .expect("write should succeed");
614 assert_eq!(write_output.kind, "create");
615
616 let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
617 .expect("read should succeed");
618 assert_eq!(read_output.file.content, "two");
619 }
620
621 #[test]
622 fn edits_file_contents() {
623 allow_temp_workspace();
624 let path = temp_path("edit.txt");
625 write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
626 .expect("initial write should succeed");
627 let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
628 .expect("edit should succeed");
629 assert!(output.replace_all);
630 }
631
632 #[test]
633 fn globs_and_greps_directory() {
634 allow_temp_workspace();
635 let dir = temp_path("search-dir");
636 std::fs::create_dir_all(&dir).expect("directory should be created");
637 let file = dir.join("demo.rs");
638 write_file(
639 file.to_string_lossy().as_ref(),
640 "fn main() {\n println!(\"hello\");\n}\n",
641 )
642 .expect("file write should succeed");
643
644 let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
645 .expect("glob should succeed");
646 assert_eq!(globbed.num_files, 1);
647
648 let grep_output = grep_search(&GrepSearchInput {
649 pattern: String::from("hello"),
650 path: Some(dir.to_string_lossy().into_owned()),
651 glob: Some(String::from("**/*.rs")),
652 output_mode: Some(GrepOutputMode::Content),
653 before: None,
654 after: None,
655 context_short: None,
656 context: None,
657 line_numbers: Some(true),
658 case_insensitive: Some(false),
659 file_type: None,
660 head_limit: Some(10),
661 offset: Some(0),
662 multiline: Some(false),
663 })
664 .expect("grep should succeed");
665 assert!(grep_output.content.unwrap_or_default().contains("hello"));
666 }
667
668 #[test]
669 #[cfg(unix)]
670 fn rejects_absolute_path_outside_workspace() {
671 allow_temp_workspace();
672 let result = read_file("/etc/passwd", None, None);
673 assert!(result.is_err());
674 let err = result.unwrap_err();
675 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
676 }
677
678 #[test]
679 #[cfg(windows)]
680 fn rejects_absolute_path_outside_workspace_windows() {
681 allow_temp_workspace();
682 let result = read_file("C:\\Windows\\System32\\drivers\\etc\\hosts", None, None);
683 assert!(result.is_err());
684 }
685
686 #[test]
687 fn rejects_relative_path_traversal_above_workspace() {
688 allow_temp_workspace();
689 let result = read_file("../../../etc/passwd", None, None);
690 assert!(result.is_err());
691 }
692
693 #[test]
694 fn rejects_write_outside_workspace() {
695 allow_temp_workspace();
696 let sentinel = std::env::temp_dir().join("codineer-test-outside-sentinel");
697 let sentinel_str = sentinel.to_string_lossy().to_string();
698 let result = write_file(&sentinel_str, "malicious");
699 let denied = result.is_err()
700 && result
701 .as_ref()
702 .unwrap_err()
703 .kind()
704 .eq(&std::io::ErrorKind::PermissionDenied);
705 if !denied {
706 let _ = std::fs::remove_file(&sentinel);
707 }
708 assert!(denied, "write outside workspace must be denied");
709 }
710
711 #[test]
712 fn allows_operations_within_workspace() {
713 allow_temp_workspace();
714 let path = temp_path("inside-workspace.txt");
715 write_file(path.to_string_lossy().as_ref(), "safe content")
716 .expect("write within workspace should succeed");
717 let read_output = read_file(path.to_string_lossy().as_ref(), None, None)
718 .expect("read within workspace should succeed");
719 assert_eq!(read_output.file.content, "safe content");
720 }
721
722 #[test]
723 fn edit_rejects_identical_old_and_new_string() {
724 allow_temp_workspace();
725 let path = temp_path("edit-reject.txt");
726 write_file(path.to_string_lossy().as_ref(), "content").expect("write");
727 let result = edit_file(path.to_string_lossy().as_ref(), "content", "content", false);
728 assert!(result.is_err());
729 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
730 }
731
732 #[test]
733 fn edit_rejects_missing_old_string() {
734 allow_temp_workspace();
735 let path = temp_path("edit-missing.txt");
736 write_file(path.to_string_lossy().as_ref(), "alpha beta").expect("write");
737 let result = edit_file(path.to_string_lossy().as_ref(), "gamma", "delta", false);
738 assert!(result.is_err());
739 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
740 }
741}