1use super::types::{GraphSpec, HarnessPack, LoopSpec, VerifierKind, VerifierSpec};
4use anyhow::{Context, Result, bail};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8pub fn harnesses_root(data_dir: &Path) -> PathBuf {
10 data_dir.join("harnesses")
11}
12
13pub 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
41pub 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 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
66pub 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
140pub 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 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
196pub 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}