Skip to main content

locode_packs/
lib.rs

1//! locode-packs — faithful per-harness toolsets (ADR-0012), one module per harness.
2//!
3//! A [`Pack`] bundles a harness's real tools (registered under their real wire names)
4//! with its base [`Pack::preamble`], selected whole via `--harness`. [`resolve`] maps a
5//! `--harness <name>` to its pack. v0 wires the [`GrokPack`]; other packs are the next
6//! milestone (one `match`/`static` each).
7
8pub mod claude;
9pub mod codex;
10pub mod grok;
11mod pack;
12
13pub use claude::ClaudePack;
14pub use codex::CodexPack;
15pub use grok::GrokPack;
16pub use pack::{Pack, PackContext};
17
18/// The process-wide grok singleton (zero-sized).
19static GROK: GrokPack = GrokPack;
20/// The process-wide claude singleton (zero-sized for Slice 1).
21static CLAUDE: ClaudePack = ClaudePack;
22/// The process-wide codex singleton (zero-sized).
23static CODEX: CodexPack = CodexPack;
24
25/// Every wired harness, in a stable order (for `--help` and error messages).
26const PACKS: &[&(dyn Pack + 'static)] = &[&GROK, &CLAUDE, &CODEX];
27
28/// The registered `--harness` names, in stable order.
29#[must_use]
30pub fn available() -> Vec<&'static str> {
31    PACKS.iter().map(|pack| pack.name()).collect()
32}
33
34/// Resolve a `--harness <name>` to its pack.
35///
36/// # Errors
37/// [`UnknownHarness`] when no pack matches — carries the requested name and the available
38/// list so the caller can print a clear, actionable error.
39pub fn resolve(name: &str) -> Result<&'static dyn Pack, UnknownHarness> {
40    PACKS
41        .iter()
42        .copied()
43        .find(|pack| pack.name() == name)
44        .ok_or_else(|| UnknownHarness {
45            requested: name.to_owned(),
46            available: available(),
47        })
48}
49
50/// An unknown `--harness` selector — a caller/config error (soft, never a panic).
51#[derive(Debug, thiserror::Error)]
52#[error("unknown harness `{requested}`; available: {}", .available.join(", "))]
53pub struct UnknownHarness {
54    /// The name the caller asked for.
55    pub requested: String,
56    /// The names that ARE registered.
57    pub available: Vec<&'static str>,
58}
59
60#[cfg(test)]
61mod tests {
62    // The fake pack's tools return `&'static str` literals from `description`.
63    #![allow(clippy::unnecessary_literal_bound)]
64
65    use super::*;
66    use async_trait::async_trait;
67    use locode_host::{Host, HostConfig};
68    use locode_protocol::{ContentBlock, Message, Role};
69    use locode_tools::{Registry, Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
70    use serde::Serialize;
71    use serde_json::{Value, json};
72    use std::path::PathBuf;
73    use std::sync::Arc;
74    use tokio_util::sync::CancellationToken;
75
76    /// A host over a fresh temp workspace (the tempdir must outlive the host).
77    fn test_host() -> (tempfile::TempDir, Arc<Host>) {
78        let dir = tempfile::tempdir().unwrap();
79        let host = Arc::new(Host::new(HostConfig::new(dir.path())).unwrap());
80        (dir, host)
81    }
82
83    // ---- a test-local pack with real typed tools, so the framework is proven
84    // deterministically regardless of how grok's real registry grows (Tasks 9-11). ----
85
86    #[derive(Serialize)]
87    struct EchoOut {
88        echoed: String,
89    }
90    impl ToolOutput for EchoOut {
91        fn to_prompt_text(&self) -> String {
92            self.echoed.clone()
93        }
94    }
95
96    struct Echo;
97    #[async_trait]
98    impl Tool for Echo {
99        type Args = Value;
100        type Output = EchoOut;
101        fn kind(&self) -> ToolKind {
102            ToolKind::Shell
103        }
104        fn description(&self) -> &str {
105            "echo"
106        }
107        async fn run(&self, _ctx: &ToolCtx, args: Value) -> Result<EchoOut, ToolError> {
108            Ok(EchoOut {
109                echoed: args.to_string(),
110            })
111        }
112    }
113
114    struct FakePack;
115    impl Pack for FakePack {
116        fn name(&self) -> &'static str {
117            "fake"
118        }
119        fn register(&self, _host: &Arc<Host>, registry: &mut Registry) {
120            registry.register("alpha", Echo);
121            registry.register("beta", Echo);
122        }
123        fn preamble(&self, _ctx: &PackContext) -> Vec<Message> {
124            vec![Message {
125                role: Role::System,
126                content: vec![ContentBlock::Text {
127                    text: "fake pack".into(),
128                }],
129            }]
130        }
131    }
132
133    struct DupPack;
134    impl Pack for DupPack {
135        fn name(&self) -> &'static str {
136            "dup"
137        }
138        fn register(&self, _host: &Arc<Host>, registry: &mut Registry) {
139            registry.register("alpha", Echo);
140            registry.register("alpha", Echo); // collision → panic
141        }
142        fn preamble(&self, _ctx: &PackContext) -> Vec<Message> {
143            Vec::new()
144        }
145    }
146
147    fn ctx(headless: bool) -> PackContext {
148        PackContext {
149            cwd: PathBuf::from("/repo"),
150            os: "linux".into(),
151            shell: "/bin/bash".into(),
152            date: "2026-07-18".into(),
153            headless,
154            is_git_repo: false,
155            model: None,
156            os_version: None,
157            timezone: None,
158            strip_identity: false,
159        }
160    }
161
162    // ---- resolver ----
163
164    #[test]
165    fn resolve_grok_returns_grok_pack() {
166        assert_eq!(resolve("grok").unwrap().name(), "grok");
167    }
168
169    #[test]
170    fn available_lists_wired_packs() {
171        assert_eq!(available(), vec!["grok", "claude", "codex"]);
172    }
173
174    #[test]
175    fn resolve_claude_returns_claude_pack() {
176        assert_eq!(resolve("claude").unwrap().name(), "claude");
177    }
178
179    #[test]
180    fn resolve_codex_returns_codex_pack() {
181        assert_eq!(resolve("codex").unwrap().name(), "codex");
182    }
183
184    #[test]
185    fn unknown_harness_errors_clearly() {
186        let err = resolve("gpt").err().expect("unknown harness");
187        let msg = err.to_string();
188        assert!(msg.contains("gpt"), "{msg}");
189        assert!(msg.contains("grok"), "names the available packs: {msg}");
190        assert_eq!(err.requested, "gpt");
191    }
192
193    // ---- the pack → Registry → ToolSpec path (via the fake pack) ----
194
195    #[test]
196    fn pack_builds_expected_specs() {
197        let (_dir, host) = test_host();
198        let registry = FakePack.build_registry(&host);
199        assert!(registry.contains("alpha"));
200        assert!(registry.contains("beta"));
201        let specs = registry.specs();
202        let mut names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();
203        names.sort_unstable();
204        assert_eq!(names, vec!["alpha", "beta"]);
205        // Each tool carries its description; the schema is derived from its Args type.
206        let alpha = specs.iter().find(|s| s.name == "alpha").unwrap();
207        assert_eq!(alpha.description, "echo");
208    }
209
210    #[tokio::test]
211    async fn pack_routes_to_impl() {
212        let (_dir, host) = test_host();
213        let registry = FakePack.build_registry(&host);
214        let tool_ctx = ToolCtx::new(
215            PathBuf::from("/repo"),
216            "c1".into(),
217            PathBuf::from("/repo"),
218            CancellationToken::new(),
219        );
220        let dispatched = registry
221            .dispatch("alpha", json!({ "x": 1 }), &tool_ctx)
222            .await;
223        assert!(dispatched.record.ok, "routes to the registered impl");
224        assert_eq!(dispatched.record.name, "alpha");
225        // The echo tool serialized `args.to_string()` into its output.
226        assert_eq!(dispatched.record.output["echoed"], json!(r#"{"x":1}"#));
227    }
228
229    #[test]
230    #[should_panic(expected = "duplicate tool registration")]
231    fn duplicate_registration_panics() {
232        let (_dir, host) = test_host();
233        let _ = DupPack.build_registry(&host);
234    }
235
236    // ---- grok wiring ----
237
238    /// The approval seam resolves `kind` by registry lookup pre-dispatch
239    /// (ADR-0017): pin the grok pack's `ToolKind` taxonomy end-to-end.
240    #[test]
241    fn grok_tools_expose_kinds_for_the_approval_seam() {
242        use locode_tools::ToolKind;
243        let (_dir, host) = test_host();
244        let registry = resolve("grok").unwrap().build_registry(&host);
245        for (tool, kind) in [
246            ("run_terminal_cmd", ToolKind::Shell),
247            ("read_file", ToolKind::Read),
248            ("search_replace", ToolKind::Edit),
249            ("grep", ToolKind::Grep),
250            ("list_dir", ToolKind::Glob),
251        ] {
252            assert_eq!(registry.kind_of(tool), Some(kind), "kind_of({tool})");
253        }
254        assert_eq!(registry.kind_of("no_such_tool"), None);
255    }
256
257    #[test]
258    fn grok_registers_real_tools_and_preamble() {
259        let (_dir, host) = test_host();
260        let pack = resolve("grok").unwrap();
261        let registry = pack.build_registry(&host);
262        for tool in [
263            "run_terminal_cmd",
264            "read_file",
265            "search_replace",
266            "grep",
267            "list_dir",
268        ] {
269            assert!(registry.contains(tool), "grok pack registers {tool}");
270        }
271
272        // Task 13: [System(rendered grok prompt), User(<user_info> prefix)].
273        let headless = pack.preamble(&ctx(true));
274        assert_eq!(headless.len(), 2);
275        assert_eq!(headless[0].role, Role::System);
276        assert_eq!(headless[1].role, Role::User);
277        let interactive = pack.preamble(&ctx(false));
278        assert_ne!(
279            headless, interactive,
280            "headless branch changes the identity"
281        );
282    }
283}