Skip to main content

harn_cli/
config.rs

1//! Lightweight `harn.toml` loader for `harn fmt`, `harn lint`, and
2//! `harn eval prompt --fleet-name <name>`.
3//!
4//! This module is intentionally separate from `crate::package` (which owns
5//! the richer `[check]` + `[dependencies]` manifest model used by
6//! `harn check`, `harn install`, etc.). `harn.toml` can carry both sets of
7//! keys; this loader focuses on the `[fmt]`, `[lint]`, and `[eval.fleets]`
8//! sections and walks up from an input file looking for the nearest
9//! manifest.
10//!
11//! Recognized keys (snake_case, Cargo-style):
12//!
13//! ```toml
14//! [fmt]
15//! line_width = 100
16//! # By default, section-header separators follow line_width.
17//! # Set separator_width to force a fixed width.
18//!
19//! [lint]
20//! disabled = ["unused-import"]
21//! require_file_header = false
22//! require_docstrings = false
23//! complexity_threshold = 25
24//! persona_step_allowlist = ["legacy_helper"]
25//! template_variant_branch_threshold = 3
26//!
27//! # Reusable fleets consumed by `harn eval prompt --fleet-name <name>`.
28//! [eval.fleets.frontier]
29//! models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
30//!
31//! [eval.fleets.local]
32//! models = ["ollama:qwen3.5", "ollama:llama4"]
33//! ```
34
35use std::collections::BTreeMap;
36use std::fmt;
37use std::fs;
38use std::path::{Path, PathBuf};
39
40use serde::Deserialize;
41
42const MANIFEST: &str = "harn.toml";
43
44/// Hard cap on how many parent directories the loader will inspect.
45///
46/// The walk also stops early at a `.git` boundary (the first directory
47/// containing a `.git` child is treated as the project root). The cap
48/// exists to defend against pathological paths, symlink loops, and
49/// accidental pickup of a stray `harn.toml` high up the filesystem
50/// (e.g. a user's home directory or `/tmp`).
51const MAX_PARENT_DIRS: usize = 16;
52
53/// Combined `harn.toml` view used by `harn fmt`, `harn lint`, and
54/// `harn eval prompt`.
55#[derive(Debug, Default, Clone)]
56pub struct HarnConfig {
57    pub fmt: FmtConfig,
58    pub lint: LintConfig,
59    pub eval: EvalConfig,
60}
61
62#[derive(Debug, Default, Clone, Deserialize)]
63pub struct FmtConfig {
64    #[serde(default, alias = "line-width")]
65    pub line_width: Option<usize>,
66    #[serde(default, alias = "separator-width")]
67    pub separator_width: Option<usize>,
68}
69
70#[derive(Debug, Default, Clone, Deserialize)]
71pub struct LintConfig {
72    #[serde(default)]
73    pub disabled: Option<Vec<String>>,
74    /// Opt-in file-header requirement. Accept both snake_case (canonical,
75    /// Cargo-style) and kebab-case (rule-name style) so authors who copy
76    /// the rule's diagnostic name into their TOML don't silently get
77    /// `false`.
78    #[serde(default, alias = "require-file-header")]
79    pub require_file_header: Option<bool>,
80    /// Opt-in docstring requirement: when true, the `missing-harndoc`
81    /// rule warns on public functions without a `/** */` doc comment.
82    /// Off by default — out of the box, `pub fn` needs no docs.
83    #[serde(default, alias = "require-docstrings")]
84    pub require_docstrings: Option<bool>,
85    /// Override the default cyclomatic-complexity warning threshold
86    /// (see `harn_lint::DEFAULT_COMPLEXITY_THRESHOLD`). Accept both
87    /// snake_case and kebab-case for consistency with the other keys.
88    #[serde(default, alias = "complexity-threshold")]
89    pub complexity_threshold: Option<usize>,
90    /// Non-stdlib functions that may be called directly from `@persona`
91    /// bodies without being declared as `@step`.
92    #[serde(default, alias = "persona-step-allowlist")]
93    pub persona_step_allowlist: Vec<String>,
94    /// Threshold for the `template-variant-explosion` rule. Defaults
95    /// to [`harn_lint::DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD`].
96    #[serde(default, alias = "template-variant-branch-threshold")]
97    pub template_variant_branch_threshold: Option<usize>,
98    /// `[lint.severity]` — per-rule severity overrides (#2851), a rule id →
99    /// `"error"` / `"warning"` / `"info"`. Applied after disable-filtering, so
100    /// a project can promote one rule to an error and demote another.
101    #[serde(default)]
102    pub severity: std::collections::HashMap<String, String>,
103}
104
105/// `[eval]` section of `harn.toml`. Reserves a `[eval.fleets.<name>]`
106/// table keyed by fleet name; each entry lists the model selectors
107/// (alias or `provider:model`) consumed by
108/// `harn eval prompt --fleet-name <name>`.
109#[derive(Debug, Default, Clone, Deserialize)]
110pub struct EvalConfig {
111    #[serde(default)]
112    pub fleets: BTreeMap<String, EvalFleet>,
113}
114
115#[derive(Debug, Default, Clone, Deserialize)]
116pub struct EvalFleet {
117    #[serde(default)]
118    pub models: Vec<String>,
119}
120
121#[derive(Debug, Default, Deserialize)]
122struct RawManifest {
123    #[serde(default)]
124    fmt: FmtConfig,
125    #[serde(default)]
126    lint: LintConfig,
127    #[serde(default)]
128    eval: EvalConfig,
129}
130
131#[derive(Debug)]
132pub enum ConfigError {
133    Parse {
134        path: PathBuf,
135        message: String,
136    },
137    Io {
138        path: PathBuf,
139        error: std::io::Error,
140    },
141}
142
143impl fmt::Display for ConfigError {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            ConfigError::Parse { path, message } => {
147                write!(f, "failed to parse {}: {message}", path.display())
148            }
149            ConfigError::Io { path, error } => {
150                write!(f, "failed to read {}: {error}", path.display())
151            }
152        }
153    }
154}
155
156impl std::error::Error for ConfigError {}
157
158/// Walks up from `start` to find the nearest `harn.toml`. Returns
159/// `Ok(HarnConfig::default())` if none is found. Returns `Err` on parse
160/// failure so callers can surface the problem rather than silently ignore
161/// malformed config.
162pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
163    // Normalize to an absolute path so the walk works when `start` is a
164    // non-existent relative path.
165    let base = if start.is_absolute() {
166        start.to_path_buf()
167    } else {
168        std::env::current_dir()
169            .unwrap_or_else(|_| PathBuf::from("."))
170            .join(start)
171    };
172
173    let mut cursor: Option<PathBuf> = if base.is_dir() {
174        Some(base)
175    } else {
176        base.parent().map(Path::to_path_buf)
177    };
178
179    let mut steps = 0usize;
180    while let Some(dir) = cursor {
181        if steps >= MAX_PARENT_DIRS {
182            break;
183        }
184        steps += 1;
185        let candidate = dir.join(MANIFEST);
186        if candidate.is_file() {
187            return parse_manifest(&candidate);
188        }
189        // Stop at a `.git` boundary so a stray `harn.toml` in a parent
190        // project or in `$HOME` is never silently picked up.
191        if dir.join(".git").exists() {
192            break;
193        }
194        cursor = dir.parent().map(Path::to_path_buf);
195    }
196
197    Ok(HarnConfig::default())
198}
199
200fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
201    let content = match fs::read_to_string(path) {
202        Ok(c) => c,
203        // The manifest existed at `is_file()` time; if it vanished in the
204        // race window, fall back to defaults. Any other I/O error (permission
205        // denied, bad symlink) is surfaced so a misconfigured manifest never
206        // silently degrades to default config.
207        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
208            return Ok(HarnConfig::default());
209        }
210        Err(error) => {
211            return Err(ConfigError::Io {
212                path: path.to_path_buf(),
213                error,
214            });
215        }
216    };
217    let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
218        path: path.to_path_buf(),
219        message: e.to_string(),
220    })?;
221    Ok(HarnConfig {
222        fmt: raw.fmt,
223        lint: raw.lint,
224        eval: raw.eval,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::fs::File;
232    use std::io::Write as _;
233
234    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
235        let path = dir.join(name);
236        let mut f = File::create(&path).expect("create file");
237        f.write_all(content.as_bytes()).expect("write");
238        path
239    }
240
241    #[test]
242    fn no_manifest_yields_defaults() {
243        let tmp = tempfile::tempdir().unwrap();
244        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
245        let cfg = load_for_path(&harn_file).expect("load");
246        assert!(cfg.fmt.line_width.is_none());
247        assert!(cfg.fmt.separator_width.is_none());
248        assert!(cfg.lint.disabled.is_none());
249        assert!(cfg.lint.require_file_header.is_none());
250        assert!(cfg.lint.require_docstrings.is_none());
251    }
252
253    #[test]
254    fn full_config_parses() {
255        let tmp = tempfile::tempdir().unwrap();
256        write_file(
257            tmp.path(),
258            "harn.toml",
259            r#"
260[fmt]
261line_width = 120
262separator_width = 60
263
264[lint]
265disabled = ["unused-import", "missing-harndoc"]
266require_file_header = true
267require_docstrings = true
268"#,
269        );
270        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
271        let cfg = load_for_path(&harn_file).expect("load");
272        assert_eq!(cfg.fmt.line_width, Some(120));
273        assert_eq!(cfg.fmt.separator_width, Some(60));
274        assert_eq!(
275            cfg.lint.disabled.as_deref(),
276            Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
277        );
278        assert_eq!(cfg.lint.require_file_header, Some(true));
279        assert_eq!(cfg.lint.require_docstrings, Some(true));
280    }
281
282    #[test]
283    fn partial_config_leaves_other_keys_default() {
284        let tmp = tempfile::tempdir().unwrap();
285        write_file(
286            tmp.path(),
287            "harn.toml",
288            r"
289[fmt]
290line_width = 80
291",
292        );
293        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
294        let cfg = load_for_path(&harn_file).expect("load");
295        assert_eq!(cfg.fmt.line_width, Some(80));
296        assert!(cfg.fmt.separator_width.is_none());
297        assert!(cfg.lint.disabled.is_none());
298    }
299
300    #[test]
301    fn malformed_manifest_is_an_error() {
302        let tmp = tempfile::tempdir().unwrap();
303        write_file(
304            tmp.path(),
305            "harn.toml",
306            "[fmt]\nline_width = \"not-a-number\"\n",
307        );
308        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
309        match load_for_path(&harn_file) {
310            Err(ConfigError::Parse { .. }) => {}
311            other => panic!("expected Parse error, got {other:?}"),
312        }
313    }
314
315    #[test]
316    fn walks_up_two_directories() {
317        let tmp = tempfile::tempdir().unwrap();
318        let root = tmp.path();
319        write_file(
320            root,
321            "harn.toml",
322            r"
323[fmt]
324separator_width = 42
325",
326        );
327        let sub = root.join("a").join("b");
328        std::fs::create_dir_all(&sub).unwrap();
329        let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
330        let cfg = load_for_path(&harn_file).expect("load");
331        assert_eq!(cfg.fmt.separator_width, Some(42));
332    }
333
334    #[test]
335    fn kebab_case_keys_are_accepted() {
336        // Rule and CLI flag names use kebab-case (e.g. `require-file-header`),
337        // so users sensibly reach for dashes in their harn.toml too. The loader
338        // must accept both spellings.
339        let tmp = tempfile::tempdir().unwrap();
340        write_file(
341            tmp.path(),
342            "harn.toml",
343            r"
344[fmt]
345line-width = 110
346separator-width = 72
347
348[lint]
349require-file-header = true
350require-docstrings = true
351",
352        );
353        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
354        let cfg = load_for_path(&harn_file).expect("load");
355        assert_eq!(cfg.fmt.line_width, Some(110));
356        assert_eq!(cfg.fmt.separator_width, Some(72));
357        assert_eq!(cfg.lint.require_file_header, Some(true));
358        assert_eq!(cfg.lint.require_docstrings, Some(true));
359    }
360
361    #[test]
362    fn walk_stops_at_git_boundary() {
363        // An ancestor `harn.toml` sits above a `.git` dir; the loader
364        // must NOT pick it up — that manifest lives in a different
365        // project (or the user's home) and silently applying its
366        // `[fmt]` / `[lint]` settings would surprise authors.
367        let tmp = tempfile::tempdir().unwrap();
368        let outer = tmp.path();
369        write_file(
370            outer,
371            "harn.toml",
372            r"
373[fmt]
374line_width = 999
375",
376        );
377        let project = outer.join("project");
378        std::fs::create_dir_all(&project).unwrap();
379        std::fs::create_dir_all(project.join(".git")).unwrap();
380        let inner = project.join("src");
381        std::fs::create_dir_all(&inner).unwrap();
382        let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
383        let cfg = load_for_path(&harn_file).expect("load");
384        assert!(
385            cfg.fmt.line_width.is_none(),
386            "must not pick up harn.toml from above the .git boundary: got {:?}",
387            cfg.fmt.line_width,
388        );
389    }
390
391    #[test]
392    fn walk_stops_at_max_depth() {
393        // Build > MAX_PARENT_DIRS of nested directories with no
394        // harn.toml and no .git. The loader should terminate without
395        // recursing all the way to the filesystem root.
396        let tmp = tempfile::tempdir().unwrap();
397        let mut dir = tmp.path().to_path_buf();
398        for i in 0..(MAX_PARENT_DIRS + 4) {
399            dir = dir.join(format!("lvl{i}"));
400        }
401        std::fs::create_dir_all(&dir).unwrap();
402        let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
403        // The walk must not panic, must not hang, and must return
404        // defaults even though a theoretical `harn.toml` could be found
405        // higher up on some systems.
406        let cfg = load_for_path(&harn_file).expect("load");
407        assert!(cfg.fmt.line_width.is_none());
408    }
409
410    #[test]
411    fn eval_fleets_parse_into_named_lookups() {
412        let tmp = tempfile::tempdir().unwrap();
413        write_file(
414            tmp.path(),
415            "harn.toml",
416            r#"
417[eval.fleets.frontier]
418models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
419
420[eval.fleets.local]
421models = ["ollama:qwen3.5"]
422"#,
423        );
424        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
425        let cfg = load_for_path(&harn_file).expect("load");
426        assert_eq!(cfg.eval.fleets.len(), 2);
427        assert_eq!(
428            cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
429            Some(
430                [
431                    "claude-opus-4-7".to_string(),
432                    "gpt-5".to_string(),
433                    "gemini-2.5-pro".to_string(),
434                ]
435                .as_slice()
436            ),
437        );
438        assert_eq!(
439            cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
440            Some(["ollama:qwen3.5".to_string()].as_slice()),
441        );
442    }
443
444    #[test]
445    fn ignores_unrelated_sections() {
446        // [package] and [dependencies] are handled by crate::package; this
447        // loader must not choke on their presence.
448        let tmp = tempfile::tempdir().unwrap();
449        write_file(
450            tmp.path(),
451            "harn.toml",
452            r#"
453[package]
454name = "demo"
455version = "0.1.0"
456
457[dependencies]
458foo = { path = "../foo" }
459
460[fmt]
461line_width = 77
462"#,
463        );
464        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
465        let cfg = load_for_path(&harn_file).expect("load");
466        assert_eq!(cfg.fmt.line_width, Some(77));
467    }
468}