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