1use std::collections::BTreeMap;
33use std::fmt;
34use std::fs;
35use std::path::{Path, PathBuf};
36
37use serde::Deserialize;
38
39const MANIFEST: &str = "harn.toml";
40
41const MAX_PARENT_DIRS: usize = 16;
49
50#[derive(Debug, Default, Clone)]
52pub struct HarnConfig {
53 pub fmt: FmtConfig,
54 pub lint: LintConfig,
55 pub eval: EvalConfig,
56}
57
58#[derive(Debug, Default, Clone, Deserialize)]
59pub struct FmtConfig {
60 #[serde(default, alias = "line-width")]
61 pub line_width: Option<usize>,
62 #[serde(default, alias = "separator-width")]
63 pub separator_width: Option<usize>,
64}
65
66#[derive(Debug, Default, Clone, Deserialize)]
67pub struct LintConfig {
68 #[serde(default)]
69 pub disabled: Option<Vec<String>>,
70 #[serde(default, alias = "require-file-header")]
75 pub require_file_header: Option<bool>,
76 #[serde(default, alias = "require-docstrings")]
80 pub require_docstrings: Option<bool>,
81 #[serde(default, alias = "require-public-api-types")]
84 pub require_public_api_types: 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)]
101 pub severity: std::collections::HashMap<String, LintSeverity>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum LintSeverity {
107 Info,
108 Warning,
109 Error,
110}
111
112impl<'de> Deserialize<'de> for LintSeverity {
113 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114 where
115 D: serde::Deserializer<'de>,
116 {
117 let value = String::deserialize(deserializer)?;
118 match value.to_ascii_lowercase().as_str() {
119 "info" => Ok(Self::Info),
120 "warning" | "warn" => Ok(Self::Warning),
121 "error" => Ok(Self::Error),
122 other => Err(serde::de::Error::custom(format!(
123 "unknown lint severity `{other}`; expected `info`, `warning`, or `error`"
124 ))),
125 }
126 }
127}
128
129#[derive(Debug, Default, Clone, Deserialize)]
134pub struct EvalConfig {
135 #[serde(default)]
136 pub fleets: BTreeMap<String, EvalFleet>,
137}
138
139#[derive(Debug, Default, Clone, Deserialize)]
140pub struct EvalFleet {
141 #[serde(default)]
142 pub models: Vec<String>,
143}
144
145#[derive(Debug, Default, Deserialize)]
146struct RawManifest {
147 #[serde(default)]
148 fmt: FmtConfig,
149 #[serde(default)]
150 lint: LintConfig,
151 #[serde(default)]
152 eval: EvalConfig,
153}
154
155#[derive(Debug)]
156pub enum ConfigError {
157 Parse {
158 path: PathBuf,
159 message: String,
160 },
161 Io {
162 path: PathBuf,
163 error: std::io::Error,
164 },
165}
166
167impl fmt::Display for ConfigError {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 ConfigError::Parse { path, message } => {
171 write!(f, "failed to parse {}: {message}", path.display())
172 }
173 ConfigError::Io { path, error } => {
174 write!(f, "failed to read {}: {error}", path.display())
175 }
176 }
177 }
178}
179
180impl std::error::Error for ConfigError {}
181
182pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
187 let base = if start.is_absolute() {
190 start.to_path_buf()
191 } else {
192 std::env::current_dir()
193 .unwrap_or_else(|_| PathBuf::from("."))
194 .join(start)
195 };
196
197 let mut cursor: Option<PathBuf> = if base.is_dir() {
198 Some(base)
199 } else {
200 base.parent().map(Path::to_path_buf)
201 };
202
203 let mut steps = 0usize;
204 while let Some(dir) = cursor {
205 if steps >= MAX_PARENT_DIRS {
206 break;
207 }
208 steps += 1;
209 let candidate = dir.join(MANIFEST);
210 if candidate.is_file() {
211 return parse_manifest(&candidate);
212 }
213 if dir.join(".git").exists() {
216 break;
217 }
218 cursor = dir.parent().map(Path::to_path_buf);
219 }
220
221 Ok(HarnConfig::default())
222}
223
224fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
225 let content = match fs::read_to_string(path) {
226 Ok(c) => c,
227 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
232 return Ok(HarnConfig::default());
233 }
234 Err(error) => {
235 return Err(ConfigError::Io {
236 path: path.to_path_buf(),
237 error,
238 });
239 }
240 };
241 let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
242 path: path.to_path_buf(),
243 message: e.to_string(),
244 })?;
245 Ok(HarnConfig {
246 fmt: raw.fmt,
247 lint: raw.lint,
248 eval: raw.eval,
249 })
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use std::fs::File;
256 use std::io::Write as _;
257
258 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
259 let path = dir.join(name);
260 let mut f = File::create(&path).expect("create file");
261 f.write_all(content.as_bytes()).expect("write");
262 path
263 }
264
265 #[test]
266 fn no_manifest_yields_defaults() {
267 let tmp = tempfile::tempdir().unwrap();
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!(cfg.fmt.line_width.is_none());
271 assert!(cfg.fmt.separator_width.is_none());
272 assert!(cfg.lint.disabled.is_none());
273 assert!(cfg.lint.require_file_header.is_none());
274 assert!(cfg.lint.require_docstrings.is_none());
275 assert!(cfg.lint.require_public_api_types.is_none());
276 }
277
278 #[test]
279 fn full_config_parses() {
280 let tmp = tempfile::tempdir().unwrap();
281 write_file(
282 tmp.path(),
283 "harn.toml",
284 r#"
285[fmt]
286line_width = 120
287separator_width = 60
288
289[lint]
290disabled = ["unused-import", "missing-harndoc"]
291require_file_header = true
292require_docstrings = true
293require_public_api_types = true
294
295[lint.severity]
296missing-public-api-type = "ERROR"
297unused-import = "warn"
298"#,
299 );
300 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
301 let cfg = load_for_path(&harn_file).expect("load");
302 assert_eq!(cfg.fmt.line_width, Some(120));
303 assert_eq!(cfg.fmt.separator_width, Some(60));
304 assert_eq!(
305 cfg.lint.disabled.as_deref(),
306 Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
307 );
308 assert_eq!(cfg.lint.require_file_header, Some(true));
309 assert_eq!(cfg.lint.require_docstrings, Some(true));
310 assert_eq!(cfg.lint.require_public_api_types, Some(true));
311 assert_eq!(
312 cfg.lint.severity,
313 std::collections::HashMap::from([
314 ("missing-public-api-type".to_string(), LintSeverity::Error,),
315 ("unused-import".to_string(), LintSeverity::Warning),
316 ])
317 );
318 }
319
320 #[test]
321 fn partial_config_leaves_other_keys_default() {
322 let tmp = tempfile::tempdir().unwrap();
323 write_file(
324 tmp.path(),
325 "harn.toml",
326 r"
327[fmt]
328line_width = 80
329",
330 );
331 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
332 let cfg = load_for_path(&harn_file).expect("load");
333 assert_eq!(cfg.fmt.line_width, Some(80));
334 assert!(cfg.fmt.separator_width.is_none());
335 assert!(cfg.lint.disabled.is_none());
336 }
337
338 #[test]
339 fn malformed_manifest_is_an_error() {
340 let tmp = tempfile::tempdir().unwrap();
341 write_file(
342 tmp.path(),
343 "harn.toml",
344 "[fmt]\nline_width = \"not-a-number\"\n",
345 );
346 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
347 match load_for_path(&harn_file) {
348 Err(ConfigError::Parse { .. }) => {}
349 other => panic!("expected Parse error, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn unknown_lint_severity_is_a_config_error() {
355 let tmp = tempfile::tempdir().unwrap();
356 write_file(
357 tmp.path(),
358 "harn.toml",
359 "[lint.severity]\nmissing-public-api-type = \"urgent\"\n",
360 );
361 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
362 let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
363 let ConfigError::Parse { path, message } = error else {
364 panic!("expected a typed parse error, got {error:?}");
365 };
366 assert_eq!(path, tmp.path().join("harn.toml"));
367 assert!(
368 message
369 .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
370 "serde/toml location prose may vary, but the owned reason must survive: {message}"
371 );
372 }
373
374 #[test]
375 fn walks_up_two_directories() {
376 let tmp = tempfile::tempdir().unwrap();
377 let root = tmp.path();
378 write_file(
379 root,
380 "harn.toml",
381 r"
382[fmt]
383separator_width = 42
384",
385 );
386 let sub = root.join("a").join("b");
387 std::fs::create_dir_all(&sub).unwrap();
388 let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
389 let cfg = load_for_path(&harn_file).expect("load");
390 assert_eq!(cfg.fmt.separator_width, Some(42));
391 }
392
393 #[test]
394 fn kebab_case_keys_are_accepted() {
395 let tmp = tempfile::tempdir().unwrap();
399 write_file(
400 tmp.path(),
401 "harn.toml",
402 r"
403[fmt]
404line-width = 110
405separator-width = 72
406
407[lint]
408require-file-header = true
409require-docstrings = true
410require-public-api-types = true
411",
412 );
413 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
414 let cfg = load_for_path(&harn_file).expect("load");
415 assert_eq!(cfg.fmt.line_width, Some(110));
416 assert_eq!(cfg.fmt.separator_width, Some(72));
417 assert_eq!(cfg.lint.require_file_header, Some(true));
418 assert_eq!(cfg.lint.require_docstrings, Some(true));
419 assert_eq!(cfg.lint.require_public_api_types, Some(true));
420 }
421
422 #[test]
423 fn walk_stops_at_git_boundary() {
424 let tmp = tempfile::tempdir().unwrap();
429 let outer = tmp.path();
430 write_file(
431 outer,
432 "harn.toml",
433 r"
434[fmt]
435line_width = 999
436",
437 );
438 let project = outer.join("project");
439 std::fs::create_dir_all(&project).unwrap();
440 std::fs::create_dir_all(project.join(".git")).unwrap();
441 let inner = project.join("src");
442 std::fs::create_dir_all(&inner).unwrap();
443 let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
444 let cfg = load_for_path(&harn_file).expect("load");
445 assert!(
446 cfg.fmt.line_width.is_none(),
447 "must not pick up harn.toml from above the .git boundary: got {:?}",
448 cfg.fmt.line_width,
449 );
450 }
451
452 #[test]
453 fn walk_stops_at_max_depth() {
454 let tmp = tempfile::tempdir().unwrap();
458 let mut dir = tmp.path().to_path_buf();
459 for i in 0..(MAX_PARENT_DIRS + 4) {
460 dir = dir.join(format!("lvl{i}"));
461 }
462 std::fs::create_dir_all(&dir).unwrap();
463 let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
464 let cfg = load_for_path(&harn_file).expect("load");
468 assert!(cfg.fmt.line_width.is_none());
469 }
470
471 #[test]
472 fn eval_fleets_parse_into_named_lookups() {
473 let tmp = tempfile::tempdir().unwrap();
474 write_file(
475 tmp.path(),
476 "harn.toml",
477 r#"
478[eval.fleets.frontier]
479models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
480
481[eval.fleets.local]
482models = ["ollama:qwen3.5"]
483"#,
484 );
485 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
486 let cfg = load_for_path(&harn_file).expect("load");
487 assert_eq!(cfg.eval.fleets.len(), 2);
488 assert_eq!(
489 cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
490 Some(
491 [
492 "claude-opus-4-7".to_string(),
493 "gpt-5".to_string(),
494 "gemini-2.5-pro".to_string(),
495 ]
496 .as_slice()
497 ),
498 );
499 assert_eq!(
500 cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
501 Some(["ollama:qwen3.5".to_string()].as_slice()),
502 );
503 }
504
505 #[test]
506 fn ignores_unrelated_sections() {
507 let tmp = tempfile::tempdir().unwrap();
510 write_file(
511 tmp.path(),
512 "harn.toml",
513 r#"
514[package]
515name = "demo"
516version = "0.1.0"
517
518[dependencies]
519foo = { path = "../foo" }
520
521[fmt]
522line_width = 77
523"#,
524 );
525 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
526 let cfg = load_for_path(&harn_file).expect("load");
527 assert_eq!(cfg.fmt.line_width, Some(77));
528 }
529}