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)]
146pub enum ConfigError {
147 Parse {
148 path: PathBuf,
149 message: String,
150 },
151 Io {
152 path: PathBuf,
153 error: std::io::Error,
154 },
155}
156
157impl fmt::Display for ConfigError {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 match self {
160 ConfigError::Parse { path, message } => {
161 write!(f, "failed to parse {}: {message}", path.display())
162 }
163 ConfigError::Io { path, error } => {
164 write!(f, "failed to read {}: {error}", path.display())
165 }
166 }
167 }
168}
169
170impl std::error::Error for ConfigError {}
171
172pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
178 match crate::manifest_walk::find_nearest_manifest(start) {
179 Some(found) => parse_manifest(&found.path),
180 None => Ok(HarnConfig::default()),
181 }
182}
183
184fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
185 let content = match fs::read_to_string(path) {
186 Ok(c) => c,
187 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
192 return Ok(HarnConfig::default());
193 }
194 Err(error) => {
195 return Err(ConfigError::Io {
196 path: path.to_path_buf(),
197 error,
198 });
199 }
200 };
201 let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
202 path: path.to_path_buf(),
203 message: e.to_string(),
204 })?;
205 Ok(HarnConfig {
206 fmt: raw.fmt,
207 lint: raw.lint,
208 eval: raw.eval,
209 })
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::fs::File;
216 use std::io::Write as _;
217
218 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
219 let path = dir.join(name);
220 let mut f = File::create(&path).expect("create file");
221 f.write_all(content.as_bytes()).expect("write");
222 path
223 }
224
225 #[test]
226 fn no_manifest_yields_defaults() {
227 let tmp = tempfile::tempdir().unwrap();
228 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
229 let cfg = load_for_path(&harn_file).expect("load");
230 assert!(cfg.fmt.line_width.is_none());
231 assert!(cfg.fmt.separator_width.is_none());
232 assert!(cfg.lint.disabled.is_none());
233 assert!(cfg.lint.require_file_header.is_none());
234 assert!(cfg.lint.require_docstrings.is_none());
235 }
236
237 #[test]
238 fn full_config_parses() {
239 let tmp = tempfile::tempdir().unwrap();
240 write_file(
241 tmp.path(),
242 "harn.toml",
243 r#"
244[fmt]
245line_width = 120
246separator_width = 60
247
248[lint]
249disabled = ["unused-import", "missing-harndoc"]
250require_file_header = true
251require_docstrings = true
252
253[lint.severity]
254missing-harndoc = "ERROR"
255unused-import = "warn"
256"#,
257 );
258 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
259 let cfg = load_for_path(&harn_file).expect("load");
260 assert_eq!(cfg.fmt.line_width, Some(120));
261 assert_eq!(cfg.fmt.separator_width, Some(60));
262 assert_eq!(
263 cfg.lint.disabled.as_deref(),
264 Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
265 );
266 assert_eq!(cfg.lint.require_file_header, Some(true));
267 assert_eq!(cfg.lint.require_docstrings, Some(true));
268 assert_eq!(
269 cfg.lint.severity,
270 std::collections::HashMap::from([
271 ("missing-harndoc".to_string(), LintSeverity::Error,),
272 ("unused-import".to_string(), LintSeverity::Warning),
273 ])
274 );
275 }
276
277 #[test]
278 fn partial_config_leaves_other_keys_default() {
279 let tmp = tempfile::tempdir().unwrap();
280 write_file(
281 tmp.path(),
282 "harn.toml",
283 r"
284[fmt]
285line_width = 80
286",
287 );
288 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
289 let cfg = load_for_path(&harn_file).expect("load");
290 assert_eq!(cfg.fmt.line_width, Some(80));
291 assert!(cfg.fmt.separator_width.is_none());
292 assert!(cfg.lint.disabled.is_none());
293 }
294
295 #[test]
296 fn malformed_manifest_is_an_error() {
297 let tmp = tempfile::tempdir().unwrap();
298 write_file(
299 tmp.path(),
300 "harn.toml",
301 "[fmt]\nline_width = \"not-a-number\"\n",
302 );
303 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
304 match load_for_path(&harn_file) {
305 Err(ConfigError::Parse { .. }) => {}
306 other => panic!("expected Parse error, got {other:?}"),
307 }
308 }
309
310 #[test]
311 fn unknown_lint_severity_is_a_config_error() {
312 let tmp = tempfile::tempdir().unwrap();
313 write_file(
314 tmp.path(),
315 "harn.toml",
316 "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
317 );
318 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
319 let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
320 let ConfigError::Parse { path, message } = error else {
321 panic!("expected a typed parse error, got {error:?}");
322 };
323 assert_eq!(path, tmp.path().join("harn.toml"));
324 assert!(
325 message
326 .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
327 "serde/toml location prose may vary, but the owned reason must survive: {message}"
328 );
329 }
330
331 #[test]
332 fn walks_up_two_directories() {
333 let tmp = tempfile::tempdir().unwrap();
334 let root = tmp.path();
335 write_file(
336 root,
337 "harn.toml",
338 r"
339[fmt]
340separator_width = 42
341",
342 );
343 let sub = root.join("a").join("b");
344 std::fs::create_dir_all(&sub).unwrap();
345 let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
346 let cfg = load_for_path(&harn_file).expect("load");
347 assert_eq!(cfg.fmt.separator_width, Some(42));
348 }
349
350 #[test]
351 fn kebab_case_keys_are_accepted() {
352 let tmp = tempfile::tempdir().unwrap();
356 write_file(
357 tmp.path(),
358 "harn.toml",
359 r"
360[fmt]
361line-width = 110
362separator-width = 72
363
364[lint]
365require-file-header = true
366require-docstrings = true
367",
368 );
369 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
370 let cfg = load_for_path(&harn_file).expect("load");
371 assert_eq!(cfg.fmt.line_width, Some(110));
372 assert_eq!(cfg.fmt.separator_width, Some(72));
373 assert_eq!(cfg.lint.require_file_header, Some(true));
374 assert_eq!(cfg.lint.require_docstrings, Some(true));
375 }
376
377 #[test]
378 fn walk_stops_at_git_boundary() {
379 let tmp = tempfile::tempdir().unwrap();
384 let outer = tmp.path();
385 write_file(
386 outer,
387 "harn.toml",
388 r"
389[fmt]
390line_width = 999
391",
392 );
393 let project = outer.join("project");
394 std::fs::create_dir_all(&project).unwrap();
395 std::fs::create_dir_all(project.join(".git")).unwrap();
396 let inner = project.join("src");
397 std::fs::create_dir_all(&inner).unwrap();
398 let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
399 let cfg = load_for_path(&harn_file).expect("load");
400 assert!(
401 cfg.fmt.line_width.is_none(),
402 "must not pick up harn.toml from above the .git boundary: got {:?}",
403 cfg.fmt.line_width,
404 );
405 }
406
407 #[test]
408 fn walk_stops_at_max_depth() {
409 let tmp = tempfile::tempdir().unwrap();
413 let mut dir = tmp.path().to_path_buf();
414 for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
415 dir = dir.join(format!("lvl{i}"));
416 }
417 std::fs::create_dir_all(&dir).unwrap();
418 let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
419 let cfg = load_for_path(&harn_file).expect("load");
423 assert!(cfg.fmt.line_width.is_none());
424 }
425
426 #[test]
427 fn eval_fleets_parse_into_named_lookups() {
428 let tmp = tempfile::tempdir().unwrap();
429 write_file(
430 tmp.path(),
431 "harn.toml",
432 r#"
433[eval.fleets.frontier]
434models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
435
436[eval.fleets.local]
437models = ["ollama:qwen3.5"]
438"#,
439 );
440 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
441 let cfg = load_for_path(&harn_file).expect("load");
442 assert_eq!(cfg.eval.fleets.len(), 2);
443 assert_eq!(
444 cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
445 Some(
446 [
447 "claude-opus-4-7".to_string(),
448 "gpt-5".to_string(),
449 "gemini-2.5-pro".to_string(),
450 ]
451 .as_slice()
452 ),
453 );
454 assert_eq!(
455 cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
456 Some(["ollama:qwen3.5".to_string()].as_slice()),
457 );
458 }
459
460 #[test]
461 fn ignores_unrelated_sections() {
462 let tmp = tempfile::tempdir().unwrap();
465 write_file(
466 tmp.path(),
467 "harn.toml",
468 r#"
469[package]
470name = "demo"
471version = "0.1.0"
472
473[dependencies]
474foo = { path = "../foo" }
475
476[fmt]
477line_width = 77
478"#,
479 );
480 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
481 let cfg = load_for_path(&harn_file).expect("load");
482 assert_eq!(cfg.fmt.line_width, Some(77));
483 }
484}