1use std::collections::BTreeMap;
35use std::fmt;
36use std::fs;
37use std::path::{Path, PathBuf};
38
39use serde::Deserialize;
40
41const MANIFEST: &str = "harn.toml";
42
43const MAX_PARENT_DIRS: usize = 16;
51
52#[derive(Debug, Default, Clone)]
55pub struct HarnConfig {
56 pub fmt: FmtConfig,
57 pub lint: LintConfig,
58 pub eval: EvalConfig,
59}
60
61#[derive(Debug, Default, Clone, Deserialize)]
62pub struct FmtConfig {
63 #[serde(default, alias = "line-width")]
64 pub line_width: Option<usize>,
65 #[serde(default, alias = "separator-width")]
66 pub separator_width: Option<usize>,
67}
68
69#[derive(Debug, Default, Clone, Deserialize)]
70pub struct LintConfig {
71 #[serde(default)]
72 pub disabled: Option<Vec<String>>,
73 #[serde(default, alias = "require-file-header")]
78 pub require_file_header: Option<bool>,
79 #[serde(default, alias = "complexity-threshold")]
83 pub complexity_threshold: Option<usize>,
84 #[serde(default, alias = "persona-step-allowlist")]
87 pub persona_step_allowlist: Vec<String>,
88 #[serde(default, alias = "template-variant-branch-threshold")]
91 pub template_variant_branch_threshold: Option<usize>,
92}
93
94#[derive(Debug, Default, Clone, Deserialize)]
99pub struct EvalConfig {
100 #[serde(default)]
101 pub fleets: BTreeMap<String, EvalFleet>,
102}
103
104#[derive(Debug, Default, Clone, Deserialize)]
105pub struct EvalFleet {
106 #[serde(default)]
107 pub models: Vec<String>,
108}
109
110#[derive(Debug, Default, Deserialize)]
111struct RawManifest {
112 #[serde(default)]
113 fmt: FmtConfig,
114 #[serde(default)]
115 lint: LintConfig,
116 #[serde(default)]
117 eval: EvalConfig,
118}
119
120#[derive(Debug)]
121pub enum ConfigError {
122 Parse {
123 path: PathBuf,
124 message: String,
125 },
126 #[allow(dead_code)]
127 Io {
128 path: PathBuf,
129 error: std::io::Error,
130 },
131}
132
133impl fmt::Display for ConfigError {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 match self {
136 ConfigError::Parse { path, message } => {
137 write!(f, "failed to parse {}: {message}", path.display())
138 }
139 ConfigError::Io { path, error } => {
140 write!(f, "failed to read {}: {error}", path.display())
141 }
142 }
143 }
144}
145
146impl std::error::Error for ConfigError {}
147
148pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
153 let base = if start.is_absolute() {
156 start.to_path_buf()
157 } else {
158 std::env::current_dir()
159 .unwrap_or_else(|_| PathBuf::from("."))
160 .join(start)
161 };
162
163 let mut cursor: Option<PathBuf> = if base.is_dir() {
164 Some(base)
165 } else {
166 base.parent().map(Path::to_path_buf)
167 };
168
169 let mut steps = 0usize;
170 while let Some(dir) = cursor {
171 if steps >= MAX_PARENT_DIRS {
172 break;
173 }
174 steps += 1;
175 let candidate = dir.join(MANIFEST);
176 if candidate.is_file() {
177 return parse_manifest(&candidate);
178 }
179 if dir.join(".git").exists() {
182 break;
183 }
184 cursor = dir.parent().map(Path::to_path_buf);
185 }
186
187 Ok(HarnConfig::default())
188}
189
190fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
191 let content = match fs::read_to_string(path) {
192 Ok(c) => c,
193 Err(_) => return Ok(HarnConfig::default()),
194 };
195 let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
196 path: path.to_path_buf(),
197 message: e.to_string(),
198 })?;
199 Ok(HarnConfig {
200 fmt: raw.fmt,
201 lint: raw.lint,
202 eval: raw.eval,
203 })
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use std::fs::File;
210 use std::io::Write as _;
211
212 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
213 let path = dir.join(name);
214 let mut f = File::create(&path).expect("create file");
215 f.write_all(content.as_bytes()).expect("write");
216 path
217 }
218
219 #[test]
220 fn no_manifest_yields_defaults() {
221 let tmp = tempfile::tempdir().unwrap();
222 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
223 let cfg = load_for_path(&harn_file).expect("load");
224 assert!(cfg.fmt.line_width.is_none());
225 assert!(cfg.fmt.separator_width.is_none());
226 assert!(cfg.lint.disabled.is_none());
227 assert!(cfg.lint.require_file_header.is_none());
228 }
229
230 #[test]
231 fn full_config_parses() {
232 let tmp = tempfile::tempdir().unwrap();
233 write_file(
234 tmp.path(),
235 "harn.toml",
236 r#"
237[fmt]
238line_width = 120
239separator_width = 60
240
241[lint]
242disabled = ["unused-import", "missing-harndoc"]
243require_file_header = true
244"#,
245 );
246 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
247 let cfg = load_for_path(&harn_file).expect("load");
248 assert_eq!(cfg.fmt.line_width, Some(120));
249 assert_eq!(cfg.fmt.separator_width, Some(60));
250 assert_eq!(
251 cfg.lint.disabled.as_deref(),
252 Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
253 );
254 assert_eq!(cfg.lint.require_file_header, Some(true));
255 }
256
257 #[test]
258 fn partial_config_leaves_other_keys_default() {
259 let tmp = tempfile::tempdir().unwrap();
260 write_file(
261 tmp.path(),
262 "harn.toml",
263 r#"
264[fmt]
265line_width = 80
266"#,
267 );
268 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
269 let cfg = load_for_path(&harn_file).expect("load");
270 assert_eq!(cfg.fmt.line_width, Some(80));
271 assert!(cfg.fmt.separator_width.is_none());
272 assert!(cfg.lint.disabled.is_none());
273 }
274
275 #[test]
276 fn malformed_manifest_is_an_error() {
277 let tmp = tempfile::tempdir().unwrap();
278 write_file(
279 tmp.path(),
280 "harn.toml",
281 "[fmt]\nline_width = \"not-a-number\"\n",
282 );
283 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
284 match load_for_path(&harn_file) {
285 Err(ConfigError::Parse { .. }) => {}
286 other => panic!("expected Parse error, got {other:?}"),
287 }
288 }
289
290 #[test]
291 fn walks_up_two_directories() {
292 let tmp = tempfile::tempdir().unwrap();
293 let root = tmp.path();
294 write_file(
295 root,
296 "harn.toml",
297 r#"
298[fmt]
299separator_width = 42
300"#,
301 );
302 let sub = root.join("a").join("b");
303 std::fs::create_dir_all(&sub).unwrap();
304 let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
305 let cfg = load_for_path(&harn_file).expect("load");
306 assert_eq!(cfg.fmt.separator_width, Some(42));
307 }
308
309 #[test]
310 fn kebab_case_keys_are_accepted() {
311 let tmp = tempfile::tempdir().unwrap();
315 write_file(
316 tmp.path(),
317 "harn.toml",
318 r#"
319[fmt]
320line-width = 110
321separator-width = 72
322
323[lint]
324require-file-header = true
325"#,
326 );
327 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
328 let cfg = load_for_path(&harn_file).expect("load");
329 assert_eq!(cfg.fmt.line_width, Some(110));
330 assert_eq!(cfg.fmt.separator_width, Some(72));
331 assert_eq!(cfg.lint.require_file_header, Some(true));
332 }
333
334 #[test]
335 fn walk_stops_at_git_boundary() {
336 let tmp = tempfile::tempdir().unwrap();
341 let outer = tmp.path();
342 write_file(
343 outer,
344 "harn.toml",
345 r#"
346[fmt]
347line_width = 999
348"#,
349 );
350 let project = outer.join("project");
351 std::fs::create_dir_all(&project).unwrap();
352 std::fs::create_dir_all(project.join(".git")).unwrap();
353 let inner = project.join("src");
354 std::fs::create_dir_all(&inner).unwrap();
355 let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
356 let cfg = load_for_path(&harn_file).expect("load");
357 assert!(
358 cfg.fmt.line_width.is_none(),
359 "must not pick up harn.toml from above the .git boundary: got {:?}",
360 cfg.fmt.line_width,
361 );
362 }
363
364 #[test]
365 fn walk_stops_at_max_depth() {
366 let tmp = tempfile::tempdir().unwrap();
370 let mut dir = tmp.path().to_path_buf();
371 for i in 0..(MAX_PARENT_DIRS + 4) {
372 dir = dir.join(format!("lvl{i}"));
373 }
374 std::fs::create_dir_all(&dir).unwrap();
375 let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
376 let cfg = load_for_path(&harn_file).expect("load");
380 assert!(cfg.fmt.line_width.is_none());
381 }
382
383 #[test]
384 fn eval_fleets_parse_into_named_lookups() {
385 let tmp = tempfile::tempdir().unwrap();
386 write_file(
387 tmp.path(),
388 "harn.toml",
389 r#"
390[eval.fleets.frontier]
391models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
392
393[eval.fleets.local]
394models = ["ollama:qwen3.5"]
395"#,
396 );
397 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
398 let cfg = load_for_path(&harn_file).expect("load");
399 assert_eq!(cfg.eval.fleets.len(), 2);
400 assert_eq!(
401 cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
402 Some(
403 [
404 "claude-opus-4-7".to_string(),
405 "gpt-5".to_string(),
406 "gemini-2.5-pro".to_string(),
407 ]
408 .as_slice()
409 ),
410 );
411 assert_eq!(
412 cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
413 Some(["ollama:qwen3.5".to_string()].as_slice()),
414 );
415 }
416
417 #[test]
418 fn ignores_unrelated_sections() {
419 let tmp = tempfile::tempdir().unwrap();
422 write_file(
423 tmp.path(),
424 "harn.toml",
425 r#"
426[package]
427name = "demo"
428version = "0.1.0"
429
430[dependencies]
431foo = { path = "../foo" }
432
433[fmt]
434line_width = 77
435"#,
436 );
437 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
438 let cfg = load_for_path(&harn_file).expect("load");
439 assert_eq!(cfg.fmt.line_width, Some(77));
440 }
441}