Skip to main content

locode_packs/claude/
mod.rs

1//! The `claude` pack — a faithful port of Claude Code's headless-relevant toolset
2//! plus its static system prompt (ADR-0012, ADR-0023), over `locode-host`. The
3//! highest-value A/B counterpart to the grok pack: same engine, same wire,
4//! genuinely different tool surface.
5//!
6//! Complete (Task 20, 7 slices): all six tools — `Bash`, `Read`, `Edit`, `Write`
7//! (the middle three sharing the `ClaudeSessionState` read-before-write + staleness
8//! gate), `Glob`, and `Grep` — plus the full byte-exact static system prompt (all
9//! sections + env block + the `currentDate` first-turn reminder). See
10//! `docs/claude-pack-dev-process.md`.
11//!
12//! Fidelity boundary (ADR-0023): the pack reproduces tools + prompt + static
13//! preamble only. Loop-adjacent machinery (project-instruction loading, reminder
14//! re-injection, compaction, subagents) stays on the shared engine.
15
16mod bash;
17mod edit;
18mod glob;
19mod grep;
20pub mod prompt;
21mod read;
22mod state;
23mod write;
24
25use std::sync::Arc;
26
27use locode_host::Host;
28use locode_protocol::{ContentBlock, Message, Role};
29use locode_tools::Registry;
30
31use crate::pack::{Pack, PackContext};
32use bash::ClaudeBash;
33use edit::ClaudeEdit;
34use glob::ClaudeGlob;
35use grep::ClaudeGrep;
36use read::ClaudeRead;
37use state::ClaudeSessionState;
38use write::ClaudeWrite;
39
40/// The Claude Code harness pack (a zero-sized `&'static` singleton; the per-run
41/// `ClaudeSessionState` lives in the tools that share it, constructed in `register`).
42#[derive(Debug, Default, Clone, Copy)]
43pub struct ClaudePack;
44
45impl Pack for ClaudePack {
46    fn name(&self) -> &'static str {
47        "claude"
48    }
49
50    fn register(&self, host: &Arc<Host>, registry: &mut Registry) {
51        // Per-run freshness store (CC's per-session `readFileState`): Read records
52        // into it; Edit/Write (S3/S4) gate on it. Cloned into each tool that shares it.
53        let state = Arc::new(ClaudeSessionState::default());
54        // Claude Code's exact UpperCamelCase wire names (contrast grok's snake_case).
55        registry.register("Bash", ClaudeBash::new(Arc::clone(host)));
56        registry.register(
57            "Read",
58            ClaudeRead::new(Arc::clone(host), Arc::clone(&state)),
59        );
60        registry.register(
61            "Edit",
62            ClaudeEdit::new(Arc::clone(host), Arc::clone(&state)),
63        );
64        registry.register(
65            "Write",
66            ClaudeWrite::new(Arc::clone(host), Arc::clone(&state)),
67        );
68        registry.register("Glob", ClaudeGlob::new(Arc::clone(host)));
69        registry.register("Grep", ClaudeGrep::new(Arc::clone(host)));
70    }
71
72    fn preamble(&self, ctx: &PackContext) -> Vec<Message> {
73        // CC's real split (ADR-0013, D10): the full system prompt (identity + static
74        // sections + env) is the System message; the first-turn `currentDate`
75        // `<system-reminder>` (`prependUserContext`) is a `User` message. CC sends
76        // the raw user prompt after it (no wrapper) — see `shape_user_prompt`.
77        vec![
78            Message {
79                role: Role::System,
80                content: vec![ContentBlock::Text {
81                    text: prompt::render(ctx),
82                }],
83            },
84            Message {
85                role: Role::User,
86                content: vec![ContentBlock::Text {
87                    text: prompt::context_reminder(ctx),
88                }],
89            },
90        ]
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use locode_host::HostConfig;
98    use locode_protocol::ResultChunk;
99    use locode_tools::ToolCtx;
100    use serde_json::json;
101    use std::path::Path;
102    use tokio_util::sync::CancellationToken;
103
104    /// A claude registry over a fresh temp workspace; `bash -c` (non-login) keeps
105    /// login-profile output out of the captured streams. Returns the host's
106    /// canonical root (the jail root the `ToolCtx.cwd` must match).
107    fn setup() -> (tempfile::TempDir, Registry, std::path::PathBuf) {
108        let dir = tempfile::tempdir().unwrap();
109        let mut config = HostConfig::new(dir.path());
110        config.login_shell = false;
111        let host = Arc::new(Host::new(config).unwrap());
112        let root = host.workspace_root().to_path_buf();
113        let registry = ClaudePack.build_registry(&host);
114        (dir, registry, root)
115    }
116
117    fn ctx(dir: &Path) -> ToolCtx {
118        ToolCtx::new(
119            dir.to_path_buf(),
120            "c1".into(),
121            dir.to_path_buf(),
122            CancellationToken::new(),
123        )
124    }
125
126    fn result_text(block: &ContentBlock) -> String {
127        match block {
128            ContentBlock::ToolResult { content, .. } => content
129                .iter()
130                .filter_map(|chunk| match chunk {
131                    ResultChunk::Text { text } => Some(text.clone()),
132                    ResultChunk::Image { .. } => None,
133                })
134                .collect(),
135            _ => panic!("expected a tool_result"),
136        }
137    }
138
139    fn is_error(block: &ContentBlock) -> bool {
140        matches!(block, ContentBlock::ToolResult { is_error: true, .. })
141    }
142
143    #[test]
144    fn pack_registers_expected_tools_this_slice() {
145        let (_dir, registry, _root) = setup();
146        let mut names: Vec<&str> = registry.names().collect();
147        names.sort_unstable();
148        assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
149        assert_eq!(
150            registry.kind_of("Bash"),
151            Some(locode_tools::ToolKind::Shell)
152        );
153        assert_eq!(registry.kind_of("Read"), Some(locode_tools::ToolKind::Read));
154        assert_eq!(registry.kind_of("Edit"), Some(locode_tools::ToolKind::Edit));
155        assert_eq!(
156            registry.kind_of("Write"),
157            Some(locode_tools::ToolKind::Write)
158        );
159        assert_eq!(registry.kind_of("Glob"), Some(locode_tools::ToolKind::Glob));
160        assert_eq!(registry.kind_of("Grep"), Some(locode_tools::ToolKind::Grep));
161    }
162
163    #[test]
164    fn bash_schema_is_faithful() {
165        let (_dir, registry, _root) = setup();
166        let specs = registry.specs();
167        let bash = specs.iter().find(|s| s.name == "Bash").expect("Bash spec");
168        let params = match &bash.input {
169            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
170            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Bash is JSON-schema"),
171        };
172        // z.strictObject → additionalProperties:false (deny_unknown_fields).
173        assert_eq!(params["additionalProperties"], json!(false));
174        let props = params["properties"].as_object().unwrap();
175        // Present: command, timeout, description (verbatim field descriptions).
176        assert_eq!(
177            props["command"]["description"],
178            json!("The command to execute")
179        );
180        assert_eq!(
181            props["timeout"]["description"],
182            json!("Optional timeout in milliseconds (max 600000)")
183        );
184        assert!(
185            props["description"]["description"]
186                .as_str()
187                .unwrap()
188                .starts_with("Clear, concise description of what this command does")
189        );
190        // Absent (faithfully omitted): background/sandbox/internal fields.
191        for absent in [
192            "run_in_background",
193            "dangerouslyDisableSandbox",
194            "_simulatedSedEdit",
195        ] {
196            assert!(!props.contains_key(absent), "{absent} must be absent");
197        }
198        // Only `command` is required.
199        let required: Vec<&str> = params["required"]
200            .as_array()
201            .unwrap()
202            .iter()
203            .map(|v| v.as_str().unwrap())
204            .collect();
205        assert_eq!(required, vec!["command"]);
206    }
207
208    #[test]
209    fn shape_user_prompt_is_verbatim_for_claude() {
210        // CC sends the raw prompt (no <user_query> wrapper); grok wraps it.
211        assert_eq!(ClaudePack.shape_user_prompt("do the thing"), "do the thing");
212        assert_ne!(
213            crate::GrokPack.shape_user_prompt("do the thing"),
214            "do the thing"
215        );
216    }
217
218    #[test]
219    fn preamble_is_system_then_user_reminder() {
220        let dir = tempfile::tempdir().unwrap();
221        let pc = PackContext {
222            cwd: dir.path().to_path_buf(),
223            os: "macos".into(),
224            shell: "/bin/zsh".into(),
225            date: "2026-07-24".into(),
226            headless: true,
227            is_git_repo: false,
228            model: Some("claude-opus-4-8".into()),
229            os_version: Some("Darwin 24.6.0".into()),
230            timezone: None,
231            strip_identity: false,
232        };
233        // D10: [System(prompt+env), User(<system-reminder> currentDate)].
234        let msgs = ClaudePack.preamble(&pc);
235        assert_eq!(msgs.len(), 2);
236        assert_eq!(msgs[0].role, Role::System);
237        assert_eq!(msgs[1].role, Role::User);
238    }
239
240    // ---- Bash behavior (via build_registry + dispatch over a tempdir host) ----
241
242    #[cfg(unix)]
243    #[tokio::test]
244    async fn bash_echo_ok() {
245        let (_dir, registry, root) = setup();
246        let out = registry
247            .dispatch("Bash", json!({ "command": "echo hi" }), &ctx(&root))
248            .await;
249        assert!(out.record.ok);
250        assert!(!is_error(&out.tool_result));
251        assert_eq!(result_text(&out.tool_result), "hi");
252        assert_eq!(out.record.output["exit_code"], json!(0));
253    }
254
255    #[cfg(unix)]
256    #[tokio::test]
257    async fn bash_nonzero_exit_is_soft_ok_with_exit_code_note() {
258        let (_dir, registry, root) = setup();
259        let out = registry
260            .dispatch(
261                "Bash",
262                json!({ "command": "echo oops; exit 3" }),
263                &ctx(&root),
264            )
265            .await;
266        // CC: is_error is false for a non-zero exit; "Exit code N" is appended.
267        assert!(out.record.ok);
268        assert!(!is_error(&out.tool_result));
269        let text = result_text(&out.tool_result);
270        assert_eq!(text, "oops\nExit code 3", "{text}");
271        assert_eq!(out.record.output["exit_code"], json!(3));
272    }
273
274    #[cfg(unix)]
275    #[tokio::test]
276    async fn bash_timeout_is_interrupted_soft_error() {
277        let (_dir, registry, root) = setup();
278        let out = registry
279            .dispatch(
280                "Bash",
281                json!({ "command": "sleep 5", "timeout": 50 }),
282                &ctx(&root),
283            )
284            .await;
285        // CC sets is_error when interrupted (timeout).
286        assert!(is_error(&out.tool_result));
287        assert!(
288            result_text(&out.tool_result).contains("Command was aborted before completion"),
289            "{}",
290            result_text(&out.tool_result)
291        );
292        // The error path carries the message, not a structured output record.
293        assert!(!out.record.ok);
294    }
295
296    #[cfg(unix)]
297    #[tokio::test]
298    async fn bash_large_output_is_middle_truncated() {
299        let (_dir, registry, root) = setup();
300        // ~40k chars of output overflows the 30k maxResultSizeChars cap.
301        let out = registry
302            .dispatch(
303                "Bash",
304                json!({ "command": "for i in $(seq 1 8000); do echo LINE$i; done" }),
305                &ctx(&root),
306            )
307            .await;
308        assert!(out.record.ok);
309        let text = result_text(&out.tool_result);
310        // The tool caps at 30k (front/back over the merged stream); the engine
311        // dispatch-door belt (MODEL_OUTPUT_BUDGET=30k, ADR-0008) truncates on top
312        // and supplies the visible marker. Either way the model gets head + tail
313        // + a truncation marker.
314        assert!(
315            text.contains("truncated"),
316            "carries a truncation marker: {}",
317            &text[..60]
318        );
319        assert!(text.contains("LINE1\n"), "head retained: {}", &text[..40]);
320        assert!(text.contains("LINE8000"), "tail retained");
321        assert_eq!(out.record.output["truncated"], json!(true));
322    }
323
324    // ---- Read behavior ----
325
326    #[tokio::test]
327    async fn read_numbers_lines_cat_n_compact() {
328        let (_dir, registry, root) = setup();
329        std::fs::write(root.join("f.txt"), "alpha\nbeta\ngamma\n").unwrap();
330        let out = registry
331            .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
332            .await;
333        assert!(out.record.ok);
334        // Compact cat -n: `N\tline`, 1-indexed; trailing newline adds no phantom.
335        assert_eq!(result_text(&out.tool_result), "1\talpha\n2\tbeta\n3\tgamma");
336        assert_eq!(out.record.output["lines"], json!(3));
337        assert_eq!(out.record.output["truncated"], json!(false));
338    }
339
340    #[tokio::test]
341    async fn read_offset_and_limit_window() {
342        let (_dir, registry, root) = setup();
343        std::fs::write(root.join("f.txt"), "l1\nl2\nl3\nl4\nl5\n").unwrap();
344        let out = registry
345            .dispatch(
346                "Read",
347                json!({ "file_path": "f.txt", "offset": 2, "limit": 2 }),
348                &ctx(&root),
349            )
350            .await;
351        assert!(out.record.ok);
352        // Absolute line numbers preserved (2,3); window is a subset → truncated.
353        assert_eq!(result_text(&out.tool_result), "2\tl2\n3\tl3");
354        assert_eq!(out.record.output["truncated"], json!(true));
355    }
356
357    #[tokio::test]
358    async fn read_empty_file_warns() {
359        let (_dir, registry, root) = setup();
360        std::fs::write(root.join("e.txt"), "").unwrap();
361        let out = registry
362            .dispatch("Read", json!({ "file_path": "e.txt" }), &ctx(&root))
363            .await;
364        assert!(out.record.ok);
365        assert_eq!(
366            result_text(&out.tool_result),
367            "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>"
368        );
369    }
370
371    #[tokio::test]
372    async fn read_offset_past_eof_warns() {
373        let (_dir, registry, root) = setup();
374        std::fs::write(root.join("f.txt"), "a\nb\n").unwrap();
375        let out = registry
376            .dispatch(
377                "Read",
378                json!({ "file_path": "f.txt", "offset": 99 }),
379                &ctx(&root),
380            )
381            .await;
382        assert!(out.record.ok);
383        assert_eq!(
384            result_text(&out.tool_result),
385            "<system-reminder>Warning: the file exists but is shorter than the provided offset (99). The file has 2 lines.</system-reminder>"
386        );
387    }
388
389    #[tokio::test]
390    async fn read_missing_file_is_soft_error() {
391        let (_dir, registry, root) = setup();
392        let out = registry
393            .dispatch("Read", json!({ "file_path": "nope.txt" }), &ctx(&root))
394            .await;
395        assert!(!out.record.ok);
396        assert!(is_error(&out.tool_result));
397    }
398
399    #[tokio::test]
400    async fn read_dedup_unchanged_then_rereads_after_change() {
401        let (_dir, registry, root) = setup();
402        let p = root.join("f.txt");
403        std::fs::write(&p, "one\ntwo\n").unwrap();
404        // First read: full content.
405        let first = registry
406            .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
407            .await;
408        assert_eq!(result_text(&first.tool_result), "1\tone\n2\ttwo");
409        // Re-read unchanged → stub.
410        let second = registry
411            .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
412            .await;
413        assert!(second.record.ok);
414        assert!(result_text(&second.tool_result).starts_with("File unchanged since last read."));
415        // Change the file (bump mtime well past ms granularity) → full re-read.
416        std::thread::sleep(std::time::Duration::from_millis(20));
417        std::fs::write(&p, "one\ntwo\nthree\n").unwrap();
418        let third = registry
419            .dispatch("Read", json!({ "file_path": "f.txt" }), &ctx(&root))
420            .await;
421        assert_eq!(result_text(&third.tool_result), "1\tone\n2\ttwo\n3\tthree");
422    }
423
424    #[test]
425    fn read_schema_is_faithful() {
426        let (_dir, registry, _root) = setup();
427        let specs = registry.specs();
428        let read = specs.iter().find(|s| s.name == "Read").expect("Read spec");
429        let params = match &read.input {
430            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
431            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Read is JSON-schema"),
432        };
433        assert_eq!(params["additionalProperties"], json!(false));
434        let props = params["properties"].as_object().unwrap();
435        assert_eq!(
436            props["file_path"]["description"],
437            json!("The absolute path to the file to read")
438        );
439        for key in ["file_path", "offset", "limit", "pages"] {
440            assert!(props.contains_key(key), "missing schema field {key}");
441        }
442        let required: Vec<&str> = params["required"]
443            .as_array()
444            .unwrap()
445            .iter()
446            .map(|v| v.as_str().unwrap())
447            .collect();
448        assert_eq!(required, vec!["file_path"]);
449    }
450
451    // ---- Edit: the read-before-edit + staleness gate is the star ----
452
453    async fn read_then(registry: &Registry, root: &Path, file: &str) {
454        let _ = registry
455            .dispatch("Read", json!({ "file_path": file }), &ctx(root))
456            .await;
457    }
458
459    #[tokio::test]
460    async fn edit_requires_prior_read() {
461        let (_dir, registry, root) = setup();
462        std::fs::write(root.join("f.txt"), "alpha beta").unwrap();
463        // No Read first → the gate rejects (errorCode 6).
464        let out = registry
465            .dispatch(
466                "Edit",
467                json!({ "file_path": "f.txt", "old_string": "beta", "new_string": "BETA" }),
468                &ctx(&root),
469            )
470            .await;
471        assert!(!out.record.ok);
472        assert_eq!(
473            result_text(&out.tool_result),
474            "File has not been read yet. Read it first before writing to it."
475        );
476    }
477
478    #[tokio::test]
479    async fn edit_after_read_succeeds_and_sequential_edit_is_fresh() {
480        let (_dir, registry, root) = setup();
481        std::fs::write(root.join("f.txt"), "one two three").unwrap();
482        read_then(&registry, &root, "f.txt").await;
483        let out = registry
484            .dispatch(
485                "Edit",
486                json!({ "file_path": "f.txt", "old_string": "two", "new_string": "TWO" }),
487                &ctx(&root),
488            )
489            .await;
490        assert!(out.record.ok, "{}", result_text(&out.tool_result));
491        assert_eq!(
492            result_text(&out.tool_result),
493            "The file f.txt has been updated successfully."
494        );
495        assert_eq!(
496            std::fs::read_to_string(root.join("f.txt")).unwrap(),
497            "one TWO three"
498        );
499        // A second edit without re-reading is still fresh (Edit recorded post-edit mtime).
500        let out2 = registry
501            .dispatch(
502                "Edit",
503                json!({ "file_path": "f.txt", "old_string": "one", "new_string": "ONE" }),
504                &ctx(&root),
505            )
506            .await;
507        assert!(out2.record.ok, "{}", result_text(&out2.tool_result));
508    }
509
510    #[tokio::test]
511    async fn edit_stale_after_external_modification_is_rejected() {
512        let (_dir, registry, root) = setup();
513        let p = root.join("f.txt");
514        std::fs::write(&p, "hello world").unwrap();
515        read_then(&registry, &root, "f.txt").await;
516        // External modification bumps mtime past the recorded read.
517        std::thread::sleep(std::time::Duration::from_millis(20));
518        std::fs::write(&p, "hello there").unwrap();
519        let out = registry
520            .dispatch(
521                "Edit",
522                json!({ "file_path": "f.txt", "old_string": "hello", "new_string": "HI" }),
523                &ctx(&root),
524            )
525            .await;
526        assert!(!out.record.ok);
527        assert!(
528            result_text(&out.tool_result).starts_with("File has been modified since read"),
529            "{}",
530            result_text(&out.tool_result)
531        );
532    }
533
534    #[tokio::test]
535    async fn edit_no_op_not_found_and_multi_match() {
536        let (_dir, registry, root) = setup();
537        std::fs::write(root.join("f.txt"), "a a a").unwrap();
538        read_then(&registry, &root, "f.txt").await;
539        // old == new (errorCode 1) — checked before the gate.
540        let noop = registry
541            .dispatch(
542                "Edit",
543                json!({ "file_path": "f.txt", "old_string": "a", "new_string": "a" }),
544                &ctx(&root),
545            )
546            .await;
547        assert!(!noop.record.ok);
548        assert!(result_text(&noop.tool_result).contains("exactly the same"));
549        // not found (errorCode 8).
550        let nf = registry
551            .dispatch(
552                "Edit",
553                json!({ "file_path": "f.txt", "old_string": "zzz", "new_string": "q" }),
554                &ctx(&root),
555            )
556            .await;
557        assert!(!nf.record.ok);
558        assert!(result_text(&nf.tool_result).starts_with("String to replace not found in file."));
559        // multi-match without replace_all (errorCode 9).
560        let mm = registry
561            .dispatch(
562                "Edit",
563                json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b" }),
564                &ctx(&root),
565            )
566            .await;
567        assert!(!mm.record.ok);
568        assert!(result_text(&mm.tool_result).starts_with("Found 3 matches"));
569        // replace_all succeeds.
570        let all = registry
571            .dispatch(
572                "Edit",
573                json!({ "file_path": "f.txt", "old_string": "a", "new_string": "b", "replace_all": true }),
574                &ctx(&root),
575            )
576            .await;
577        assert!(all.record.ok);
578        assert_eq!(
579            result_text(&all.tool_result),
580            "The file f.txt has been updated. All occurrences were successfully replaced."
581        );
582        assert_eq!(
583            std::fs::read_to_string(root.join("f.txt")).unwrap(),
584            "b b b"
585        );
586    }
587
588    #[tokio::test]
589    async fn edit_creates_file_with_empty_old_string() {
590        // CC's Edit creates a new file when old_string is empty (plan §4.3 corrected).
591        let (_dir, registry, root) = setup();
592        let out = registry
593            .dispatch(
594                "Edit",
595                json!({ "file_path": "new.txt", "old_string": "", "new_string": "fresh content" }),
596                &ctx(&root),
597            )
598            .await;
599        assert!(out.record.ok, "{}", result_text(&out.tool_result));
600        assert_eq!(out.record.output["created"], json!(true));
601        assert_eq!(
602            std::fs::read_to_string(root.join("new.txt")).unwrap(),
603            "fresh content"
604        );
605    }
606
607    #[tokio::test]
608    async fn edit_empty_old_string_on_existing_nonempty_is_rejected() {
609        let (_dir, registry, root) = setup();
610        std::fs::write(root.join("f.txt"), "existing").unwrap();
611        let out = registry
612            .dispatch(
613                "Edit",
614                json!({ "file_path": "f.txt", "old_string": "", "new_string": "x" }),
615                &ctx(&root),
616            )
617            .await;
618        assert!(!out.record.ok);
619        assert_eq!(
620            result_text(&out.tool_result),
621            "Cannot create new file - file already exists."
622        );
623    }
624
625    // ---- Write: new writes freely; existing must be read first ----
626
627    #[tokio::test]
628    async fn write_new_file_creates_freely() {
629        let (_dir, registry, root) = setup();
630        let out = registry
631            .dispatch(
632                "Write",
633                json!({ "file_path": "new.txt", "content": "hello\nworld\n" }),
634                &ctx(&root),
635            )
636            .await;
637        assert!(out.record.ok, "{}", result_text(&out.tool_result));
638        assert_eq!(
639            result_text(&out.tool_result),
640            "File created successfully at: new.txt"
641        );
642        assert_eq!(out.record.output["created"], json!(true));
643        assert_eq!(
644            std::fs::read_to_string(root.join("new.txt")).unwrap(),
645            "hello\nworld\n"
646        );
647    }
648
649    #[tokio::test]
650    async fn write_existing_unread_is_rejected() {
651        let (_dir, registry, root) = setup();
652        std::fs::write(root.join("f.txt"), "old").unwrap();
653        let out = registry
654            .dispatch(
655                "Write",
656                json!({ "file_path": "f.txt", "content": "new" }),
657                &ctx(&root),
658            )
659            .await;
660        assert!(!out.record.ok);
661        assert_eq!(
662            result_text(&out.tool_result),
663            "File has not been read yet. Read it first before writing to it."
664        );
665    }
666
667    #[tokio::test]
668    async fn write_existing_after_read_overwrites() {
669        let (_dir, registry, root) = setup();
670        std::fs::write(root.join("f.txt"), "old content").unwrap();
671        read_then(&registry, &root, "f.txt").await;
672        let out = registry
673            .dispatch(
674                "Write",
675                json!({ "file_path": "f.txt", "content": "brand new" }),
676                &ctx(&root),
677            )
678            .await;
679        assert!(out.record.ok, "{}", result_text(&out.tool_result));
680        assert_eq!(
681            result_text(&out.tool_result),
682            "The file f.txt has been updated successfully."
683        );
684        assert_eq!(out.record.output["created"], json!(false));
685        assert_eq!(
686            std::fs::read_to_string(root.join("f.txt")).unwrap(),
687            "brand new"
688        );
689    }
690
691    #[tokio::test]
692    async fn write_creates_missing_parent_dirs() {
693        // CC mkdirs parents on create; via Host::create_dir (not write_file).
694        let (_dir, registry, root) = setup();
695        let out = registry
696            .dispatch(
697                "Write",
698                json!({ "file_path": "a/b/c.txt", "content": "deep" }),
699                &ctx(&root),
700            )
701            .await;
702        assert!(out.record.ok, "{}", result_text(&out.tool_result));
703        assert_eq!(
704            std::fs::read_to_string(root.join("a/b/c.txt")).unwrap(),
705            "deep"
706        );
707    }
708
709    #[tokio::test]
710    async fn edit_creates_file_with_missing_parent_dirs() {
711        let (_dir, registry, root) = setup();
712        let out = registry
713            .dispatch(
714                "Edit",
715                json!({ "file_path": "x/y/z.txt", "old_string": "", "new_string": "made" }),
716                &ctx(&root),
717            )
718            .await;
719        assert!(out.record.ok, "{}", result_text(&out.tool_result));
720        assert_eq!(out.record.output["created"], json!(true));
721        assert_eq!(
722            std::fs::read_to_string(root.join("x/y/z.txt")).unwrap(),
723            "made"
724        );
725    }
726
727    #[tokio::test]
728    async fn write_stale_after_external_modification_is_rejected() {
729        let (_dir, registry, root) = setup();
730        let p = root.join("f.txt");
731        std::fs::write(&p, "v1").unwrap();
732        read_then(&registry, &root, "f.txt").await;
733        std::thread::sleep(std::time::Duration::from_millis(20));
734        std::fs::write(&p, "v2 external").unwrap();
735        let out = registry
736            .dispatch(
737                "Write",
738                json!({ "file_path": "f.txt", "content": "v3" }),
739                &ctx(&root),
740            )
741            .await;
742        assert!(!out.record.ok);
743        assert!(result_text(&out.tool_result).starts_with("File has been modified since read"));
744    }
745
746    // ---- Glob (needs `rg`; happy paths gated on its presence) ----
747
748    fn rg_present() -> bool {
749        std::process::Command::new("rg")
750            .arg("--version")
751            .output()
752            .is_ok()
753    }
754
755    #[tokio::test]
756    async fn glob_finds_matching_files() {
757        if !rg_present() {
758            return;
759        }
760        let (_dir, registry, root) = setup();
761        std::fs::create_dir(root.join("src")).unwrap();
762        std::fs::write(root.join("src/main.rs"), "").unwrap();
763        std::fs::write(root.join("src/lib.rs"), "").unwrap();
764        std::fs::write(root.join("README.md"), "").unwrap();
765        let out = registry
766            .dispatch("Glob", json!({ "pattern": "**/*.rs" }), &ctx(&root))
767            .await;
768        assert!(out.record.ok, "{}", result_text(&out.tool_result));
769        let text = result_text(&out.tool_result);
770        assert!(text.contains("src/main.rs"), "{text}");
771        assert!(text.contains("src/lib.rs"), "{text}");
772        assert!(!text.contains("README.md"), "{text}");
773        assert_eq!(out.record.output["truncated"], json!(false));
774    }
775
776    #[tokio::test]
777    async fn glob_no_match_says_no_files_found() {
778        if !rg_present() {
779            return;
780        }
781        let (_dir, registry, root) = setup();
782        std::fs::write(root.join("a.txt"), "").unwrap();
783        let out = registry
784            .dispatch("Glob", json!({ "pattern": "**/*.zzz" }), &ctx(&root))
785            .await;
786        assert!(out.record.ok);
787        assert_eq!(result_text(&out.tool_result), "No files found");
788    }
789
790    #[tokio::test]
791    async fn glob_path_missing_is_soft_error() {
792        let (_dir, registry, root) = setup();
793        let out = registry
794            .dispatch(
795                "Glob",
796                json!({ "pattern": "*", "path": "nope" }),
797                &ctx(&root),
798            )
799            .await;
800        assert!(!out.record.ok);
801        assert!(result_text(&out.tool_result).starts_with("Directory does not exist: nope."));
802    }
803
804    #[tokio::test]
805    async fn glob_path_is_a_file_is_soft_error() {
806        let (_dir, registry, root) = setup();
807        std::fs::write(root.join("f.txt"), "x").unwrap();
808        let out = registry
809            .dispatch(
810                "Glob",
811                json!({ "pattern": "*", "path": "f.txt" }),
812                &ctx(&root),
813            )
814            .await;
815        assert!(!out.record.ok);
816        assert_eq!(
817            result_text(&out.tool_result),
818            "Path is not a directory: f.txt"
819        );
820    }
821
822    // ---- Grep (needs `rg`; happy paths gated on its presence) ----
823
824    #[tokio::test]
825    async fn grep_files_with_matches_default() {
826        if !rg_present() {
827            return;
828        }
829        let (_dir, registry, root) = setup();
830        std::fs::write(root.join("a.txt"), "needle here\n").unwrap();
831        std::fs::write(root.join("b.txt"), "nothing\n").unwrap();
832        let out = registry
833            .dispatch("Grep", json!({ "pattern": "needle" }), &ctx(&root))
834            .await;
835        assert!(out.record.ok, "{}", result_text(&out.tool_result));
836        let text = result_text(&out.tool_result);
837        assert!(text.starts_with("Found 1 file"), "{text}");
838        assert!(text.contains("a.txt"), "{text}");
839        assert!(!text.contains("b.txt"), "{text}");
840    }
841
842    #[tokio::test]
843    async fn grep_no_match_files_mode() {
844        if !rg_present() {
845            return;
846        }
847        let (_dir, registry, root) = setup();
848        std::fs::write(root.join("a.txt"), "hello\n").unwrap();
849        let out = registry
850            .dispatch("Grep", json!({ "pattern": "zzznope" }), &ctx(&root))
851            .await;
852        assert!(out.record.ok);
853        assert_eq!(result_text(&out.tool_result), "No files found");
854    }
855
856    #[tokio::test]
857    async fn grep_content_mode_with_line_numbers() {
858        if !rg_present() {
859            return;
860        }
861        let (_dir, registry, root) = setup();
862        std::fs::write(root.join("a.txt"), "one\nneedle two\nthree\n").unwrap();
863        let out = registry
864            .dispatch(
865                "Grep",
866                json!({ "pattern": "needle", "output_mode": "content" }),
867                &ctx(&root),
868            )
869            .await;
870        assert!(out.record.ok);
871        let text = result_text(&out.tool_result);
872        // Line numbers on by default in content mode; path relativized.
873        assert!(text.contains("a.txt:2:needle two"), "{text}");
874    }
875
876    #[tokio::test]
877    async fn grep_content_no_match() {
878        if !rg_present() {
879            return;
880        }
881        let (_dir, registry, root) = setup();
882        std::fs::write(root.join("a.txt"), "hello\n").unwrap();
883        let out = registry
884            .dispatch(
885                "Grep",
886                json!({ "pattern": "zzz", "output_mode": "content" }),
887                &ctx(&root),
888            )
889            .await;
890        assert!(out.record.ok);
891        assert_eq!(result_text(&out.tool_result), "No matches found");
892    }
893
894    #[tokio::test]
895    async fn grep_count_mode_summary() {
896        if !rg_present() {
897            return;
898        }
899        let (_dir, registry, root) = setup();
900        std::fs::write(root.join("a.txt"), "x\nx\nx\n").unwrap();
901        let out = registry
902            .dispatch(
903                "Grep",
904                json!({ "pattern": "x", "output_mode": "count" }),
905                &ctx(&root),
906            )
907            .await;
908        assert!(out.record.ok);
909        let text = result_text(&out.tool_result);
910        assert!(
911            text.contains("Found 3 total occurrences across 1 file."),
912            "{text}"
913        );
914        assert_eq!(out.record.output["num_matches"], json!(3));
915    }
916
917    #[test]
918    fn grep_schema_is_faithful() {
919        let (_dir, registry, _root) = setup();
920        let specs = registry.specs();
921        let grep = specs.iter().find(|s| s.name == "Grep").expect("Grep spec");
922        let params = match &grep.input {
923            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
924            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Grep is JSON-schema"),
925        };
926        assert_eq!(params["additionalProperties"], json!(false));
927        let props = params["properties"].as_object().unwrap();
928        // The rg-flag-named fields keep their exact wire keys.
929        for key in [
930            "pattern",
931            "path",
932            "glob",
933            "output_mode",
934            "-A",
935            "-B",
936            "-C",
937            "context",
938            "-n",
939            "-i",
940            "type",
941            "head_limit",
942            "offset",
943            "multiline",
944        ] {
945            assert!(props.contains_key(key), "missing schema field {key}");
946        }
947        assert_eq!(
948            props["pattern"]["description"],
949            json!("The regular expression pattern to search for in file contents")
950        );
951        let required: Vec<&str> = params["required"]
952            .as_array()
953            .unwrap()
954            .iter()
955            .map(|v| v.as_str().unwrap())
956            .collect();
957        assert_eq!(required, vec!["pattern"]);
958    }
959
960    #[test]
961    fn glob_schema_is_faithful() {
962        let (_dir, registry, _root) = setup();
963        let specs = registry.specs();
964        let glob = specs.iter().find(|s| s.name == "Glob").expect("Glob spec");
965        let params = match &glob.input {
966            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
967            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Glob is JSON-schema"),
968        };
969        assert_eq!(params["additionalProperties"], json!(false));
970        let props = params["properties"].as_object().unwrap();
971        assert_eq!(
972            props["pattern"]["description"],
973            json!("The glob pattern to match files against")
974        );
975        assert!(
976            props["path"]["description"]
977                .as_str()
978                .unwrap()
979                .contains("DO NOT enter \"undefined\" or \"null\"")
980        );
981        let required: Vec<&str> = params["required"]
982            .as_array()
983            .unwrap()
984            .iter()
985            .map(|v| v.as_str().unwrap())
986            .collect();
987        assert_eq!(required, vec!["pattern"]);
988    }
989
990    #[test]
991    fn write_schema_is_faithful() {
992        let (_dir, registry, _root) = setup();
993        let specs = registry.specs();
994        let write = specs
995            .iter()
996            .find(|s| s.name == "Write")
997            .expect("Write spec");
998        let params = match &write.input {
999            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
1000            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Write is JSON-schema"),
1001        };
1002        assert_eq!(params["additionalProperties"], json!(false));
1003        let props = params["properties"].as_object().unwrap();
1004        assert_eq!(
1005            props["file_path"]["description"],
1006            json!("The absolute path to the file to write (must be absolute, not relative)")
1007        );
1008        assert_eq!(
1009            props["content"]["description"],
1010            json!("The content to write to the file")
1011        );
1012        let mut required: Vec<&str> = params["required"]
1013            .as_array()
1014            .unwrap()
1015            .iter()
1016            .map(|v| v.as_str().unwrap())
1017            .collect();
1018        required.sort_unstable();
1019        assert_eq!(required, vec!["content", "file_path"]);
1020    }
1021
1022    #[test]
1023    fn edit_schema_is_faithful() {
1024        let (_dir, registry, _root) = setup();
1025        let specs = registry.specs();
1026        let edit = specs.iter().find(|s| s.name == "Edit").expect("Edit spec");
1027        let params = match &edit.input {
1028            locode_protocol::ToolInputFormat::JsonSchema { parameters } => parameters,
1029            locode_protocol::ToolInputFormat::Freeform { .. } => panic!("Edit is JSON-schema"),
1030        };
1031        assert_eq!(params["additionalProperties"], json!(false));
1032        let props = params["properties"].as_object().unwrap();
1033        assert_eq!(
1034            props["new_string"]["description"],
1035            json!("The text to replace it with (must be different from old_string)")
1036        );
1037        for key in ["file_path", "old_string", "new_string", "replace_all"] {
1038            assert!(props.contains_key(key), "missing schema field {key}");
1039        }
1040        let mut required: Vec<&str> = params["required"]
1041            .as_array()
1042            .unwrap()
1043            .iter()
1044            .map(|v| v.as_str().unwrap())
1045            .collect();
1046        required.sort_unstable();
1047        assert_eq!(required, vec!["file_path", "new_string", "old_string"]);
1048    }
1049
1050    #[test]
1051    fn read_offset_rejects_string_and_float() {
1052        // Type-strict (repo policy): no lenient coercion.
1053        for bad in [
1054            json!({"file_path": "f", "offset": "3"}),
1055            json!({"file_path": "f", "limit": 2.5}),
1056        ] {
1057            assert!(serde_json::from_value::<read::ReadArgs>(bad).is_err());
1058        }
1059    }
1060
1061    #[tokio::test]
1062    async fn bash_rejects_unknown_field() {
1063        let (_dir, registry, root) = setup();
1064        // deny_unknown_fields (z.strictObject): run_in_background is not in our schema.
1065        let out = registry
1066            .dispatch(
1067                "Bash",
1068                json!({ "command": "echo hi", "run_in_background": true }),
1069                &ctx(&root),
1070            )
1071            .await;
1072        assert!(!out.record.ok, "unknown field rejected");
1073        assert!(is_error(&out.tool_result));
1074    }
1075}