Skip to main content

koda_core/tools/
validate.rs

1//! Pre-flight validation for tool calls.
2//!
3//! Runs **before** the approval prompt so we never ask the user to approve
4//! an operation that will inevitably fail. Cheap checks only — no mutations.
5//!
6//! ## What it validates
7//!
8//! - **Write**: target path resolves within project root
9//! - **Write (overwrite)**: file exists and `overwrite: true` is set
10//! - **Edit**: file exists, `old_str` is found, `old_str` is unique
11//!   (unless `replace_all: true`), `new_str` does not contain omission
12//!   placeholders (e.g. `// rest of code ...`)
13//! - **Delete**: file exists
14//! - **Bash**: command is non-empty
15//!
16//! Validation errors are returned as tool results (not panics), so the
17//! model sees the error and can self-correct.
18
19use super::safe_resolve_path;
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24/// Format a duration into a compact human-readable age string.
25///
26/// ```text
27/// <5s  → "just now"
28/// <60s → "12s ago"
29/// else → "3m ago"
30/// ```
31fn 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
42/// Validate a tool call before approval.
43///
44/// `read_cache` is the session file-read cache.  When provided, `validate_edit`
45/// uses it to detect files that have been modified on disk since the model last
46/// read them — catching the most common source of lost-context edits.
47///
48/// `last_writer` and `last_bash` add context to staleness errors: instead of a
49/// generic "file was modified" message the model sees which tool was responsible
50/// and how long ago it ran (#804 item 7).
51///
52/// Returns `None` if the call looks valid, or `Some(error_message)` describing
53/// why it will fail. The error message is fed back to the model so it can
54/// self-correct without consuming an approval prompt.
55pub 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
72/// DRY wrapper: pull the three caches from a `ToolRegistry` and run
73/// the standard pre-flight validation against the given root.
74///
75/// Three call sites used to inline the same 9-line cache-pull-then-
76/// call-validate dance:
77///
78///   * `tool_dispatch::validate_then_execute_one_tool` (parallel +
79///     split-batch arms, validates after auto-approval)
80///   * `tool_dispatch::execute_tools_sequential` (sequential arm,
81///     validates before approval prompting so we don't bother the
82///     user with doomed prompts)
83///   * `sub_agent_dispatch::execute_sub_agent` (sub-agent loop,
84///     validates before per-tool approval branch)
85///
86/// `project_root` is taken explicitly because the sub-agent path
87/// validates against its (possibly worktree-shifted) effective root,
88/// not the registry's `project_root`. Everything else is mechanical
89/// cache plumbing that's identical across call sites.
90pub 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
110/// Build a parenthetical hint describing what tool last modified a file.
111///
112/// Priority:
113/// 1. A recorded Write/Edit entry for this exact path  → " (last written by Edit 3s ago)"
114/// 2. A recent Bash invocation (possible indirect modifier) → " (Bash ran 2s ago: `cargo fmt ...`)"
115/// 3. Neither known                                        → "" (empty; generic message is fine)
116fn writer_hint(
117    resolved: &PathBuf,
118    last_writer: Option<&super::LastWriterCache>,
119    last_bash: Option<&super::LastBashCache>,
120) -> String {
121    // Check for a direct Write/Edit record for this path.
122    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    // Fall back to the most recent Bash call — it may have modified the file
129    // indirectly (formatter, build script, etc.).
130    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
139/// Edit: file must exist, replacements must be non-empty, each old_str must
140/// be non-empty and actually present in the file.  Also warns if the file has
141/// been modified on disk since the model last read it.
142async 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    // File must exist (Edit is not Write)
169    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    // Stale-file detection: warn if the file has been modified on disk since
180    // the model last read it.  Only fires when a full-read cache entry exists
181    // and the current mtime differs from the cached one.
182    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            // Build a context hint naming the responsible tool so the model
196            // doesn't have to guess why the file changed (#804 item 7).
197            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    // Validate each replacement
206    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        // Omission placeholder detection: catch lazy model output like
225        // "// rest of code ..." or "(unchanged methods ...)" before it
226        // silently replaces real code with a comment.
227        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            // Try fuzzy (whitespace-normalized) before hard-failing.
233            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            // 2+ fuzzy matches is also a problem — flag it now so the model
242            // can tighten the snippet before burning an approval prompt.
243            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
257/// Write: catch overwrite-without-flag before approval.
258async 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
288/// Delete: path must exist.
289async 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    // Non-empty dir without recursive flag
308    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
325/// Bash: command must be non-empty.
326fn 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
337// ── Omission placeholder detection ────────────────────────────
338
339/// Known omission phrase prefixes (lowercase, before the `...`).
340const 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
357/// Check if `new_str` introduces an omission placeholder not present in `old_str`.
358///
359/// Returns an error message if the model is being lazy, or `None` if clean.
360fn 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    // If old_str already had the same placeholder, the model is preserving
370    // an existing comment — that's fine.
371    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
383/// Scan text for lines that look like omission placeholders.
384///
385/// Recognized patterns:
386/// - `// rest of code ...`
387/// - `# unchanged methods ...`
388/// - `(rest of implementation ...)`
389/// - `// (existing code ...)`
390///
391/// Returns normalized placeholder strings (e.g. `"rest of code ..."`).
392fn 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
402/// Try to parse a single line as an omission placeholder.
403///
404/// Strips comment prefixes (`//`, `#`), optional parentheses, then checks
405/// for a known phrase followed by `...`.
406fn normalize_placeholder_line(line: &str) -> Option<String> {
407    let mut text = line.trim();
408    if text.is_empty() {
409        return None;
410    }
411
412    // Strip comment prefix
413    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    // Strip optional parentheses: (rest of code ...)
420    if text.starts_with('(') && text.ends_with(')') {
421        text = &text[1..text.len() - 1];
422        text = text.trim();
423    }
424
425    // Must contain "..."
426    let ellipsis_pos = text.find("...")?;
427    let prefix = text[..ellipsis_pos].trim();
428    let suffix = text[ellipsis_pos + 3..].trim();
429
430    // Suffix must be empty or all dots ("...." is fine)
431    if !suffix.is_empty() && !suffix.chars().all(|c| c == '.') {
432        return None;
433    }
434
435    // Normalize whitespace in prefix and check against known phrases
436    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// ── Tests ─────────────────────────────────────────────────────
447
448#[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    // ── Edit validation ───────────────────────────────────────
467
468    #[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}"); // suggests Write
504    }
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        // File has trailing spaces; model sends clean needle — should pass pre-flight.
532        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    // ── Write validation ──────────────────────────────────────
577
578    #[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    // ── Delete validation ─────────────────────────────────────
610
611    #[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    // ── file_path alias acceptance ──────────────────────────
642
643    #[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    // ── Bash validation ───────────────────────────────────────
672
673    #[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    // ── Stale-file detection ──────────────────────────────────
698
699    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        // Populate cache with a deliberately old mtime (epoch = definitely stale).
711        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        // Populate cache with the real current mtime.
728        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        // File was never read via Read tool — empty cache, no stale warning.
745        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    // ── Staleness hint messages (#804 item 7) ─────────────────
760
761    #[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        // Populate last_writer with an Edit entry for this file.
768        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        // No last_writer entry for this file — fall through to last_bash.
792        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    // ── Omission placeholder detection ────────────────────────
818
819    #[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!(\"...\");", // "..." not after a known prefix
857            "// See the rest of the docs at ...", // not a known prefix
858        ];
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        // "// rest of code ......" should still match
875        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        // "// rest of code ... here" has non-dot suffix — not a placeholder
882        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        // old_str already has the placeholder — model is preserving, not creating.
889        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        // "..." without a known prefix should NOT be detected
930        assert!(
931            validate_edit(&args, dir.path(), None, None, None)
932                .await
933                .is_none()
934        );
935    }
936
937    // ── fmt_age boundary values (#819) ───────────────────────
938
939    #[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        // Boundary: 5s is the first value that produces "Xs ago".
948        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        // Boundary: 60s is the first value that produces "Xm ago".
960        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}