Skip to main content

navi_core/harness_pack/
store.rs

1//! Filesystem store for harness packs under `{data_dir}/harnesses/<id>/`.
2
3use super::types::{GraphSpec, HarnessPack, LoopSpec, VerifierKind, VerifierSpec};
4use anyhow::{Context, Result, bail};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Returns `{data_dir}/harnesses`.
9pub fn harnesses_root(data_dir: &Path) -> PathBuf {
10    data_dir.join("harnesses")
11}
12
13/// Returns `{data_dir}/harnesses/<id>`.
14pub fn harness_pack_dir(data_dir: &Path, id: &str) -> PathBuf {
15    harnesses_root(data_dir).join(sanitize_id(id))
16}
17
18fn sanitize_id(id: &str) -> String {
19    let s = id.trim().to_ascii_lowercase();
20    let mut out = String::new();
21    let mut prev_dash = false;
22    for ch in s.chars() {
23        if ch.is_ascii_alphanumeric() {
24            out.push(ch);
25            prev_dash = false;
26        } else if matches!(ch, '-' | '_' | ' ' | '/') && !prev_dash && !out.is_empty() {
27            out.push('-');
28            prev_dash = true;
29        }
30    }
31    while out.ends_with('-') {
32        out.pop();
33    }
34    if out.is_empty() {
35        "harness".into()
36    } else {
37        out
38    }
39}
40
41/// Lists harness pack ids (directory names under harnesses/).
42pub fn list_harness_ids(data_dir: &Path) -> Result<Vec<String>> {
43    let root = harnesses_root(data_dir);
44    if !root.is_dir() {
45        return Ok(Vec::new());
46    }
47    let mut ids = Vec::new();
48    for entry in fs::read_dir(&root).with_context(|| format!("read {}", root.display()))? {
49        let entry = entry?;
50        if entry.file_type()?.is_dir() {
51            if let Some(name) = entry.file_name().to_str() {
52                if name.starts_with('.') {
53                    continue;
54                }
55                // Prefer packs that have loop.toml
56                if entry.path().join("loop.toml").is_file() {
57                    ids.push(name.to_string());
58                }
59            }
60        }
61    }
62    ids.sort();
63    Ok(ids)
64}
65
66/// Loads a harness pack by id. Returns `None` if the directory or loop.toml is missing.
67pub fn load_pack(data_dir: &Path, id: &str) -> Result<Option<HarnessPack>> {
68    let root = harness_pack_dir(data_dir, id);
69    let loop_path = root.join("loop.toml");
70    if !loop_path.is_file() {
71        return Ok(None);
72    }
73    let loop_raw =
74        fs::read_to_string(&loop_path).with_context(|| format!("read {}", loop_path.display()))?;
75    let mut loop_spec: LoopSpec =
76        toml::from_str(&loop_raw).with_context(|| format!("parse {}", loop_path.display()))?;
77    if loop_spec.id.trim().is_empty() {
78        loop_spec.id = sanitize_id(id);
79    }
80
81    let graph = {
82        let graph_path = root.join("graph.toml");
83        if graph_path.is_file() {
84            let raw = fs::read_to_string(&graph_path)
85                .with_context(|| format!("read {}", graph_path.display()))?;
86            Some(
87                toml::from_str::<GraphSpec>(&raw)
88                    .with_context(|| format!("parse {}", graph_path.display()))?,
89            )
90        } else {
91            None
92        }
93    };
94
95    let verifiers = load_verifiers_dir(&root.join("verifiers"))?;
96
97    let capability_md = read_optional_utf8(&root.join("CAPABILITY.md"))?;
98    let skill_md = read_optional_utf8(&root.join("SKILL.md"))?;
99
100    Ok(Some(HarnessPack {
101        id: sanitize_id(id),
102        root,
103        loop_spec,
104        graph,
105        verifiers,
106        capability_md,
107        skill_md,
108    }))
109}
110
111fn read_optional_utf8(path: &Path) -> Result<Option<String>> {
112    if !path.is_file() {
113        return Ok(None);
114    }
115    Ok(Some(
116        fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?,
117    ))
118}
119
120fn load_verifiers_dir(dir: &Path) -> Result<Vec<VerifierSpec>> {
121    if !dir.is_dir() {
122        return Ok(Vec::new());
123    }
124    let mut out = Vec::new();
125    for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
126        let entry = entry?;
127        let path = entry.path();
128        if path.extension().and_then(|e| e.to_str()) != Some("toml") {
129            continue;
130        }
131        let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
132        let spec: VerifierSpec =
133            toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
134        out.push(spec);
135    }
136    out.sort_by(|a, b| a.id.cmp(&b.id));
137    Ok(out)
138}
139
140/// Writes a full pack to disk (creates directories). Overwrites existing files.
141pub fn write_pack(data_dir: &Path, pack: &HarnessPack) -> Result<PathBuf> {
142    let id = sanitize_id(&pack.id);
143    if id.is_empty() {
144        bail!("harness pack id is required");
145    }
146    let root = harness_pack_dir(data_dir, &id);
147    fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
148    fs::create_dir_all(root.join("verifiers"))
149        .with_context(|| format!("create {}/verifiers", root.display()))?;
150    fs::create_dir_all(root.join("runs"))
151        .with_context(|| format!("create {}/runs", root.display()))?;
152
153    let mut loop_spec = pack.loop_spec.clone();
154    loop_spec.id = id.clone();
155    let loop_toml = toml::to_string_pretty(&loop_spec).context("serialize loop.toml")?;
156    fs::write(root.join("loop.toml"), loop_toml)
157        .with_context(|| format!("write {}/loop.toml", root.display()))?;
158
159    if let Some(graph) = &pack.graph {
160        let graph_toml = toml::to_string_pretty(graph).context("serialize graph.toml")?;
161        fs::write(root.join("graph.toml"), graph_toml)
162            .with_context(|| format!("write {}/graph.toml", root.display()))?;
163    }
164
165    for v in &pack.verifiers {
166        let name = if v.id.is_empty() {
167            "verifier.toml".into()
168        } else {
169            format!("{}.toml", sanitize_id(&v.id))
170        };
171        let body = toml::to_string_pretty(v).context("serialize verifier")?;
172        fs::write(root.join("verifiers").join(&name), body)
173            .with_context(|| format!("write verifier {name}"))?;
174    }
175
176    if let Some(cap) = &pack.capability_md {
177        fs::write(root.join("CAPABILITY.md"), cap)
178            .with_context(|| format!("write CAPABILITY.md"))?;
179    }
180    if let Some(skill) = &pack.skill_md {
181        fs::write(root.join("SKILL.md"), skill).with_context(|| format!("write SKILL.md"))?;
182    }
183
184    // Seed CHANGELOG if missing
185    let changelog = root.join("CHANGELOG.md");
186    if !changelog.is_file() {
187        fs::write(
188            &changelog,
189            format!("# Harness `{id}`\n\n- Initial materialize\n"),
190        )?;
191    }
192
193    Ok(root)
194}
195
196/// Thin facade used by CLI/SDK.
197pub struct HarnessPackStore {
198    data_dir: PathBuf,
199}
200
201impl HarnessPackStore {
202    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
203        Self {
204            data_dir: data_dir.into(),
205        }
206    }
207
208    pub fn data_dir(&self) -> &Path {
209        &self.data_dir
210    }
211
212    pub fn list(&self) -> Result<Vec<String>> {
213        list_harness_ids(&self.data_dir)
214    }
215
216    pub fn load(&self, id: &str) -> Result<Option<HarnessPack>> {
217        load_pack(&self.data_dir, id)
218    }
219
220    pub fn write(&self, pack: &HarnessPack) -> Result<PathBuf> {
221        write_pack(&self.data_dir, pack)
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::harness_pack::types::{GraphEdge, GraphNode};
229
230    #[test]
231    fn write_and_load_roundtrip() {
232        let dir = tempfile::tempdir().unwrap();
233        let mut pack = HarnessPack {
234            id: "Design Loop".into(),
235            root: PathBuf::new(),
236            loop_spec: LoopSpec {
237                id: "design-loop".into(),
238                max_turns: 12,
239                token_budget: Some(50_000),
240                stop: vec!["goal.complete".into(), "budget".into()],
241            },
242            graph: Some(GraphSpec {
243                entry: "explore".into(),
244                nodes: vec![
245                    GraphNode {
246                        id: "explore".into(),
247                        role: "read_only".into(),
248                        allow_tools: vec!["search".into(), "read_file".into()],
249                        verifiers: vec![],
250                    },
251                    GraphNode {
252                        id: "implement".into(),
253                        role: "write".into(),
254                        allow_tools: vec!["edit".into(), "write_file".into()],
255                        verifiers: vec![],
256                    },
257                ],
258                edges: vec![GraphEdge {
259                    from: "explore".into(),
260                    to: "implement".into(),
261                    when: None,
262                }],
263            }),
264            verifiers: vec![VerifierSpec {
265                id: "smoke".into(),
266                kind: VerifierKind::Bash,
267                recipe: "cargo test -q".into(),
268            }],
269            capability_md: Some("browser: yes\n".into()),
270            skill_md: Some("# Design Loop\n".into()),
271        };
272        let root = write_pack(dir.path(), &pack).unwrap();
273        assert!(root.join("loop.toml").is_file());
274        assert!(root.join("graph.toml").is_file());
275        assert!(root.join("verifiers/smoke.toml").is_file());
276
277        let loaded = load_pack(dir.path(), "design-loop").unwrap().unwrap();
278        assert_eq!(loaded.id, "design-loop");
279        assert_eq!(loaded.loop_spec.max_turns, 12);
280        assert_eq!(loaded.loop_spec.token_budget, Some(50_000));
281        let graph = loaded.graph.as_ref().unwrap();
282        assert_eq!(graph.entry, "explore");
283        assert_eq!(graph.nodes.len(), 2);
284        assert_eq!(loaded.verifiers.len(), 1);
285        assert_eq!(loaded.verifiers[0].recipe, "cargo test -q");
286        assert!(loaded.capability_md.unwrap().contains("browser"));
287        pack.root = root;
288    }
289
290    #[test]
291    fn list_ids_sorted() {
292        let dir = tempfile::tempdir().unwrap();
293        for id in ["z-pack", "a-pack"] {
294            let pack = HarnessPack {
295                id: id.into(),
296                root: PathBuf::new(),
297                loop_spec: LoopSpec {
298                    id: id.into(),
299                    ..LoopSpec::default()
300                },
301                graph: None,
302                verifiers: vec![],
303                capability_md: None,
304                skill_md: None,
305            };
306            write_pack(dir.path(), &pack).unwrap();
307        }
308        let ids = list_harness_ids(dir.path()).unwrap();
309        assert_eq!(ids, vec!["a-pack".to_string(), "z-pack".to_string()]);
310    }
311}