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()?)
480 .unwrap_or_else(|_| workspace_root().unwrap_or_default());
481 if !resolved.starts_with(&root) {
482 return Err(io::Error::new(
483 io::ErrorKind::PermissionDenied,
484 format!(
485 "path '{}' is outside the workspace root '{}'; access denied",
486 resolved.display(),
487 root.display(),
488 ),
489 ));
490 }
491 Ok(())
492}
493
494fn normalize_path(path: &str) -> io::Result<PathBuf> {
495 let candidate = if Path::new(path).is_absolute() {
496 PathBuf::from(path)
497 } else {
498 std::env::current_dir()?.join(path)
499 };
500 match dunce::canonicalize(&candidate) {
501 Ok(resolved) => {
502 enforce_workspace_boundary(&resolved)?;
503 Ok(resolved)
504 }
505 Err(err) if err.kind() == io::ErrorKind::NotFound => {
506 let root = workspace_root()?;
513 if !candidate.starts_with(&root) {
514 return Err(io::Error::new(
515 io::ErrorKind::PermissionDenied,
516 format!(
517 "path '{}' is outside the workspace root '{}'; access denied",
518 candidate.display(),
519 root.display(),
520 ),
521 ));
522 }
523 Err(err)
524 }
525 Err(err) => Err(err),
526 }
527}
528
529fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
530 let candidate = if Path::new(path).is_absolute() {
531 PathBuf::from(path)
532 } else {
533 std::env::current_dir()?.join(path)
534 };
535
536 if let Ok(canonical) = dunce::canonicalize(&candidate) {
537 enforce_workspace_boundary(&canonical)?;
538 return Ok(canonical);
539 }
540
541 let mut ancestor = candidate.clone();
546 let mut suffix = PathBuf::new();
547 loop {
548 if let Ok(canonical_ancestor) = dunce::canonicalize(&ancestor) {
549 enforce_workspace_boundary(&canonical_ancestor)?;
550 return Ok(if suffix.as_os_str().is_empty() {
551 canonical_ancestor
552 } else {
553 canonical_ancestor.join(&suffix)
554 });
555 }
556 match ancestor.file_name() {
557 Some(name) => {
558 suffix = if suffix.as_os_str().is_empty() {
559 PathBuf::from(name)
560 } else {
561 PathBuf::from(name).join(&suffix)
562 };
563 }
564 None => break,
565 }
566 match ancestor.parent() {
567 Some(parent) if !parent.as_os_str().is_empty() => {
568 ancestor = parent.to_path_buf();
569 }
570 _ => break,
571 }
572 }
573
574 Ok(candidate)
575}
576
577#[cfg(test)]
578mod tests {
579 use std::time::{SystemTime, UNIX_EPOCH};
580
581 use super::{
582 edit_file, glob_search, grep_search, read_file, write_file, GrepOutputMode, GrepSearchInput,
583 };
584
585 fn workspace_dir() -> std::path::PathBuf {
586 std::env::temp_dir().join("codineer-test-workspace")
587 }
588
589 fn temp_path(name: &str) -> std::path::PathBuf {
590 let unique = SystemTime::now()
591 .duration_since(UNIX_EPOCH)
592 .expect("time should move forward")
593 .as_nanos();
594 workspace_dir().join(format!("codineer-native-{name}-{unique}"))
595 }
596
597 fn allow_temp_workspace() {
598 let ws = workspace_dir();
599 std::fs::create_dir_all(&ws).ok();
600 let ws = ws.canonicalize().unwrap_or(ws);
601 std::env::set_var("CODINEER_WORKSPACE_ROOT", ws);
602 }
603
604 #[test]
605 fn reads_and_writes_files() {
606 allow_temp_workspace();
607 let path = temp_path("read-write.txt");
608 let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
609 .expect("write should succeed");
610 assert_eq!(write_output.kind, "create");
611
612 let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
613 .expect("read should succeed");
614 assert_eq!(read_output.file.content, "two");
615 }
616
617 #[test]
618 fn edits_file_contents() {
619 allow_temp_workspace();
620 let path = temp_path("edit.txt");
621 write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
622 .expect("initial write should succeed");
623 let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
624 .expect("edit should succeed");
625 assert!(output.replace_all);
626 }
627
628 #[test]
629 fn globs_and_greps_directory() {
630 allow_temp_workspace();
631 let dir = temp_path("search-dir");
632 std::fs::create_dir_all(&dir).expect("directory should be created");
633 let file = dir.join("demo.rs");
634 write_file(
635 file.to_string_lossy().as_ref(),
636 "fn main() {\n println!(\"hello\");\n}\n",
637 )
638 .expect("file write should succeed");
639
640 let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
641 .expect("glob should succeed");
642 assert_eq!(globbed.num_files, 1);
643
644 let grep_output = grep_search(&GrepSearchInput {
645 pattern: String::from("hello"),
646 path: Some(dir.to_string_lossy().into_owned()),
647 glob: Some(String::from("**/*.rs")),
648 output_mode: Some(GrepOutputMode::Content),
649 before: None,
650 after: None,
651 context_short: None,
652 context: None,
653 line_numbers: Some(true),
654 case_insensitive: Some(false),
655 file_type: None,
656 head_limit: Some(10),
657 offset: Some(0),
658 multiline: Some(false),
659 })
660 .expect("grep should succeed");
661 assert!(grep_output.content.unwrap_or_default().contains("hello"));
662 }
663
664 #[test]
665 fn rejects_absolute_path_outside_workspace() {
666 allow_temp_workspace();
667 let result = read_file("/etc/passwd", None, None);
668 assert!(result.is_err());
669 let err = result.unwrap_err();
670 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
671 }
672
673 #[test]
674 fn rejects_relative_path_traversal_above_workspace() {
675 allow_temp_workspace();
676 let result = read_file("../../../etc/passwd", None, None);
677 assert!(result.is_err());
678 }
679
680 #[test]
681 fn rejects_write_outside_workspace() {
682 allow_temp_workspace();
683 let sentinel = std::env::temp_dir().join("codineer-test-outside-sentinel");
684 let sentinel_str = sentinel.to_string_lossy().to_string();
685 let result = write_file(&sentinel_str, "malicious");
686 let denied = result.is_err()
687 && result
688 .as_ref()
689 .unwrap_err()
690 .kind()
691 .eq(&std::io::ErrorKind::PermissionDenied);
692 if !denied {
693 let _ = std::fs::remove_file(&sentinel);
694 }
695 assert!(denied, "write outside workspace must be denied");
696 }
697
698 #[test]
699 fn allows_operations_within_workspace() {
700 allow_temp_workspace();
701 let path = temp_path("inside-workspace.txt");
702 write_file(path.to_string_lossy().as_ref(), "safe content")
703 .expect("write within workspace should succeed");
704 let read_output = read_file(path.to_string_lossy().as_ref(), None, None)
705 .expect("read within workspace should succeed");
706 assert_eq!(read_output.file.content, "safe content");
707 }
708
709 #[test]
710 fn edit_rejects_identical_old_and_new_string() {
711 allow_temp_workspace();
712 let path = temp_path("edit-reject.txt");
713 write_file(path.to_string_lossy().as_ref(), "content").expect("write");
714 let result = edit_file(path.to_string_lossy().as_ref(), "content", "content", false);
715 assert!(result.is_err());
716 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
717 }
718
719 #[test]
720 fn edit_rejects_missing_old_string() {
721 allow_temp_workspace();
722 let path = temp_path("edit-missing.txt");
723 write_file(path.to_string_lossy().as_ref(), "alpha beta").expect("write");
724 let result = edit_file(path.to_string_lossy().as_ref(), "gamma", "delta", false);
725 assert!(result.is_err());
726 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
727 }
728}