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