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 = "template-variant-branch-threshold")]
81 pub template_variant_branch_threshold: Option<usize>,
82 #[serde(default)]
85 pub severity: std::collections::HashMap<String, LintSeverity>,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum LintSeverity {
91 Info,
92 Warning,
93 Error,
94}
95
96impl<'de> Deserialize<'de> for LintSeverity {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98 where
99 D: serde::Deserializer<'de>,
100 {
101 let value = String::deserialize(deserializer)?;
102 match value.to_ascii_lowercase().as_str() {
103 "info" => Ok(Self::Info),
104 "warning" | "warn" => Ok(Self::Warning),
105 "error" => Ok(Self::Error),
106 other => Err(serde::de::Error::custom(format!(
107 "unknown lint severity `{other}`; expected `info`, `warning`, or `error`"
108 ))),
109 }
110 }
111}
112
113#[derive(Debug, Default, Clone, Deserialize)]
118pub struct EvalConfig {
119 #[serde(default)]
120 pub fleets: BTreeMap<String, EvalFleet>,
121}
122
123#[derive(Debug, Default, Clone, Deserialize)]
124pub struct EvalFleet {
125 #[serde(default)]
126 pub models: Vec<String>,
127}
128
129#[derive(Debug, Default, Deserialize)]
130struct RawManifest {
131 #[serde(default)]
132 fmt: FmtConfig,
133 #[serde(default)]
134 lint: LintConfig,
135 #[serde(default)]
136 eval: EvalConfig,
137}
138
139#[derive(Debug)]
140pub enum ConfigError {
141 Parse {
142 path: PathBuf,
143 message: String,
144 },
145 Io {
146 path: PathBuf,
147 error: std::io::Error,
148 },
149}
150
151impl fmt::Display for ConfigError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 ConfigError::Parse { path, message } => {
155 write!(f, "failed to parse {}: {message}", path.display())
156 }
157 ConfigError::Io { path, error } => {
158 write!(f, "failed to read {}: {error}", path.display())
159 }
160 }
161 }
162}
163
164impl std::error::Error for ConfigError {}
165
166pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
172 match crate::manifest_walk::find_nearest_manifest(start) {
173 Some(found) => parse_manifest(&found.path),
174 None => Ok(HarnConfig::default()),
175 }
176}
177
178fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
179 let content = match fs::read_to_string(path) {
180 Ok(c) => c,
181 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
186 return Ok(HarnConfig::default());
187 }
188 Err(error) => {
189 return Err(ConfigError::Io {
190 path: path.to_path_buf(),
191 error,
192 });
193 }
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 assert!(cfg.lint.require_docstrings.is_none());
229 }
230
231 #[test]
232 fn full_config_parses() {
233 let tmp = tempfile::tempdir().unwrap();
234 write_file(
235 tmp.path(),
236 "harn.toml",
237 r#"
238[fmt]
239line_width = 120
240separator_width = 60
241
242[lint]
243disabled = ["unused-import", "missing-harndoc"]
244require_file_header = true
245require_docstrings = true
246
247[lint.severity]
248missing-harndoc = "ERROR"
249unused-import = "warn"
250"#,
251 );
252 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
253 let cfg = load_for_path(&harn_file).expect("load");
254 assert_eq!(cfg.fmt.line_width, Some(120));
255 assert_eq!(cfg.fmt.separator_width, Some(60));
256 assert_eq!(
257 cfg.lint.disabled.as_deref(),
258 Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
259 );
260 assert_eq!(cfg.lint.require_file_header, Some(true));
261 assert_eq!(cfg.lint.require_docstrings, Some(true));
262 assert_eq!(
263 cfg.lint.severity,
264 std::collections::HashMap::from([
265 ("missing-harndoc".to_string(), LintSeverity::Error,),
266 ("unused-import".to_string(), LintSeverity::Warning),
267 ])
268 );
269 }
270
271 #[test]
272 fn partial_config_leaves_other_keys_default() {
273 let tmp = tempfile::tempdir().unwrap();
274 write_file(
275 tmp.path(),
276 "harn.toml",
277 r"
278[fmt]
279line_width = 80
280",
281 );
282 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
283 let cfg = load_for_path(&harn_file).expect("load");
284 assert_eq!(cfg.fmt.line_width, Some(80));
285 assert!(cfg.fmt.separator_width.is_none());
286 assert!(cfg.lint.disabled.is_none());
287 }
288
289 #[test]
290 fn malformed_manifest_is_an_error() {
291 let tmp = tempfile::tempdir().unwrap();
292 write_file(
293 tmp.path(),
294 "harn.toml",
295 "[fmt]\nline_width = \"not-a-number\"\n",
296 );
297 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
298 match load_for_path(&harn_file) {
299 Err(ConfigError::Parse { .. }) => {}
300 other => panic!("expected Parse error, got {other:?}"),
301 }
302 }
303
304 #[test]
305 fn unknown_lint_severity_is_a_config_error() {
306 let tmp = tempfile::tempdir().unwrap();
307 write_file(
308 tmp.path(),
309 "harn.toml",
310 "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
311 );
312 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
313 let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
314 let ConfigError::Parse { path, message } = error else {
315 panic!("expected a typed parse error, got {error:?}");
316 };
317 assert_eq!(path, tmp.path().join("harn.toml"));
318 assert!(
319 message
320 .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
321 "serde/toml location prose may vary, but the owned reason must survive: {message}"
322 );
323 }
324
325 #[test]
326 fn walks_up_two_directories() {
327 let tmp = tempfile::tempdir().unwrap();
328 let root = tmp.path();
329 write_file(
330 root,
331 "harn.toml",
332 r"
333[fmt]
334separator_width = 42
335",
336 );
337 let sub = root.join("a").join("b");
338 std::fs::create_dir_all(&sub).unwrap();
339 let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
340 let cfg = load_for_path(&harn_file).expect("load");
341 assert_eq!(cfg.fmt.separator_width, Some(42));
342 }
343
344 #[test]
345 fn kebab_case_keys_are_accepted() {
346 let tmp = tempfile::tempdir().unwrap();
350 write_file(
351 tmp.path(),
352 "harn.toml",
353 r"
354[fmt]
355line-width = 110
356separator-width = 72
357
358[lint]
359require-file-header = true
360require-docstrings = true
361",
362 );
363 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
364 let cfg = load_for_path(&harn_file).expect("load");
365 assert_eq!(cfg.fmt.line_width, Some(110));
366 assert_eq!(cfg.fmt.separator_width, Some(72));
367 assert_eq!(cfg.lint.require_file_header, Some(true));
368 assert_eq!(cfg.lint.require_docstrings, Some(true));
369 }
370
371 #[test]
372 fn walk_stops_at_git_boundary() {
373 let tmp = tempfile::tempdir().unwrap();
378 let outer = tmp.path();
379 write_file(
380 outer,
381 "harn.toml",
382 r"
383[fmt]
384line_width = 999
385",
386 );
387 let project = outer.join("project");
388 std::fs::create_dir_all(&project).unwrap();
389 std::fs::create_dir_all(project.join(".git")).unwrap();
390 let inner = project.join("src");
391 std::fs::create_dir_all(&inner).unwrap();
392 let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
393 let cfg = load_for_path(&harn_file).expect("load");
394 assert!(
395 cfg.fmt.line_width.is_none(),
396 "must not pick up harn.toml from above the .git boundary: got {:?}",
397 cfg.fmt.line_width,
398 );
399 }
400
401 #[test]
402 fn walk_stops_at_max_depth() {
403 let tmp = tempfile::tempdir().unwrap();
407 let mut dir = tmp.path().to_path_buf();
408 for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
409 dir = dir.join(format!("lvl{i}"));
410 }
411 std::fs::create_dir_all(&dir).unwrap();
412 let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
413 let cfg = load_for_path(&harn_file).expect("load");
417 assert!(cfg.fmt.line_width.is_none());
418 }
419
420 #[test]
421 fn eval_fleets_parse_into_named_lookups() {
422 let tmp = tempfile::tempdir().unwrap();
423 write_file(
424 tmp.path(),
425 "harn.toml",
426 r#"
427[eval.fleets.frontier]
428models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
429
430[eval.fleets.local]
431models = ["ollama:qwen3.5"]
432"#,
433 );
434 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
435 let cfg = load_for_path(&harn_file).expect("load");
436 assert_eq!(cfg.eval.fleets.len(), 2);
437 assert_eq!(
438 cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
439 Some(
440 [
441 "claude-opus-4-7".to_string(),
442 "gpt-5".to_string(),
443 "gemini-2.5-pro".to_string(),
444 ]
445 .as_slice()
446 ),
447 );
448 assert_eq!(
449 cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
450 Some(["ollama:qwen3.5".to_string()].as_slice()),
451 );
452 }
453
454 #[test]
455 fn ignores_unrelated_sections() {
456 let tmp = tempfile::tempdir().unwrap();
459 write_file(
460 tmp.path(),
461 "harn.toml",
462 r#"
463[package]
464name = "demo"
465version = "0.1.0"
466
467[dependencies]
468foo = { path = "../foo" }
469
470[fmt]
471line_width = 77
472"#,
473 );
474 let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
475 let cfg = load_for_path(&harn_file).expect("load");
476 assert_eq!(cfg.fmt.line_width, Some(77));
477 }
478}