Skip to main content

harn_modules/
project_config.rs

1//! Typed project configuration shared by Harn's CLI and language tooling.
2//!
3//! `harn.toml` can carry many sections. This loader exposes the generic
4//! `[fmt]`, `[lint]`, and `[eval.fleets]` policy used by every frontend and
5//! walks up from an input file looking for the nearest manifest.
6//!
7//! Recognized keys (snake_case, Cargo-style):
8//!
9//! ```toml
10//! [fmt]
11//! line_width = 100
12//! # By default, section-header separators follow line_width.
13//! # Set separator_width to force a fixed width.
14//!
15//! [lint]
16//! disabled = ["unused-import"]
17//! require_file_header = false
18//! require_docstrings = false
19//! complexity_threshold = 25
20//! persona_step_allowlist = ["legacy_helper"]
21//! template_variant_branch_threshold = 3
22//!
23//! # Reusable fleets consumed by `harn eval prompt --fleet-name <name>`.
24//! [eval.fleets.frontier]
25//! models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
26//!
27//! [eval.fleets.local]
28//! models = ["ollama:qwen3.5", "ollama:llama4"]
29//! ```
30
31use std::collections::BTreeMap;
32use std::fmt;
33use std::fs;
34use std::path::{Path, PathBuf};
35
36use serde::Deserialize;
37
38/// Generic `harn.toml` view shared by the CLI, LSP, and future frontends.
39#[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    /// Opt-in file-header requirement. Accept both snake_case (canonical,
59    /// Cargo-style) and kebab-case (rule-name style) so authors who copy
60    /// the rule's diagnostic name into their TOML don't silently get
61    /// `false`.
62    #[serde(default, alias = "require-file-header")]
63    pub require_file_header: Option<bool>,
64    /// Opt-in docstring requirement: when true, the `missing-harndoc`
65    /// rule warns on public functions without a `/** */` doc comment.
66    /// Off by default — out of the box, `pub fn` needs no docs.
67    #[serde(default, alias = "require-docstrings")]
68    pub require_docstrings: Option<bool>,
69    /// Override the default cyclomatic-complexity warning threshold
70    /// (see `harn_lint::DEFAULT_COMPLEXITY_THRESHOLD`). Accept both
71    /// snake_case and kebab-case for consistency with the other keys.
72    #[serde(default, alias = "complexity-threshold")]
73    pub complexity_threshold: Option<usize>,
74    /// Non-stdlib functions that may be called directly from `@persona`
75    /// bodies without being declared as `@step`.
76    #[serde(default, alias = "persona-step-allowlist")]
77    pub persona_step_allowlist: Vec<String>,
78    /// Directory names this project treats as test roots, in addition to the
79    /// built-in `tests`. Declaring a root here is a structural fact visible in
80    /// review, not a per-file escape hatch, which is the property the
81    /// path-driven test predicate exists to preserve.
82    #[serde(default, alias = "test-root-components")]
83    pub test_root_components: Vec<String>,
84    /// Threshold for the `template-variant-explosion` rule. Defaults
85    /// to [`harn_lint::DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD`].
86    #[serde(default, alias = "template-variant-branch-threshold")]
87    pub template_variant_branch_threshold: Option<usize>,
88    /// `[lint.severity]` — typed per-rule severity overrides (#2851). Parsed
89    /// here so every frontend observes the same normalized policy.
90    #[serde(default)]
91    pub severity: std::collections::HashMap<String, LintSeverity>,
92}
93
94/// Canonical severity used by project lint configuration and lint diagnostics.
95#[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/// `[eval]` section of `harn.toml`. Reserves a `[eval.fleets.<name>]`
120/// table keyed by fleet name; each entry lists the model selectors
121/// (alias or `provider:model`) consumed by
122/// `harn eval prompt --fleet-name <name>`.
123#[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)]
146#[non_exhaustive]
147pub enum ConfigError {
148    Parse {
149        path: PathBuf,
150        message: String,
151    },
152    Io {
153        path: PathBuf,
154        error: std::io::Error,
155    },
156}
157
158impl fmt::Display for ConfigError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            ConfigError::Parse { path, message } => {
162                write!(f, "failed to parse {}: {message}", path.display())
163            }
164            ConfigError::Io { path, error } => {
165                write!(f, "failed to read {}: {error}", path.display())
166            }
167        }
168    }
169}
170
171impl std::error::Error for ConfigError {}
172
173/// Walks up from `start` to find the nearest `harn.toml` via the shared
174/// [`manifest_walk`](crate::manifest_walk) walk. Returns
175/// `Ok(HarnConfig::default())` if none is found. Returns `Err` on parse
176/// failure so callers can surface the problem rather than silently ignore
177/// malformed config.
178pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
179    match crate::manifest_walk::find_nearest_manifest(start) {
180        Some(found) => parse_manifest(&found.path),
181        None => Ok(HarnConfig::default()),
182    }
183}
184
185fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
186    let content = match fs::read_to_string(path) {
187        Ok(c) => c,
188        // The manifest existed at `is_file()` time; if it vanished in the
189        // race window, fall back to defaults. Any other I/O error (permission
190        // denied, bad symlink) is surfaced so a misconfigured manifest never
191        // silently degrades to default config.
192        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
193            return Ok(HarnConfig::default());
194        }
195        Err(error) => {
196            return Err(ConfigError::Io {
197                path: path.to_path_buf(),
198                error,
199            });
200        }
201    };
202    let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
203        path: path.to_path_buf(),
204        message: e.to_string(),
205    })?;
206    Ok(HarnConfig {
207        fmt: raw.fmt,
208        lint: raw.lint,
209        eval: raw.eval,
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::fs::File;
217    use std::io::Write as _;
218
219    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
220        let path = dir.join(name);
221        let mut f = File::create(&path).expect("create file");
222        f.write_all(content.as_bytes()).expect("write");
223        path
224    }
225
226    #[test]
227    fn no_manifest_yields_defaults() {
228        let tmp = tempfile::tempdir().unwrap();
229        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
230        let cfg = load_for_path(&harn_file).expect("load");
231        assert!(cfg.fmt.line_width.is_none());
232        assert!(cfg.fmt.separator_width.is_none());
233        assert!(cfg.lint.disabled.is_none());
234        assert!(cfg.lint.require_file_header.is_none());
235        assert!(cfg.lint.require_docstrings.is_none());
236    }
237
238    #[test]
239    fn full_config_parses() {
240        let tmp = tempfile::tempdir().unwrap();
241        write_file(
242            tmp.path(),
243            "harn.toml",
244            r#"
245[fmt]
246line_width = 120
247separator_width = 60
248
249[lint]
250disabled = ["unused-import", "missing-harndoc"]
251require_file_header = true
252require_docstrings = true
253
254[lint.severity]
255missing-harndoc = "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!(
270            cfg.lint.severity,
271            std::collections::HashMap::from([
272                ("missing-harndoc".to_string(), LintSeverity::Error,),
273                ("unused-import".to_string(), LintSeverity::Warning),
274            ])
275        );
276    }
277
278    #[test]
279    fn partial_config_leaves_other_keys_default() {
280        let tmp = tempfile::tempdir().unwrap();
281        write_file(
282            tmp.path(),
283            "harn.toml",
284            r"
285[fmt]
286line_width = 80
287",
288        );
289        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
290        let cfg = load_for_path(&harn_file).expect("load");
291        assert_eq!(cfg.fmt.line_width, Some(80));
292        assert!(cfg.fmt.separator_width.is_none());
293        assert!(cfg.lint.disabled.is_none());
294    }
295
296    #[test]
297    fn malformed_manifest_is_an_error() {
298        let tmp = tempfile::tempdir().unwrap();
299        write_file(
300            tmp.path(),
301            "harn.toml",
302            "[fmt]\nline_width = \"not-a-number\"\n",
303        );
304        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
305        match load_for_path(&harn_file) {
306            Err(ConfigError::Parse { .. }) => {}
307            other => panic!("expected Parse error, got {other:?}"),
308        }
309    }
310
311    #[test]
312    fn unknown_lint_severity_is_a_config_error() {
313        let tmp = tempfile::tempdir().unwrap();
314        write_file(
315            tmp.path(),
316            "harn.toml",
317            "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
318        );
319        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
320        let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
321        let ConfigError::Parse { path, message } = error else {
322            panic!("expected a typed parse error, got {error:?}");
323        };
324        assert_eq!(path, tmp.path().join("harn.toml"));
325        assert!(
326            message
327                .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
328            "serde/toml location prose may vary, but the owned reason must survive: {message}"
329        );
330    }
331
332    #[test]
333    fn walks_up_two_directories() {
334        let tmp = tempfile::tempdir().unwrap();
335        let root = tmp.path();
336        write_file(
337            root,
338            "harn.toml",
339            r"
340[fmt]
341separator_width = 42
342",
343        );
344        let sub = root.join("a").join("b");
345        std::fs::create_dir_all(&sub).unwrap();
346        let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
347        let cfg = load_for_path(&harn_file).expect("load");
348        assert_eq!(cfg.fmt.separator_width, Some(42));
349    }
350
351    #[test]
352    fn kebab_case_keys_are_accepted() {
353        // Rule and CLI flag names use kebab-case (e.g. `require-file-header`),
354        // so users sensibly reach for dashes in their harn.toml too. The loader
355        // must accept both spellings.
356        let tmp = tempfile::tempdir().unwrap();
357        write_file(
358            tmp.path(),
359            "harn.toml",
360            r"
361[fmt]
362line-width = 110
363separator-width = 72
364
365[lint]
366require-file-header = true
367require-docstrings = true
368",
369        );
370        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
371        let cfg = load_for_path(&harn_file).expect("load");
372        assert_eq!(cfg.fmt.line_width, Some(110));
373        assert_eq!(cfg.fmt.separator_width, Some(72));
374        assert_eq!(cfg.lint.require_file_header, Some(true));
375        assert_eq!(cfg.lint.require_docstrings, Some(true));
376    }
377
378    #[test]
379    fn walk_stops_at_git_boundary() {
380        // An ancestor `harn.toml` sits above a `.git` dir; the loader
381        // must NOT pick it up — that manifest lives in a different
382        // project (or the user's home) and silently applying its
383        // `[fmt]` / `[lint]` settings would surprise authors.
384        let tmp = tempfile::tempdir().unwrap();
385        let outer = tmp.path();
386        write_file(
387            outer,
388            "harn.toml",
389            r"
390[fmt]
391line_width = 999
392",
393        );
394        let project = outer.join("project");
395        std::fs::create_dir_all(&project).unwrap();
396        std::fs::create_dir_all(project.join(".git")).unwrap();
397        let inner = project.join("src");
398        std::fs::create_dir_all(&inner).unwrap();
399        let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
400        let cfg = load_for_path(&harn_file).expect("load");
401        assert!(
402            cfg.fmt.line_width.is_none(),
403            "must not pick up harn.toml from above the .git boundary: got {:?}",
404            cfg.fmt.line_width,
405        );
406    }
407
408    #[test]
409    fn walk_stops_at_max_depth() {
410        // Build > MAX_PARENT_DIRS of nested directories with no
411        // harn.toml and no .git. The loader should terminate without
412        // recursing all the way to the filesystem root.
413        let tmp = tempfile::tempdir().unwrap();
414        let mut dir = tmp.path().to_path_buf();
415        for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
416            dir = dir.join(format!("lvl{i}"));
417        }
418        std::fs::create_dir_all(&dir).unwrap();
419        let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
420        // The walk must not panic, must not hang, and must return
421        // defaults even though a theoretical `harn.toml` could be found
422        // higher up on some systems.
423        let cfg = load_for_path(&harn_file).expect("load");
424        assert!(cfg.fmt.line_width.is_none());
425    }
426
427    #[test]
428    fn eval_fleets_parse_into_named_lookups() {
429        let tmp = tempfile::tempdir().unwrap();
430        write_file(
431            tmp.path(),
432            "harn.toml",
433            r#"
434[eval.fleets.frontier]
435models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
436
437[eval.fleets.local]
438models = ["ollama:qwen3.5"]
439"#,
440        );
441        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
442        let cfg = load_for_path(&harn_file).expect("load");
443        assert_eq!(cfg.eval.fleets.len(), 2);
444        assert_eq!(
445            cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
446            Some(
447                [
448                    "claude-opus-4-7".to_string(),
449                    "gpt-5".to_string(),
450                    "gemini-2.5-pro".to_string(),
451                ]
452                .as_slice()
453            ),
454        );
455        assert_eq!(
456            cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
457            Some(["ollama:qwen3.5".to_string()].as_slice()),
458        );
459    }
460
461    #[test]
462    fn ignores_unrelated_sections() {
463        // [package] and [dependencies] are handled by crate::package; this
464        // loader must not choke on their presence.
465        let tmp = tempfile::tempdir().unwrap();
466        write_file(
467            tmp.path(),
468            "harn.toml",
469            r#"
470[package]
471name = "demo"
472version = "0.1.0"
473
474[dependencies]
475foo = { path = "../foo" }
476
477[fmt]
478line_width = 77
479"#,
480        );
481        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
482        let cfg = load_for_path(&harn_file).expect("load");
483        assert_eq!(cfg.fmt.line_width, Some(77));
484    }
485}