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    /// Threshold for the `template-variant-explosion` rule. Defaults
79    /// to [`harn_lint::DEFAULT_TEMPLATE_VARIANT_BRANCH_THRESHOLD`].
80    #[serde(default, alias = "template-variant-branch-threshold")]
81    pub template_variant_branch_threshold: Option<usize>,
82    /// `[lint.severity]` — typed per-rule severity overrides (#2851). Parsed
83    /// here so every frontend observes the same normalized policy.
84    #[serde(default)]
85    pub severity: std::collections::HashMap<String, LintSeverity>,
86}
87
88/// Canonical severity used by project lint configuration and lint diagnostics.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum LintSeverity {
91    Info,
92    Warning,
93    Error,
94}
95
96impl<'de> Deserialize<'de> for LintSeverity {
97    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98    where
99        D: serde::Deserializer<'de>,
100    {
101        let value = String::deserialize(deserializer)?;
102        match value.to_ascii_lowercase().as_str() {
103            "info" => Ok(Self::Info),
104            "warning" | "warn" => Ok(Self::Warning),
105            "error" => Ok(Self::Error),
106            other => Err(serde::de::Error::custom(format!(
107                "unknown lint severity `{other}`; expected `info`, `warning`, or `error`"
108            ))),
109        }
110    }
111}
112
113/// `[eval]` section of `harn.toml`. Reserves a `[eval.fleets.<name>]`
114/// table keyed by fleet name; each entry lists the model selectors
115/// (alias or `provider:model`) consumed by
116/// `harn eval prompt --fleet-name <name>`.
117#[derive(Debug, Default, Clone, Deserialize)]
118pub struct EvalConfig {
119    #[serde(default)]
120    pub fleets: BTreeMap<String, EvalFleet>,
121}
122
123#[derive(Debug, Default, Clone, Deserialize)]
124pub struct EvalFleet {
125    #[serde(default)]
126    pub models: Vec<String>,
127}
128
129#[derive(Debug, Default, Deserialize)]
130struct RawManifest {
131    #[serde(default)]
132    fmt: FmtConfig,
133    #[serde(default)]
134    lint: LintConfig,
135    #[serde(default)]
136    eval: EvalConfig,
137}
138
139#[derive(Debug)]
140pub enum ConfigError {
141    Parse {
142        path: PathBuf,
143        message: String,
144    },
145    Io {
146        path: PathBuf,
147        error: std::io::Error,
148    },
149}
150
151impl fmt::Display for ConfigError {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            ConfigError::Parse { path, message } => {
155                write!(f, "failed to parse {}: {message}", path.display())
156            }
157            ConfigError::Io { path, error } => {
158                write!(f, "failed to read {}: {error}", path.display())
159            }
160        }
161    }
162}
163
164impl std::error::Error for ConfigError {}
165
166/// Walks up from `start` to find the nearest `harn.toml` via the shared
167/// [`manifest_walk`](crate::manifest_walk) walk. Returns
168/// `Ok(HarnConfig::default())` if none is found. Returns `Err` on parse
169/// failure so callers can surface the problem rather than silently ignore
170/// malformed config.
171pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
172    match crate::manifest_walk::find_nearest_manifest(start) {
173        Some(found) => parse_manifest(&found.path),
174        None => Ok(HarnConfig::default()),
175    }
176}
177
178fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
179    let content = match fs::read_to_string(path) {
180        Ok(c) => c,
181        // The manifest existed at `is_file()` time; if it vanished in the
182        // race window, fall back to defaults. Any other I/O error (permission
183        // denied, bad symlink) is surfaced so a misconfigured manifest never
184        // silently degrades to default config.
185        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
186            return Ok(HarnConfig::default());
187        }
188        Err(error) => {
189            return Err(ConfigError::Io {
190                path: path.to_path_buf(),
191                error,
192            });
193        }
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        assert!(cfg.lint.require_docstrings.is_none());
229    }
230
231    #[test]
232    fn full_config_parses() {
233        let tmp = tempfile::tempdir().unwrap();
234        write_file(
235            tmp.path(),
236            "harn.toml",
237            r#"
238[fmt]
239line_width = 120
240separator_width = 60
241
242[lint]
243disabled = ["unused-import", "missing-harndoc"]
244require_file_header = true
245require_docstrings = true
246
247[lint.severity]
248missing-harndoc = "ERROR"
249unused-import = "warn"
250"#,
251        );
252        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
253        let cfg = load_for_path(&harn_file).expect("load");
254        assert_eq!(cfg.fmt.line_width, Some(120));
255        assert_eq!(cfg.fmt.separator_width, Some(60));
256        assert_eq!(
257            cfg.lint.disabled.as_deref(),
258            Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
259        );
260        assert_eq!(cfg.lint.require_file_header, Some(true));
261        assert_eq!(cfg.lint.require_docstrings, Some(true));
262        assert_eq!(
263            cfg.lint.severity,
264            std::collections::HashMap::from([
265                ("missing-harndoc".to_string(), LintSeverity::Error,),
266                ("unused-import".to_string(), LintSeverity::Warning),
267            ])
268        );
269    }
270
271    #[test]
272    fn partial_config_leaves_other_keys_default() {
273        let tmp = tempfile::tempdir().unwrap();
274        write_file(
275            tmp.path(),
276            "harn.toml",
277            r"
278[fmt]
279line_width = 80
280",
281        );
282        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
283        let cfg = load_for_path(&harn_file).expect("load");
284        assert_eq!(cfg.fmt.line_width, Some(80));
285        assert!(cfg.fmt.separator_width.is_none());
286        assert!(cfg.lint.disabled.is_none());
287    }
288
289    #[test]
290    fn malformed_manifest_is_an_error() {
291        let tmp = tempfile::tempdir().unwrap();
292        write_file(
293            tmp.path(),
294            "harn.toml",
295            "[fmt]\nline_width = \"not-a-number\"\n",
296        );
297        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
298        match load_for_path(&harn_file) {
299            Err(ConfigError::Parse { .. }) => {}
300            other => panic!("expected Parse error, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn unknown_lint_severity_is_a_config_error() {
306        let tmp = tempfile::tempdir().unwrap();
307        write_file(
308            tmp.path(),
309            "harn.toml",
310            "[lint.severity]\nmissing-harndoc = \"urgent\"\n",
311        );
312        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
313        let error = load_for_path(&harn_file).expect_err("unknown severity must fail closed");
314        let ConfigError::Parse { path, message } = error else {
315            panic!("expected a typed parse error, got {error:?}");
316        };
317        assert_eq!(path, tmp.path().join("harn.toml"));
318        assert!(
319            message
320                .contains("unknown lint severity `urgent`; expected `info`, `warning`, or `error`"),
321            "serde/toml location prose may vary, but the owned reason must survive: {message}"
322        );
323    }
324
325    #[test]
326    fn walks_up_two_directories() {
327        let tmp = tempfile::tempdir().unwrap();
328        let root = tmp.path();
329        write_file(
330            root,
331            "harn.toml",
332            r"
333[fmt]
334separator_width = 42
335",
336        );
337        let sub = root.join("a").join("b");
338        std::fs::create_dir_all(&sub).unwrap();
339        let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
340        let cfg = load_for_path(&harn_file).expect("load");
341        assert_eq!(cfg.fmt.separator_width, Some(42));
342    }
343
344    #[test]
345    fn kebab_case_keys_are_accepted() {
346        // Rule and CLI flag names use kebab-case (e.g. `require-file-header`),
347        // so users sensibly reach for dashes in their harn.toml too. The loader
348        // must accept both spellings.
349        let tmp = tempfile::tempdir().unwrap();
350        write_file(
351            tmp.path(),
352            "harn.toml",
353            r"
354[fmt]
355line-width = 110
356separator-width = 72
357
358[lint]
359require-file-header = true
360require-docstrings = true
361",
362        );
363        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
364        let cfg = load_for_path(&harn_file).expect("load");
365        assert_eq!(cfg.fmt.line_width, Some(110));
366        assert_eq!(cfg.fmt.separator_width, Some(72));
367        assert_eq!(cfg.lint.require_file_header, Some(true));
368        assert_eq!(cfg.lint.require_docstrings, Some(true));
369    }
370
371    #[test]
372    fn walk_stops_at_git_boundary() {
373        // An ancestor `harn.toml` sits above a `.git` dir; the loader
374        // must NOT pick it up — that manifest lives in a different
375        // project (or the user's home) and silently applying its
376        // `[fmt]` / `[lint]` settings would surprise authors.
377        let tmp = tempfile::tempdir().unwrap();
378        let outer = tmp.path();
379        write_file(
380            outer,
381            "harn.toml",
382            r"
383[fmt]
384line_width = 999
385",
386        );
387        let project = outer.join("project");
388        std::fs::create_dir_all(&project).unwrap();
389        std::fs::create_dir_all(project.join(".git")).unwrap();
390        let inner = project.join("src");
391        std::fs::create_dir_all(&inner).unwrap();
392        let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
393        let cfg = load_for_path(&harn_file).expect("load");
394        assert!(
395            cfg.fmt.line_width.is_none(),
396            "must not pick up harn.toml from above the .git boundary: got {:?}",
397            cfg.fmt.line_width,
398        );
399    }
400
401    #[test]
402    fn walk_stops_at_max_depth() {
403        // Build > MAX_PARENT_DIRS of nested directories with no
404        // harn.toml and no .git. The loader should terminate without
405        // recursing all the way to the filesystem root.
406        let tmp = tempfile::tempdir().unwrap();
407        let mut dir = tmp.path().to_path_buf();
408        for i in 0..(crate::manifest_walk::MAX_PARENT_DIRS + 4) {
409            dir = dir.join(format!("lvl{i}"));
410        }
411        std::fs::create_dir_all(&dir).unwrap();
412        let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
413        // The walk must not panic, must not hang, and must return
414        // defaults even though a theoretical `harn.toml` could be found
415        // higher up on some systems.
416        let cfg = load_for_path(&harn_file).expect("load");
417        assert!(cfg.fmt.line_width.is_none());
418    }
419
420    #[test]
421    fn eval_fleets_parse_into_named_lookups() {
422        let tmp = tempfile::tempdir().unwrap();
423        write_file(
424            tmp.path(),
425            "harn.toml",
426            r#"
427[eval.fleets.frontier]
428models = ["claude-opus-4-7", "gpt-5", "gemini-2.5-pro"]
429
430[eval.fleets.local]
431models = ["ollama:qwen3.5"]
432"#,
433        );
434        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
435        let cfg = load_for_path(&harn_file).expect("load");
436        assert_eq!(cfg.eval.fleets.len(), 2);
437        assert_eq!(
438            cfg.eval.fleets.get("frontier").map(|f| f.models.as_slice()),
439            Some(
440                [
441                    "claude-opus-4-7".to_string(),
442                    "gpt-5".to_string(),
443                    "gemini-2.5-pro".to_string(),
444                ]
445                .as_slice()
446            ),
447        );
448        assert_eq!(
449            cfg.eval.fleets.get("local").map(|f| f.models.as_slice()),
450            Some(["ollama:qwen3.5".to_string()].as_slice()),
451        );
452    }
453
454    #[test]
455    fn ignores_unrelated_sections() {
456        // [package] and [dependencies] are handled by crate::package; this
457        // loader must not choke on their presence.
458        let tmp = tempfile::tempdir().unwrap();
459        write_file(
460            tmp.path(),
461            "harn.toml",
462            r#"
463[package]
464name = "demo"
465version = "0.1.0"
466
467[dependencies]
468foo = { path = "../foo" }
469
470[fmt]
471line_width = 77
472"#,
473        );
474        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
475        let cfg = load_for_path(&harn_file).expect("load");
476        assert_eq!(cfg.fmt.line_width, Some(77));
477    }
478}