1use std::collections::BTreeMap;
32use std::fmt;
33use std::fs;
34use std::path::{Path, PathBuf};
35
36use serde::Deserialize;
37
38#[derive(Debug, Default, Clone)]
40pub struct HarnConfig {
41 pub fmt: FmtConfig,
42 pub lint: LintConfig,
43 pub eval: EvalConfig,
44}
45
46#[derive(Debug, Default, Clone, Deserialize)]
47pub struct FmtConfig {
48 #[serde(default, alias = "line-width")]
49 pub line_width: Option<usize>,
50 #[serde(default, alias = "separator-width")]
51 pub separator_width: Option<usize>,
52}
53
54#[derive(Debug, Default, Clone, Deserialize)]
55pub struct LintConfig {
56 #[serde(default)]
57 pub disabled: Option<Vec<String>>,
58 #[serde(default, alias = "require-file-header")]
63 pub require_file_header: Option<bool>,
64 #[serde(default, alias = "require-docstrings")]
68 pub require_docstrings: Option<bool>,
69 #[serde(default, alias = "complexity-threshold")]
73 pub complexity_threshold: Option<usize>,
74 #[serde(default, alias = "persona-step-allowlist")]
77 pub persona_step_allowlist: Vec<String>,
78 #[serde(default, alias = "test-root-components")]
83 pub test_root_components: Vec<String>,
84 #[serde(default, alias = "template-variant-branch-threshold")]
87 pub template_variant_branch_threshold: Option<usize>,
88 #[serde(default)]
91 pub severity: std::collections::HashMap<String, LintSeverity>,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum LintSeverity {
97 Info,
98 Warning,
99 Error,
100}
101
102impl<'de> Deserialize<'de> for LintSeverity {
103 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
104 where
105 D: serde::Deserializer<'de>,
106 {
107 let value = String::deserialize(deserializer)?;
108 match value.to_ascii_lowercase().as_str() {
109 "info" => Ok(Self::Info),
110 "warning" | "warn" => Ok(Self::Warning),
111 "error" => Ok(Self::Error),
112 other => Err(serde::de::Error::custom(format!(
113 "unknown lint severity `{other}`; expected `info`, `warning`, or `error`"
114 ))),
115 }
116 }
117}
118
119#[derive(Debug, Default, Clone, Deserialize)]
124pub struct EvalConfig {
125 #[serde(default)]
126 pub fleets: BTreeMap<String, EvalFleet>,
127}
128
129#[derive(Debug, Default, Clone, Deserialize)]
130pub struct EvalFleet {
131 #[serde(default)]
132 pub models: Vec<String>,
133}
134
135#[derive(Debug, Default, Deserialize)]
136struct RawManifest {
137 #[serde(default)]
138 fmt: FmtConfig,
139 #[serde(default)]
140 lint: LintConfig,
141 #[serde(default)]
142 eval: EvalConfig,
143}
144
145#[derive(Debug)]
146#[non_exhaustive]
147pub enum ConfigError {
148 Parse {
149 path: PathBuf,
150 message: String,
151 },
152 Io {
153 path: PathBuf,
154 error: std::io::Error,
155 },
156}
157
158impl fmt::Display for ConfigError {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 ConfigError::Parse { path, message } => {
162 write!(f, "failed to parse {}: {message}", path.display())
163 }
164 ConfigError::Io { path, error } => {
165 write!(f, "failed to read {}: {error}", path.display())
166 }
167 }
168 }
169}
170
171impl std::error::Error for ConfigError {}
172
173pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
179 match crate::manifest_walk::find_nearest_manifest(start) {
180 Some(found) => parse_manifest(&found.path),
181 None => Ok(HarnConfig::default()),
182 }
183}
184
185fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
186 let content = match fs::read_to_string(path) {
187 Ok(c) => c,
188 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
193 return Ok(HarnConfig::default());
194 }
195 Err(error) => {
196 return Err(ConfigError::Io {
197 path: path.to_path_buf(),
198 error,
199 });
200 }
201 };
202 let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
203 path: path.to_path_buf(),
204 message: e.to_string(),
205 })?;
206 Ok(HarnConfig {
207 fmt: raw.fmt,
208 lint: raw.lint,
209 eval: raw.eval,
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::fs::File;
217 use std::io::Write as _;
218
219 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
220 let path = dir.join(name);
221 let mut f = File::create(&path).expect("create file");
222 f.write_all(content.as_bytes()).expect("write");
223 path
224 }
225
226 #[test]
227 fn no_manifest_yields_defaults() {
228 let tmp = tempfile::tempdir().unwrap();
229 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
230 let cfg = load_for_path(&harn_file).expect("load");
231 assert!(cfg.fmt.line_width.is_none());
232 assert!(cfg.fmt.separator_width.is_none());
233 assert!(cfg.lint.disabled.is_none());
234 assert!(cfg.lint.require_file_header.is_none());
235 assert!(cfg.lint.require_docstrings.is_none());
236 }
237
238 #[test]
239 fn full_config_parses() {
240 let tmp = tempfile::tempdir().unwrap();
241 write_file(
242 tmp.path(),
243 "harn.toml",
244 r#"
245[fmt]
246line_width = 120
247separator_width = 60
248
249[lint]
250disabled = ["unused-import", "missing-harndoc"]
251require_file_header = true
252require_docstrings = true
253
254[lint.severity]
255missing-harndoc = "ERROR"
256unused-import = "warn"
257"#,
258 );
259 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
260 let cfg = load_for_path(&harn_file).expect("load");
261 assert_eq!(cfg.fmt.line_width, Some(120));
262 assert_eq!(cfg.fmt.separator_width, Some(60));
263 assert_eq!(
264 cfg.lint.disabled.as_deref(),
265 Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
266 );
267 assert_eq!(cfg.lint.require_file_header, Some(true));
268 assert_eq!(cfg.lint.require_docstrings, Some(true));
269 assert_eq!(
270 cfg.lint.severity,
271 std::collections::HashMap::from([
272 ("missing-harndoc".to_string(), LintSeverity::Error,),
273 ("unused-import".to_string(), LintSeverity::Warning),
274 ])
275 );
276 }
277
278 #[test]
279 fn partial_config_leaves_other_keys_default() {
280 let tmp = tempfile::tempdir().unwrap();
281 write_file(
282 tmp.path(),
283 "harn.toml",
284 r"
285[fmt]
286line_width = 80
287",
288 );
289 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
290 let cfg = load_for_path(&harn_file).expect("load");
291 assert_eq!(cfg.fmt.line_width, Some(80));
292 assert!(cfg.fmt.separator_width.is_none());
293 assert!(cfg.lint.disabled.is_none());
294 }
295
296 #[test]
297 fn malformed_manifest_is_an_error() {
298 let tmp = tempfile::tempdir().unwrap();
299 write_file(
300 tmp.path(),
301 "harn.toml",
302 "[fmt]\nline_width = \"not-a-number\"\n",
303 );
304 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
305 match load_for_path(&harn_file) {
306 Err(ConfigError::Parse { .. }) => {}
307 other => panic!("expected Parse error, got {other:?}"),
308 }
309 }
310
311 #[test]
312 fn unknown_lint_severity_is_a_config_error() {
313 let tmp = tempfile::tempdir().unwrap();
314 write_file(
315 tmp.path(),
316 "harn.toml",
317 "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
318 );
319 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
320 let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
321 let ConfigError::Parse { path, message } = error else {
322 panic!("expected a typed parse error, got {error:?}");
323 };
324 assert_eq!(path, tmp.path().join("harn.toml"));
325 assert!(
326 message
327 .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
328 "serde/toml location prose may vary, but the owned reason must survive: {message}"
329 );
330 }
331
332 #[test]
333 fn walks_up_two_directories() {
334 let tmp = tempfile::tempdir().unwrap();
335 let root = tmp.path();
336 write_file(
337 root,
338 "harn.toml",
339 r"
340[fmt]
341separator_width = 42
342",
343 );
344 let sub = root.join("a").join("b");
345 std::fs::create_dir_all(&sub).unwrap();
346 let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
347 let cfg = load_for_path(&harn_file).expect("load");
348 assert_eq!(cfg.fmt.separator_width, Some(42));
349 }
350
351 #[test]
352 fn kebab_case_keys_are_accepted() {
353 let tmp = tempfile::tempdir().unwrap();
357 write_file(
358 tmp.path(),
359 "harn.toml",
360 r"
361[fmt]
362line-width = 110
363separator-width = 72
364
365[lint]
366require-file-header = true
367require-docstrings = true
368",
369 );
370 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
371 let cfg = load_for_path(&harn_file).expect("load");
372 assert_eq!(cfg.fmt.line_width, Some(110));
373 assert_eq!(cfg.fmt.separator_width, Some(72));
374 assert_eq!(cfg.lint.require_file_header, Some(true));
375 assert_eq!(cfg.lint.require_docstrings, Some(true));
376 }
377
378 #[test]
379 fn walk_stops_at_git_boundary() {
380 let tmp = tempfile::tempdir().unwrap();
385 let outer = tmp.path();
386 write_file(
387 outer,
388 "harn.toml",
389 r"
390[fmt]
391line_width = 999
392",
393 );
394 let project = outer.join("project");
395 std::fs::create_dir_all(&project).unwrap();
396 std::fs::create_dir_all(project.join(".git")).unwrap();
397 let inner = project.join("src");
398 std::fs::create_dir_all(&inner).unwrap();
399 let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
400 let cfg = load_for_path(&harn_file).expect("load");
401 assert!(
402 cfg.fmt.line_width.is_none(),
403 "must not pick up harn.toml from above the .git boundary: got {:?}",
404 cfg.fmt.line_width,
405 );
406 }
407
408 #[test]
409 fn walk_stops_at_max_depth() {
410 let tmp = tempfile::tempdir().unwrap();
414 let mut dir = tmp.path().to_path_buf();
415 for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
416 dir = dir.join(format!("lvl{i}"));
417 }
418 std::fs::create_dir_all(&dir).unwrap();
419 let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
420 let cfg = load_for_path(&harn_file).expect("load");
424 assert!(cfg.fmt.line_width.is_none());
425 }
426
427 #[test]
428 fn eval_fleets_parse_into_named_lookups() {
429 let tmp = tempfile::tempdir().unwrap();
430 write_file(
431 tmp.path(),
432 "harn.toml",
433 r#"
434[eval.fleets.frontier]
435models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
436
437[eval.fleets.local]
438models = ["ollama:qwen3.5"]
439"#,
440 );
441 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
442 let cfg = load_for_path(&harn_file).expect("load");
443 assert_eq!(cfg.eval.fleets.len(), 2);
444 assert_eq!(
445 cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
446 Some(
447 [
448 "claude-opus-4-7".to_string(),
449 "gpt-5".to_string(),
450 "gemini-2.5-pro".to_string(),
451 ]
452 .as_slice()
453 ),
454 );
455 assert_eq!(
456 cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
457 Some(["ollama:qwen3.5".to_string()].as_slice()),
458 );
459 }
460
461 #[test]
462 fn ignores_unrelated_sections() {
463 let tmp = tempfile::tempdir().unwrap();
466 write_file(
467 tmp.path(),
468 "harn.toml",
469 r#"
470[package]
471name = "demo"
472version = "0.1.0"
473
474[dependencies]
475foo = { path = "../foo" }
476
477[fmt]
478line_width = 77
479"#,
480 );
481 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
482 let cfg = load_for_path(&harn_file).expect("load");
483 assert_eq!(cfg.fmt.line_width, Some(77));
484 }
485}