Skip to main content

harness_lock_core/
manifest.rs

1//! The manifest: `harness.toml`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::error::{Error, Result};
9use crate::hash::sha256_bytes;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(default, rename_all = "kebab-case")]
13pub struct Objective {
14    pub primary: String,
15    pub constraints: BTreeMap<String, String>,
16    pub select: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[serde(default)]
22pub struct Budget {
23    pub max_spend_usd: Option<f64>,
24    pub max_trials: Option<usize>,
25    pub strategy: String,
26    pub random_seed: u64,
27}
28
29impl Default for Budget {
30    fn default() -> Self {
31        Budget {
32            max_spend_usd: None,
33            max_trials: None,
34            strategy: "auto".into(),
35            random_seed: 0,
36        }
37    }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(default)]
42pub struct Candidates {
43    #[serde(default)]
44    pub llm: BTreeMap<String, LLMCandidate>,
45    #[serde(default)]
46    pub decision: Vec<String>,
47    #[serde(default, deserialize_with = "de_harness")]
48    pub harness: Vec<HarnessCandidate>,
49    #[serde(default)]
50    pub policy: BTreeMap<String, Vec<serde_json::Value>>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(default)]
55pub struct LLMCandidate {
56    /// Manifest candidates may spell this `any_of` (underscore) or `any-of`.
57    #[serde(alias = "any-of")]
58    pub any_of: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct HarnessCandidate {
64    pub name: String,
65    #[serde(default)]
66    pub pattern: Vec<String>,
67    #[serde(default)]
68    pub config: BTreeMap<String, serde_json::Value>,
69}
70
71impl Default for HarnessCandidate {
72    fn default() -> Self {
73        HarnessCandidate {
74            name: String::new(),
75            pattern: Vec::new(),
76            config: BTreeMap::new(),
77        }
78    }
79}
80
81/// Accept both `harness = ["reference"]` and `[[candidates.harness]]` tables.
82fn de_harness<'de, D>(d: D) -> std::result::Result<Vec<HarnessCandidate>, D::Error>
83where
84    D: Deserializer<'de>,
85{
86    use serde::de::Error as _;
87    let v = serde_json::Value::deserialize(d)?;
88    match v {
89        serde_json::Value::Array(arr) => arr
90            .iter()
91            .map(|item| match item {
92                serde_json::Value::String(s) => Ok(HarnessCandidate {
93                    name: s.clone(),
94                    ..Default::default()
95                }),
96                other => serde_json::from_value(other.clone()).map_err(D::Error::custom),
97            })
98            .collect(),
99        _ => Ok(vec![]),
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104#[serde(default)]
105pub struct Manifest {
106    #[serde(default)]
107    pub project: BTreeMap<String, serde_json::Value>,
108    /// `[evals]`: the eval packs to measure against. `[tasks]` is the old name.
109    #[serde(default, alias = "tasks")]
110    pub evals: BTreeMap<String, serde_json::Value>,
111    #[serde(default)]
112    pub candidates: Candidates,
113    #[serde(default)]
114    pub tools: BTreeMap<String, serde_json::Value>,
115    #[serde(default)]
116    pub skills: BTreeMap<String, serde_json::Value>,
117    #[serde(default)]
118    pub instructions: BTreeMap<String, serde_json::Value>,
119    #[serde(default)]
120    pub context: BTreeMap<String, serde_json::Value>,
121    #[serde(default)]
122    pub objective: Objective,
123    #[serde(default)]
124    pub budget: Budget,
125    #[serde(default, rename = "enforce")]
126    pub enforce: BTreeMap<String, serde_json::Value>,
127    #[serde(default)]
128    pub backend: BTreeMap<String, serde_json::Value>,
129    /// The harness this lock is for (`[target] harness = "claude-code"`).
130    #[serde(default)]
131    pub target: BTreeMap<String, serde_json::Value>,
132    /// Project-defined harnesses (`[harnesses.<id>]`): framework agents and custom scripts.
133    #[serde(default)]
134    pub harnesses: BTreeMap<String, serde_json::Value>,
135    #[serde(skip)]
136    pub source_path: Option<PathBuf>,
137}
138
139/// The nearest `harness.toml` at or above `start`, like Cargo finds `Cargo.toml`.
140pub fn find_manifest(start: &Path) -> Option<PathBuf> {
141    let mut dir = Some(start);
142    while let Some(d) = dir {
143        let candidate = d.join(crate::MANIFEST_PATH);
144        if candidate.is_file() {
145            return Some(candidate);
146        }
147        dir = d.parent();
148    }
149    None
150}
151
152impl Manifest {
153    pub fn load(path: &Path) -> Result<Manifest> {
154        let text = std::fs::read_to_string(path)
155            .map_err(|e| Error::Manifest(format!("cannot read {}: {e}", path.display())))?;
156        let mut m: Manifest = toml::from_str(&text)
157            .map_err(|e| Error::Manifest(format!("invalid harness.toml: {e}")))?;
158        m.source_path = Some(path.to_path_buf());
159        Ok(m)
160    }
161
162    /// The project directory: where the manifest lives (else the working directory).
163    /// Relative paths in the manifest (eval packs, instructions) resolve against it.
164    pub fn root(&self) -> PathBuf {
165        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
166        match self.source_path.as_ref().and_then(|p| p.parent()) {
167            Some(d) if d.as_os_str().is_empty() => cwd,
168            Some(d) if d.is_absolute() => d.to_path_buf(),
169            Some(d) => cwd.join(d),
170            None => cwd,
171        }
172    }
173
174    /// Parse from an in-memory TOML string.
175    pub fn from_str(text: &str) -> Result<Manifest> {
176        toml::from_str(text).map_err(|e| Error::Manifest(format!("invalid harness.toml: {e}")))
177    }
178
179    pub fn name(&self) -> String {
180        self.project
181            .get("name")
182            .and_then(|v| v.as_str())
183            .map(|s| s.to_string())
184            .unwrap_or_else(|| "agent".to_string())
185    }
186
187    pub fn eval_packs(&self) -> Vec<String> {
188        self.evals
189            .get("packs")
190            .and_then(|v| v.as_array())
191            .map(|a| {
192                a.iter()
193                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
194                    .collect()
195            })
196            .unwrap_or_default()
197    }
198
199    #[deprecated(note = "renamed to `eval_packs`")]
200    pub fn task_packs(&self) -> Vec<String> {
201        self.eval_packs()
202    }
203
204    pub fn seeds(&self) -> u64 {
205        self.evals
206            .get("seeds")
207            .and_then(|v| v.as_u64())
208            .unwrap_or(3)
209    }
210
211    pub fn backend_target(&self) -> String {
212        self.backend
213            .get("target")
214            .and_then(|v| v.as_str())
215            .map(|s| s.to_string())
216            .unwrap_or_else(|| "local".to_string())
217    }
218
219    /// `[target] harness`, the harness the lock is meant to run in.
220    pub fn target_harness(&self) -> Option<String> {
221        self.target
222            .get("harness")
223            .and_then(|v| v.as_str())
224            .filter(|s| !s.is_empty())
225            .map(|s| s.to_string())
226    }
227
228    pub fn search_frac(&self) -> f64 {
229        self.evals
230            .get("splits")
231            .and_then(|v| v.get("search"))
232            .and_then(|v| v.as_f64())
233            .unwrap_or(0.7)
234    }
235
236    /// Hash of the manifest (of the source file when loaded, else of its TOML).
237    pub fn manifest_hash(&self) -> String {
238        if let Some(p) = &self.source_path {
239            if let Ok(bytes) = std::fs::read(p) {
240                return sha256_bytes(&bytes);
241            }
242        }
243        match toml::to_string(self) {
244            Ok(s) => sha256_bytes(s.as_bytes()),
245            Err(_) => String::new(),
246        }
247    }
248}