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