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)]
146pub enum ConfigError {
147    Parse {
148        path: PathBuf,
149        message: String,
150    },
151    Io {
152        path: PathBuf,
153        error: std::io::Error,
154    },
155}
156
157impl fmt::Display for ConfigError {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        match self {
160            ConfigError::Parse { path, message } => {
161                write!(f, "failed to parse {}: {message}", path.display())
162            }
163            ConfigError::Io { path, error } => {
164                write!(f, "failed to read {}: {error}", path.display())
165            }
166        }
167    }
168}
169
170impl std::error::Error for ConfigError {}
171
172/// Walks up from `start` to find the nearest `harn.toml` via the shared
173/// [`manifest_walk`](crate::manifest_walk) walk. Returns
174/// `Ok(HarnConfig::default())` if none is found. Returns `Err` on parse
175/// failure so callers can surface the problem rather than silently ignore
176/// malformed config.
177pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
178    match crate::manifest_walk::find_nearest_manifest(start) {
179        Some(found) => parse_manifest(&found.path),
180        None => Ok(HarnConfig::default()),
181    }
182}
183
184fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
185    let content = match fs::read_to_string(path) {
186        Ok(c) => c,
187        // The manifest existed at `is_file()` time; if it vanished in the
188        // race window, fall back to defaults. Any other I/O error (permission
189        // denied, bad symlink) is surfaced so a misconfigured manifest never
190        // silently degrades to default config.
191        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
192            return Ok(HarnConfig::default());
193        }
194        Err(error) => {
195            return Err(ConfigError::Io {
196                path: path.to_path_buf(),
197                error,
198            });
199        }
200    };
201    let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
202        path: path.to_path_buf(),
203        message: e.to_string(),
204    })?;
205    Ok(HarnConfig {
206        fmt: raw.fmt,
207        lint: raw.lint,
208        eval: raw.eval,
209    })
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::fs::File;
216    use std::io::Write as _;
217
218    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
219        let path = dir.join(name);
220        let mut f = File::create(&path).expect("create file");
221        f.write_all(content.as_bytes()).expect("write");
222        path
223    }
224
225    #[test]
226    fn no_manifest_yields_defaults() {
227        let tmp = tempfile::tempdir().unwrap();
228        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
229        let cfg = load_for_path(&harn_file).expect("load");
230        assert!(cfg.fmt.line_width.is_none());
231        assert!(cfg.fmt.separator_width.is_none());
232        assert!(cfg.lint.disabled.is_none());
233        assert!(cfg.lint.require_file_header.is_none());
234        assert!(cfg.lint.require_docstrings.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
252
253[lint.severity]
254missing-harndoc = "ERROR"
255unused-import = "warn"
256"#,
257        );
258        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
259        let cfg = load_for_path(&harn_file).expect("load");
260        assert_eq!(cfg.fmt.line_width, Some(120));
261        assert_eq!(cfg.fmt.separator_width, Some(60));
262        assert_eq!(
263            cfg.lint.disabled.as_deref(),
264            Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
265        );
266        assert_eq!(cfg.lint.require_file_header, Some(true));
267        assert_eq!(cfg.lint.require_docstrings, Some(true));
268        assert_eq!(
269            cfg.lint.severity,
270            std::collections::HashMap::from([
271                ("missing-harndoc".to_string(), LintSeverity::Error,),
272                ("unused-import".to_string(), LintSeverity::Warning),
273            ])
274        );
275    }
276
277    #[test]
278    fn partial_config_leaves_other_keys_default() {
279        let tmp = tempfile::tempdir().unwrap();
280        write_file(
281            tmp.path(),
282            "harn.toml",
283            r"
284[fmt]
285line_width = 80
286",
287        );
288        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
289        let cfg = load_for_path(&harn_file).expect("load");
290        assert_eq!(cfg.fmt.line_width, Some(80));
291        assert!(cfg.fmt.separator_width.is_none());
292        assert!(cfg.lint.disabled.is_none());
293    }
294
295    #[test]
296    fn malformed_manifest_is_an_error() {
297        let tmp = tempfile::tempdir().unwrap();
298        write_file(
299            tmp.path(),
300            "harn.toml",
301            "[fmt]\nline_width = \"not-a-number\"\n",
302        );
303        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
304        match load_for_path(&harn_file) {
305            Err(ConfigError::Parse { .. }) => {}
306            other => panic!("expected Parse error, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn unknown_lint_severity_is_a_config_error() {
312        let tmp = tempfile::tempdir().unwrap();
313        write_file(
314            tmp.path(),
315            "harn.toml",
316            "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
317        );
318        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
319        let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
320        let ConfigError::Parse { path, message } = error else {
321            panic!("expected a typed parse error, got {error:?}");
322        };
323        assert_eq!(path, tmp.path().join("harn.toml"));
324        assert!(
325            message
326                .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
327            "serde/toml location prose may vary, but the owned reason must survive: {message}"
328        );
329    }
330
331    #[test]
332    fn walks_up_two_directories() {
333        let tmp = tempfile::tempdir().unwrap();
334        let root = tmp.path();
335        write_file(
336            root,
337            "harn.toml",
338            r"
339[fmt]
340separator_width = 42
341",
342        );
343        let sub = root.join("a").join("b");
344        std::fs::create_dir_all(&sub).unwrap();
345        let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
346        let cfg = load_for_path(&harn_file).expect("load");
347        assert_eq!(cfg.fmt.separator_width, Some(42));
348    }
349
350    #[test]
351    fn kebab_case_keys_are_accepted() {
352        // Rule and CLI flag names use kebab-case (e.g. `require-file-header`),
353        // so users sensibly reach for dashes in their harn.toml too. The loader
354        // must accept both spellings.
355        let tmp = tempfile::tempdir().unwrap();
356        write_file(
357            tmp.path(),
358            "harn.toml",
359            r"
360[fmt]
361line-width = 110
362separator-width = 72
363
364[lint]
365require-file-header = true
366require-docstrings = true
367",
368        );
369        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
370        let cfg = load_for_path(&harn_file).expect("load");
371        assert_eq!(cfg.fmt.line_width, Some(110));
372        assert_eq!(cfg.fmt.separator_width, Some(72));
373        assert_eq!(cfg.lint.require_file_header, Some(true));
374        assert_eq!(cfg.lint.require_docstrings, Some(true));
375    }
376
377    #[test]
378    fn walk_stops_at_git_boundary() {
379        // An ancestor `harn.toml` sits above a `.git` dir; the loader
380        // must NOT pick it up — that manifest lives in a different
381        // project (or the user's home) and silently applying its
382        // `[fmt]` / `[lint]` settings would surprise authors.
383        let tmp = tempfile::tempdir().unwrap();
384        let outer = tmp.path();
385        write_file(
386            outer,
387            "harn.toml",
388            r"
389[fmt]
390line_width = 999
391",
392        );
393        let project = outer.join("project");
394        std::fs::create_dir_all(&project).unwrap();
395        std::fs::create_dir_all(project.join(".git")).unwrap();
396        let inner = project.join("src");
397        std::fs::create_dir_all(&inner).unwrap();
398        let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
399        let cfg = load_for_path(&harn_file).expect("load");
400        assert!(
401            cfg.fmt.line_width.is_none(),
402            "must not pick up harn.toml from above the .git boundary: got {:?}",
403            cfg.fmt.line_width,
404        );
405    }
406
407    #[test]
408    fn walk_stops_at_max_depth() {
409        // Build > MAX_PARENT_DIRS of nested directories with no
410        // harn.toml and no .git. The loader should terminate without
411        // recursing all the way to the filesystem root.
412        let tmp = tempfile::tempdir().unwrap();
413        let mut dir = tmp.path().to_path_buf();
414        for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
415            dir = dir.join(format!("lvl{i}"));
416        }
417        std::fs::create_dir_all(&dir).unwrap();
418        let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
419        // The walk must not panic, must not hang, and must return
420        // defaults even though a theoretical `harn.toml` could be found
421        // higher up on some systems.
422        let cfg = load_for_path(&harn_file).expect("load");
423        assert!(cfg.fmt.line_width.is_none());
424    }
425
426    #[test]
427    fn eval_fleets_parse_into_named_lookups() {
428        let tmp = tempfile::tempdir().unwrap();
429        write_file(
430            tmp.path(),
431            "harn.toml",
432            r#"
433[eval.fleets.frontier]
434models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
435
436[eval.fleets.local]
437models = ["ollama:qwen3.5"]
438"#,
439        );
440        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
441        let cfg = load_for_path(&harn_file).expect("load");
442        assert_eq!(cfg.eval.fleets.len(), 2);
443        assert_eq!(
444            cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
445            Some(
446                [
447                    "claude-opus-4-7".to_string(),
448                    "gpt-5".to_string(),
449                    "gemini-2.5-pro".to_string(),
450                ]
451                .as_slice()
452            ),
453        );
454        assert_eq!(
455            cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
456            Some(["ollama:qwen3.5".to_string()].as_slice()),
457        );
458    }
459
460    #[test]
461    fn ignores_unrelated_sections() {
462        // [package] and [dependencies] are handled by crate::package; this
463        // loader must not choke on their presence.
464        let tmp = tempfile::tempdir().unwrap();
465        write_file(
466            tmp.path(),
467            "harn.toml",
468            r#"
469[package]
470name = "demo"
471version = "0.1.0"
472
473[dependencies]
474foo = { path = "../foo" }
475
476[fmt]
477line_width = 77
478"#,
479        );
480        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
481        let cfg = load_for_path(&harn_file).expect("load");
482        assert_eq!(cfg.fmt.line_width, Some(77));
483    }
484}