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