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