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