1use super::safe_resolve_path;
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24fn fmt_age(age: std::time::Duration) -> String {
32 let secs = age.as_secs();
33 if secs < 5 {
34 "just now".to_string()
35 } else if secs < 60 {
36 format!("{secs}s ago")
37 } else {
38 format!("{}m ago", secs / 60)
39 }
40}
41
42pub async fn validate_tool_call(
56 tool_name: &str,
57 args: &serde_json::Value,
58 project_root: &Path,
59 read_cache: Option<&super::FileReadCache>,
60 last_writer: Option<&super::LastWriterCache>,
61 last_bash: Option<&super::LastBashCache>,
62) -> Option<String> {
63 match tool_name {
64 "Edit" => validate_edit(args, project_root, read_cache, last_writer, last_bash).await,
65 "Write" => validate_write(args, project_root).await,
66 "Delete" => validate_delete(args, project_root).await,
67 "Bash" => validate_bash(args),
68 _ => None,
69 }
70}
71
72pub async fn validate_with_registry(
91 registry: &super::ToolRegistry,
92 tool_name: &str,
93 args: &serde_json::Value,
94 project_root: &Path,
95) -> Option<String> {
96 let read_cache = registry.file_read_cache();
97 let last_writer = registry.last_writer_cache();
98 let last_bash = registry.last_bash_cache();
99 validate_tool_call(
100 tool_name,
101 args,
102 project_root,
103 Some(&read_cache),
104 Some(&last_writer),
105 Some(&last_bash),
106 )
107 .await
108}
109
110fn writer_hint(
117 resolved: &PathBuf,
118 last_writer: Option<&super::LastWriterCache>,
119 last_bash: Option<&super::LastBashCache>,
120) -> String {
121 if let Some(lw) = last_writer
123 && let Ok(guard) = lw.lock()
124 && let Some((tool, when)) = guard.get(resolved)
125 {
126 return format!(" (last written by {} {})", tool, fmt_age(when.elapsed()));
127 }
128 if let Some(lb) = last_bash
131 && let Ok(guard) = lb.lock()
132 && let Some((snippet, when)) = guard.as_ref()
133 {
134 return format!(" (Bash ran {}: `{}`)", fmt_age(when.elapsed()), snippet);
135 }
136 String::new()
137}
138
139async fn validate_edit(
143 args: &serde_json::Value,
144 project_root: &Path,
145 read_cache: Option<&super::FileReadCache>,
146 last_writer: Option<&super::LastWriterCache>,
147 last_bash: Option<&super::LastBashCache>,
148) -> Option<String> {
149 let path_str = args["file_path"]
150 .as_str()
151 .or_else(|| args["path"].as_str())
152 .unwrap_or("");
153 if path_str.is_empty() {
154 return Some("Missing 'file_path' argument.".into());
155 }
156
157 let resolved = match safe_resolve_path(project_root, path_str) {
158 Ok(p) => p,
159 Err(e) => return Some(format!("Invalid path: {e}")),
160 };
161
162 let replacements = match args["replacements"].as_array() {
163 Some(arr) if !arr.is_empty() => arr,
164 Some(_) => return Some("'replacements' array is empty.".into()),
165 None => return Some("Missing 'replacements' argument.".into()),
166 };
167
168 let content = match tokio::fs::read_to_string(&resolved).await {
170 Ok(c) => c,
171 Err(e) => {
172 return Some(format!(
173 "Cannot read '{}': {e}. Use Write to create new files.",
174 path_str
175 ));
176 }
177 };
178
179 if let Some(cache) = read_cache
183 && let Ok(meta) = tokio::fs::metadata(&resolved).await
184 {
185 let current_mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
186 let cache_key = format!("{}:None:None", resolved.display());
187 let cached_mtime = cache
188 .lock()
189 .unwrap_or_else(|e| e.into_inner())
190 .get(&cache_key)
191 .map(|(_, mtime, _)| *mtime);
192 if let Some(cm) = cached_mtime
193 && cm != current_mtime
194 {
195 let hint = writer_hint(&resolved, last_writer, last_bash);
198 return Some(format!(
199 "File '{path_str}' has been modified on disk since you last read it{hint}. \
200 Read it again to get the current content before editing.",
201 ));
202 }
203 }
204
205 for (i, replacement) in replacements.iter().enumerate() {
207 let old_str = match replacement["old_str"].as_str() {
208 Some(s) if !s.is_empty() => s,
209 Some(_) => {
210 return Some(format!("Replacement {i}: 'old_str' cannot be empty."));
211 }
212 None => {
213 return Some(format!("Replacement {i}: missing 'old_str'."));
214 }
215 };
216
217 let new_str = match replacement["new_str"].as_str() {
218 Some(s) => s,
219 None => {
220 return Some(format!("Replacement {i}: missing 'new_str'."));
221 }
222 };
223
224 if let Some(msg) = detect_new_omission_placeholder(old_str, new_str, i) {
228 return Some(msg);
229 }
230
231 if !content.contains(old_str) {
232 let ranges = super::fuzzy::fuzzy_match_ranges(old_str, &content);
234 if ranges.is_empty() {
235 return Some(format!(
236 "Replacement {i}: 'old_str' not found in '{}'. \
237 Read the file first to get the exact text.",
238 path_str
239 ));
240 }
241 if ranges.len() > 1 {
244 return Some(format!(
245 "Replacement {i}: 'old_str' is ambiguous — {} fuzzy matches in '{}'. \
246 Use a more specific snippet.",
247 ranges.len(),
248 path_str
249 ));
250 }
251 }
252 }
253
254 None
255}
256
257async fn validate_write(args: &serde_json::Value, project_root: &Path) -> Option<String> {
259 let path_str = args["file_path"]
260 .as_str()
261 .or_else(|| args["path"].as_str())
262 .unwrap_or("");
263 if path_str.is_empty() {
264 return Some("Missing 'file_path' argument.".into());
265 }
266
267 if args["content"].as_str().is_none() {
268 return Some("Missing 'content' argument.".into());
269 }
270
271 let resolved = match safe_resolve_path(project_root, path_str) {
272 Ok(p) => p,
273 Err(e) => return Some(format!("Invalid path: {e}")),
274 };
275
276 let overwrite = args["overwrite"].as_bool().unwrap_or(false);
277 if resolved.exists() && !overwrite {
278 return Some(format!(
279 "File '{}' already exists. Set overwrite=true to replace it, \
280 or use Edit for targeted changes.",
281 path_str
282 ));
283 }
284
285 None
286}
287
288async fn validate_delete(args: &serde_json::Value, project_root: &Path) -> Option<String> {
290 let path_str = args["file_path"]
291 .as_str()
292 .or_else(|| args["path"].as_str())
293 .unwrap_or("");
294 if path_str.is_empty() {
295 return Some("Missing 'file_path' argument.".into());
296 }
297
298 let resolved = match safe_resolve_path(project_root, path_str) {
299 Ok(p) => p,
300 Err(e) => return Some(format!("Invalid path: {e}")),
301 };
302
303 if !resolved.exists() {
304 return Some(format!("Path not found: '{path_str}'. Nothing to delete."));
305 }
306
307 if resolved.is_dir() {
309 let is_empty = resolved
310 .read_dir()
311 .map(|mut d| d.next().is_none())
312 .unwrap_or(false);
313 let recursive = args["recursive"].as_bool().unwrap_or(false);
314 if !is_empty && !recursive {
315 return Some(format!(
316 "Directory '{}' is not empty. Set recursive=true to delete it.",
317 path_str
318 ));
319 }
320 }
321
322 None
323}
324
325fn validate_bash(args: &serde_json::Value) -> Option<String> {
327 let cmd = args["command"]
328 .as_str()
329 .or_else(|| args["cmd"].as_str())
330 .unwrap_or("");
331 if cmd.trim().is_empty() {
332 return Some("Missing or empty 'command' argument.".into());
333 }
334 None
335}
336
337const OMISSION_PREFIXES: &[&str] = &[
341 "rest of",
342 "rest of code",
343 "rest of method",
344 "rest of methods",
345 "rest of file",
346 "rest of function",
347 "rest of implementation",
348 "existing code",
349 "existing implementation",
350 "unchanged code",
351 "unchanged method",
352 "unchanged methods",
353 "remaining code",
354 "remaining implementation",
355];
356
357fn detect_new_omission_placeholder(
361 old_str: &str,
362 new_str: &str,
363 replacement_idx: usize,
364) -> Option<String> {
365 let new_placeholders = detect_omission_placeholders(new_str);
366 if new_placeholders.is_empty() {
367 return None;
368 }
369 let old_set: HashSet<String> = detect_omission_placeholders(old_str).into_iter().collect();
372 for p in &new_placeholders {
373 if !old_set.contains(p) {
374 return Some(format!(
375 "Replacement {replacement_idx}: 'new_str' contains an omission placeholder \
376 ('{p}'). Write the actual code instead of abbreviating with comments."
377 ));
378 }
379 }
380 None
381}
382
383fn detect_omission_placeholders(text: &str) -> Vec<String> {
393 let mut found = Vec::new();
394 for line in text.lines() {
395 if let Some(normalized) = normalize_placeholder_line(line) {
396 found.push(normalized);
397 }
398 }
399 found
400}
401
402fn normalize_placeholder_line(line: &str) -> Option<String> {
407 let mut text = line.trim();
408 if text.is_empty() {
409 return None;
410 }
411
412 if let Some(rest) = text.strip_prefix("//") {
414 text = rest.trim();
415 } else if let Some(rest) = text.strip_prefix('#') {
416 text = rest.trim();
417 }
418
419 if text.starts_with('(') && text.ends_with(')') {
421 text = &text[1..text.len() - 1];
422 text = text.trim();
423 }
424
425 let ellipsis_pos = text.find("...")?;
427 let prefix = text[..ellipsis_pos].trim();
428 let suffix = text[ellipsis_pos + 3..].trim();
429
430 if !suffix.is_empty() && !suffix.chars().all(|c| c == '.') {
432 return None;
433 }
434
435 let normalized: String = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
437 let lower = normalized.to_lowercase();
438
439 if OMISSION_PREFIXES.contains(&lower.as_str()) {
440 Some(format!("{lower} ..."))
441 } else {
442 None
443 }
444}
445
446#[cfg(test)]
449mod tests {
450 use super::*;
451 use serde_json::json;
452 use tempfile::TempDir;
453
454 fn setup() -> TempDir {
455 let dir = TempDir::new().unwrap();
456 std::fs::write(
457 dir.path().join("hello.txt"),
458 "line one\nline two\nline three\n",
459 )
460 .unwrap();
461 std::fs::create_dir(dir.path().join("subdir")).unwrap();
462 std::fs::write(dir.path().join("subdir/nested.txt"), "nested").unwrap();
463 dir
464 }
465
466 #[tokio::test]
469 async fn edit_valid_replacement() {
470 let dir = setup();
471 let args = json!({
472 "path": "hello.txt",
473 "replacements": [{"old_str": "line two", "new_str": "line TWO"}]
474 });
475 assert!(
476 validate_edit(&args, dir.path(), None, None, None)
477 .await
478 .is_none()
479 );
480 }
481
482 #[tokio::test]
483 async fn edit_missing_path() {
484 let dir = setup();
485 let args = json!({"replacements": [{"old_str": "x", "new_str": "y"}]});
486 let err = validate_edit(&args, dir.path(), None, None, None)
487 .await
488 .unwrap();
489 assert!(err.contains("path"), "{err}");
490 }
491
492 #[tokio::test]
493 async fn edit_file_not_found() {
494 let dir = setup();
495 let args = json!({
496 "path": "nope.txt",
497 "replacements": [{"old_str": "x", "new_str": "y"}]
498 });
499 let err = validate_edit(&args, dir.path(), None, None, None)
500 .await
501 .unwrap();
502 assert!(err.contains("Cannot read"), "{err}");
503 assert!(err.contains("Write"), "{err}"); }
505
506 #[tokio::test]
507 async fn edit_empty_replacements() {
508 let dir = setup();
509 let args = json!({"path": "hello.txt", "replacements": []});
510 let err = validate_edit(&args, dir.path(), None, None, None)
511 .await
512 .unwrap();
513 assert!(err.contains("empty"), "{err}");
514 }
515
516 #[tokio::test]
517 async fn edit_empty_old_str() {
518 let dir = setup();
519 let args = json!({
520 "path": "hello.txt",
521 "replacements": [{"old_str": "", "new_str": "y"}]
522 });
523 let err = validate_edit(&args, dir.path(), None, None, None)
524 .await
525 .unwrap();
526 assert!(err.contains("empty"), "{err}");
527 }
528
529 #[tokio::test]
530 async fn edit_old_str_fuzzy_match_passes_validation() {
531 let dir = TempDir::new().unwrap();
533 std::fs::write(
534 dir.path().join("hello.txt"),
535 "line one \nline two \nline three\n",
536 )
537 .unwrap();
538 let args = json!({
539 "path": "hello.txt",
540 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
541 });
542 assert!(
543 validate_edit(&args, dir.path(), None, None, None)
544 .await
545 .is_none(),
546 "fuzzy match should pass validation"
547 );
548 }
549
550 #[tokio::test]
551 async fn edit_old_str_not_found() {
552 let dir = setup();
553 let args = json!({
554 "path": "hello.txt",
555 "replacements": [{"old_str": "does not exist", "new_str": "y"}]
556 });
557 let err = validate_edit(&args, dir.path(), None, None, None)
558 .await
559 .unwrap();
560 assert!(err.contains("not found"), "{err}");
561 }
562
563 #[tokio::test]
564 async fn edit_missing_new_str() {
565 let dir = setup();
566 let args = json!({
567 "path": "hello.txt",
568 "replacements": [{"old_str": "line one"}]
569 });
570 let err = validate_edit(&args, dir.path(), None, None, None)
571 .await
572 .unwrap();
573 assert!(err.contains("new_str"), "{err}");
574 }
575
576 #[tokio::test]
579 async fn write_new_file_valid() {
580 let dir = setup();
581 let args = json!({"path": "brand_new.txt", "content": "hello"});
582 assert!(validate_write(&args, dir.path()).await.is_none());
583 }
584
585 #[tokio::test]
586 async fn write_existing_without_overwrite() {
587 let dir = setup();
588 let args = json!({"path": "hello.txt", "content": "replaced"});
589 let err = validate_write(&args, dir.path()).await.unwrap();
590 assert!(err.contains("already exists"), "{err}");
591 assert!(err.contains("overwrite=true"), "{err}");
592 }
593
594 #[tokio::test]
595 async fn write_existing_with_overwrite() {
596 let dir = setup();
597 let args = json!({"path": "hello.txt", "content": "replaced", "overwrite": true});
598 assert!(validate_write(&args, dir.path()).await.is_none());
599 }
600
601 #[tokio::test]
602 async fn write_missing_content() {
603 let dir = setup();
604 let args = json!({"path": "foo.txt"});
605 let err = validate_write(&args, dir.path()).await.unwrap();
606 assert!(err.contains("content"), "{err}");
607 }
608
609 #[tokio::test]
612 async fn delete_valid_file() {
613 let dir = setup();
614 let args = json!({"path": "hello.txt"});
615 assert!(validate_delete(&args, dir.path()).await.is_none());
616 }
617
618 #[tokio::test]
619 async fn delete_not_found() {
620 let dir = setup();
621 let args = json!({"path": "nope.txt"});
622 let err = validate_delete(&args, dir.path()).await.unwrap();
623 assert!(err.contains("not found"), "{err}");
624 }
625
626 #[tokio::test]
627 async fn delete_nonempty_dir_without_recursive() {
628 let dir = setup();
629 let args = json!({"path": "subdir"});
630 let err = validate_delete(&args, dir.path()).await.unwrap();
631 assert!(err.contains("recursive"), "{err}");
632 }
633
634 #[tokio::test]
635 async fn delete_nonempty_dir_with_recursive() {
636 let dir = setup();
637 let args = json!({"path": "subdir", "recursive": true});
638 assert!(validate_delete(&args, dir.path()).await.is_none());
639 }
640
641 #[tokio::test]
644 async fn edit_accepts_file_path_param() {
645 let dir = setup();
646 let args = json!({
647 "file_path": "hello.txt",
648 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
649 });
650 assert!(
651 validate_edit(&args, dir.path(), None, None, None)
652 .await
653 .is_none()
654 );
655 }
656
657 #[tokio::test]
658 async fn write_accepts_file_path_param() {
659 let dir = setup();
660 let args = json!({"file_path": "brand_new.txt", "content": "hello"});
661 assert!(validate_write(&args, dir.path()).await.is_none());
662 }
663
664 #[tokio::test]
665 async fn delete_accepts_file_path_param() {
666 let dir = setup();
667 let args = json!({"file_path": "hello.txt"});
668 assert!(validate_delete(&args, dir.path()).await.is_none());
669 }
670
671 #[test]
674 fn bash_valid_command() {
675 let args = json!({"command": "echo hello"});
676 assert!(validate_bash(&args).is_none());
677 }
678
679 #[test]
680 fn bash_empty_command() {
681 let args = json!({"command": ""});
682 assert!(validate_bash(&args).unwrap().contains("empty"));
683 }
684
685 #[test]
686 fn bash_missing_command() {
687 let args = json!({});
688 assert!(validate_bash(&args).unwrap().contains("empty"));
689 }
690
691 #[test]
692 fn bash_cmd_alias() {
693 let args = json!({"cmd": "ls"});
694 assert!(validate_bash(&args).is_none());
695 }
696
697 fn make_cache(path: &std::path::Path, mtime: SystemTime) -> super::super::FileReadCache {
700 let cache = super::super::FileReadCache::default();
701 let key = format!("{}:None:None", path.display());
702 cache.lock().unwrap().insert(key, (0, mtime, String::new()));
703 cache
704 }
705
706 #[tokio::test]
707 async fn edit_stale_file_detected() {
708 let dir = setup();
709 let file = dir.path().join("hello.txt");
710 let cache = make_cache(&file, SystemTime::UNIX_EPOCH);
712 let args = json!({
713 "path": "hello.txt",
714 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
715 });
716 let err = validate_edit(&args, dir.path(), Some(&cache), None, None)
717 .await
718 .unwrap();
719 assert!(err.contains("modified on disk"), "{err}");
720 assert!(err.contains("Read it again"), "{err}");
721 }
722
723 #[tokio::test]
724 async fn edit_fresh_file_no_stale_warning() {
725 let dir = setup();
726 let file = dir.path().join("hello.txt");
727 let current_mtime = std::fs::metadata(&file).unwrap().modified().unwrap();
729 let cache = make_cache(&file, current_mtime);
730 let args = json!({
731 "path": "hello.txt",
732 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
733 });
734 assert!(
735 validate_edit(&args, dir.path(), Some(&cache), None, None)
736 .await
737 .is_none(),
738 "up-to-date file should not trigger stale warning"
739 );
740 }
741
742 #[tokio::test]
743 async fn edit_no_cache_entry_no_stale_warning() {
744 let dir = setup();
746 let empty_cache = super::super::FileReadCache::default();
747 let args = json!({
748 "path": "hello.txt",
749 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
750 });
751 assert!(
752 validate_edit(&args, dir.path(), Some(&empty_cache), None, None)
753 .await
754 .is_none(),
755 "no cache entry should not trigger stale warning"
756 );
757 }
758
759 #[tokio::test]
762 async fn stale_file_hints_last_writer_tool() {
763 let dir = setup();
764 let file = dir.path().join("hello.txt");
765 let cache = make_cache(&file, SystemTime::UNIX_EPOCH);
766
767 let last_writer = super::super::LastWriterCache::default();
769 last_writer.lock().unwrap().insert(
770 file.clone(),
771 ("Edit".to_string(), std::time::Instant::now()),
772 );
773
774 let args = json!({
775 "path": "hello.txt",
776 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
777 });
778 let err = validate_edit(&args, dir.path(), Some(&cache), Some(&last_writer), None)
779 .await
780 .unwrap();
781 assert!(err.contains("modified on disk"), "{err}");
782 assert!(err.contains("last written by Edit"), "{err}");
783 }
784
785 #[tokio::test]
786 async fn stale_file_hints_bash_when_no_writer_entry() {
787 let dir = setup();
788 let file = dir.path().join("hello.txt");
789 let cache = make_cache(&file, SystemTime::UNIX_EPOCH);
790
791 let last_writer = super::super::LastWriterCache::default();
793 let last_bash = super::super::LastBashCache::default();
794 *last_bash.lock().unwrap() = Some((
795 "cargo fmt -- src/bash_safety.rs".to_string(),
796 std::time::Instant::now(),
797 ));
798
799 let args = json!({
800 "path": "hello.txt",
801 "replacements": [{"old_str": "line two", "new_str": "LINE TWO"}]
802 });
803 let err = validate_edit(
804 &args,
805 dir.path(),
806 Some(&cache),
807 Some(&last_writer),
808 Some(&last_bash),
809 )
810 .await
811 .unwrap();
812 assert!(err.contains("modified on disk"), "{err}");
813 assert!(err.contains("Bash ran"), "{err}");
814 assert!(err.contains("cargo fmt"), "{err}");
815 }
816
817 #[test]
820 fn omission_detects_comment_style() {
821 let cases = vec![
822 "// rest of code ...",
823 "// rest of methods ...",
824 "# rest of implementation ...",
825 "// unchanged code ...",
826 "# existing code ...",
827 "// remaining code ...",
828 ];
829 for input in cases {
830 let found = detect_omission_placeholders(input);
831 assert!(!found.is_empty(), "should detect: {input}");
832 }
833 }
834
835 #[test]
836 fn omission_detects_paren_style() {
837 let found = detect_omission_placeholders("(rest of code ...)");
838 assert_eq!(found.len(), 1);
839 assert_eq!(found[0], "rest of code ...");
840 }
841
842 #[test]
843 fn omission_detects_comment_plus_parens() {
844 let found = detect_omission_placeholders("// (existing implementation ...)");
845 assert_eq!(found.len(), 1);
846 }
847
848 #[test]
849 fn omission_ignores_normal_code() {
850 let cases = vec![
851 "let x = 42;",
852 "// TODO: fix this later",
853 "# This is a normal comment",
854 "fn rest_of_things() {}",
855 "use std::rest::of::things;",
856 "println!(\"...\");", "// See the rest of the docs at ...", ];
859 for input in cases {
860 let found = detect_omission_placeholders(input);
861 assert!(found.is_empty(), "false positive on: {input}");
862 }
863 }
864
865 #[test]
866 fn omission_case_insensitive() {
867 let found = detect_omission_placeholders("// Rest Of Code ...");
868 assert_eq!(found.len(), 1);
869 assert_eq!(found[0], "rest of code ...");
870 }
871
872 #[test]
873 fn omission_extra_dots_ok() {
874 let found = detect_omission_placeholders("// rest of code ......");
876 assert_eq!(found.len(), 1);
877 }
878
879 #[test]
880 fn omission_suffix_text_rejects() {
881 let found = detect_omission_placeholders("// rest of code ... here");
883 assert!(found.is_empty());
884 }
885
886 #[test]
887 fn omission_preserving_existing_placeholder_is_fine() {
888 let old = "fn foo() {\n // rest of code ...\n}";
890 let new = "fn foo() {\n do_thing();\n // rest of code ...\n}";
891 assert!(detect_new_omission_placeholder(old, new, 0).is_none());
892 }
893
894 #[test]
895 fn omission_introducing_new_placeholder_rejected() {
896 let old = "fn foo() {\n real_code();\n more_code();\n}";
897 let new = "fn foo() {\n real_code();\n // rest of code ...\n}";
898 let err = detect_new_omission_placeholder(old, new, 0).unwrap();
899 assert!(err.contains("omission placeholder"), "{err}");
900 assert!(err.contains("actual code"), "{err}");
901 }
902
903 #[tokio::test]
904 async fn edit_rejects_omission_in_new_str() {
905 let dir = setup();
906 let args = json!({
907 "path": "hello.txt",
908 "replacements": [{
909 "old_str": "line two",
910 "new_str": "// rest of code ..."
911 }]
912 });
913 let err = validate_edit(&args, dir.path(), None, None, None)
914 .await
915 .unwrap();
916 assert!(err.contains("omission placeholder"), "{err}");
917 }
918
919 #[tokio::test]
920 async fn edit_allows_normal_new_str() {
921 let dir = setup();
922 let args = json!({
923 "path": "hello.txt",
924 "replacements": [{
925 "old_str": "line two",
926 "new_str": "line TWO\n// This comment has dots: ..."
927 }]
928 });
929 assert!(
931 validate_edit(&args, dir.path(), None, None, None)
932 .await
933 .is_none()
934 );
935 }
936
937 #[test]
940 fn fmt_age_under_5s_is_just_now() {
941 assert_eq!(fmt_age(std::time::Duration::from_secs(0)), "just now");
942 assert_eq!(fmt_age(std::time::Duration::from_secs(4)), "just now");
943 }
944
945 #[test]
946 fn fmt_age_exactly_5s() {
947 assert_eq!(fmt_age(std::time::Duration::from_secs(5)), "5s ago");
949 }
950
951 #[test]
952 fn fmt_age_under_60s() {
953 assert_eq!(fmt_age(std::time::Duration::from_secs(30)), "30s ago");
954 assert_eq!(fmt_age(std::time::Duration::from_secs(59)), "59s ago");
955 }
956
957 #[test]
958 fn fmt_age_exactly_60s() {
959 assert_eq!(fmt_age(std::time::Duration::from_secs(60)), "1m ago");
961 }
962
963 #[test]
964 fn fmt_age_minutes() {
965 assert_eq!(fmt_age(std::time::Duration::from_secs(90)), "1m ago");
966 assert_eq!(fmt_age(std::time::Duration::from_secs(120)), "2m ago");
967 assert_eq!(fmt_age(std::time::Duration::from_secs(3600)), "60m ago");
968 }
969}