Skip to main content

harn_cli/
config.rs

1//! Lightweight `harn.toml` loader for `harn fmt` and `harn lint`.
2//!
3//! This module is intentionally separate from `crate::package` (which owns
4//! the richer `[check]` + `[dependencies]` manifest model used by
5//! `harn check`, `harn install`, etc.). `harn.toml` can carry both sets of
6//! keys; this loader focuses on the `[fmt]` and `[lint]` sections and walks
7//! up from an input file looking for the nearest manifest.
8//!
9//! Recognized keys (snake_case, Cargo-style):
10//!
11//! ```toml
12//! [fmt]
13//! line_width = 100
14//! # By default, section-header separators follow line_width.
15//! # Set separator_width to force a fixed width.
16//!
17//! [lint]
18//! disabled = ["unused-import"]
19//! require_file_header = false
20//! complexity_threshold = 25
21//! persona_step_allowlist = ["legacy_helper"]
22//! ```
23
24use std::fmt;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use serde::Deserialize;
29
30const MANIFEST: &str = "harn.toml";
31
32/// Hard cap on how many parent directories the loader will inspect.
33///
34/// The walk also stops early at a `.git` boundary (the first directory
35/// containing a `.git` child is treated as the project root). The cap
36/// exists to defend against pathological paths, symlink loops, and
37/// accidental pickup of a stray `harn.toml` high up the filesystem
38/// (e.g. a user's home directory or `/tmp`).
39const MAX_PARENT_DIRS: usize = 16;
40
41/// Combined `harn.toml` view used by `harn fmt` and `harn lint`.
42#[derive(Debug, Default, Clone)]
43pub struct HarnConfig {
44    pub fmt: FmtConfig,
45    pub lint: LintConfig,
46}
47
48#[derive(Debug, Default, Clone, Deserialize)]
49pub struct FmtConfig {
50    #[serde(default, alias = "line-width")]
51    pub line_width: Option<usize>,
52    #[serde(default, alias = "separator-width")]
53    pub separator_width: Option<usize>,
54}
55
56#[derive(Debug, Default, Clone, Deserialize)]
57pub struct LintConfig {
58    #[serde(default)]
59    pub disabled: Option<Vec<String>>,
60    /// Opt-in file-header requirement. Accept both snake_case (canonical,
61    /// Cargo-style) and kebab-case (rule-name style) so authors who copy
62    /// the rule's diagnostic name into their TOML don't silently get
63    /// `false`.
64    #[serde(default, alias = "require-file-header")]
65    pub require_file_header: Option<bool>,
66    /// Override the default cyclomatic-complexity warning threshold
67    /// (see `harn_lint::DEFAULT_COMPLEXITY_THRESHOLD`). Accept both
68    /// snake_case and kebab-case for consistency with the other keys.
69    #[serde(default, alias = "complexity-threshold")]
70    pub complexity_threshold: Option<usize>,
71    /// Non-stdlib functions that may be called directly from `@persona`
72    /// bodies without being declared as `@step`.
73    #[serde(default, alias = "persona-step-allowlist")]
74    pub persona_step_allowlist: Vec<String>,
75}
76
77#[derive(Debug, Default, Deserialize)]
78struct RawManifest {
79    #[serde(default)]
80    fmt: FmtConfig,
81    #[serde(default)]
82    lint: LintConfig,
83}
84
85#[derive(Debug)]
86pub enum ConfigError {
87    Parse {
88        path: PathBuf,
89        message: String,
90    },
91    #[allow(dead_code)]
92    Io {
93        path: PathBuf,
94        error: std::io::Error,
95    },
96}
97
98impl fmt::Display for ConfigError {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        match self {
101            ConfigError::Parse { path, message } => {
102                write!(f, "failed to parse {}: {message}", path.display())
103            }
104            ConfigError::Io { path, error } => {
105                write!(f, "failed to read {}: {error}", path.display())
106            }
107        }
108    }
109}
110
111impl std::error::Error for ConfigError {}
112
113/// Walks up from `start` to find the nearest `harn.toml`. Returns
114/// `Ok(HarnConfig::default())` if none is found. Returns `Err` on parse
115/// failure so callers can surface the problem rather than silently ignore
116/// malformed config.
117pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
118    // Normalize to an absolute path so the walk works when `start` is a
119    // non-existent relative path.
120    let base = if start.is_absolute() {
121        start.to_path_buf()
122    } else {
123        std::env::current_dir()
124            .unwrap_or_else(|_| PathBuf::from("."))
125            .join(start)
126    };
127
128    let mut cursor: Option<PathBuf> = if base.is_dir() {
129        Some(base)
130    } else {
131        base.parent().map(Path::to_path_buf)
132    };
133
134    let mut steps = 0usize;
135    while let Some(dir) = cursor {
136        if steps >= MAX_PARENT_DIRS {
137            break;
138        }
139        steps += 1;
140        let candidate = dir.join(MANIFEST);
141        if candidate.is_file() {
142            return parse_manifest(&candidate);
143        }
144        // Stop at a `.git` boundary so a stray `harn.toml` in a parent
145        // project or in `$HOME` is never silently picked up.
146        if dir.join(".git").exists() {
147            break;
148        }
149        cursor = dir.parent().map(Path::to_path_buf);
150    }
151
152    Ok(HarnConfig::default())
153}
154
155fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
156    let content = match fs::read_to_string(path) {
157        Ok(c) => c,
158        Err(_) => return Ok(HarnConfig::default()),
159    };
160    let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
161        path: path.to_path_buf(),
162        message: e.to_string(),
163    })?;
164    Ok(HarnConfig {
165        fmt: raw.fmt,
166        lint: raw.lint,
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::fs::File;
174    use std::io::Write as _;
175
176    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
177        let path = dir.join(name);
178        let mut f = File::create(&path).expect("create file");
179        f.write_all(content.as_bytes()).expect("write");
180        path
181    }
182
183    #[test]
184    fn no_manifest_yields_defaults() {
185        let tmp = tempfile::tempdir().unwrap();
186        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
187        let cfg = load_for_path(&harn_file).expect("load");
188        assert!(cfg.fmt.line_width.is_none());
189        assert!(cfg.fmt.separator_width.is_none());
190        assert!(cfg.lint.disabled.is_none());
191        assert!(cfg.lint.require_file_header.is_none());
192    }
193
194    #[test]
195    fn full_config_parses() {
196        let tmp = tempfile::tempdir().unwrap();
197        write_file(
198            tmp.path(),
199            "harn.toml",
200            r#"
201[fmt]
202line_width = 120
203separator_width = 60
204
205[lint]
206disabled = ["unused-import", "missing-harndoc"]
207require_file_header = true
208"#,
209        );
210        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
211        let cfg = load_for_path(&harn_file).expect("load");
212        assert_eq!(cfg.fmt.line_width, Some(120));
213        assert_eq!(cfg.fmt.separator_width, Some(60));
214        assert_eq!(
215            cfg.lint.disabled.as_deref(),
216            Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
217        );
218        assert_eq!(cfg.lint.require_file_header, Some(true));
219    }
220
221    #[test]
222    fn partial_config_leaves_other_keys_default() {
223        let tmp = tempfile::tempdir().unwrap();
224        write_file(
225            tmp.path(),
226            "harn.toml",
227            r#"
228[fmt]
229line_width = 80
230"#,
231        );
232        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
233        let cfg = load_for_path(&harn_file).expect("load");
234        assert_eq!(cfg.fmt.line_width, Some(80));
235        assert!(cfg.fmt.separator_width.is_none());
236        assert!(cfg.lint.disabled.is_none());
237    }
238
239    #[test]
240    fn malformed_manifest_is_an_error() {
241        let tmp = tempfile::tempdir().unwrap();
242        write_file(
243            tmp.path(),
244            "harn.toml",
245            "[fmt]\nline_width = \"not-a-number\"\n",
246        );
247        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
248        match load_for_path(&harn_file) {
249            Err(ConfigError::Parse { .. }) => {}
250            other => panic!("expected Parse error, got {other:?}"),
251        }
252    }
253
254    #[test]
255    fn walks_up_two_directories() {
256        let tmp = tempfile::tempdir().unwrap();
257        let root = tmp.path();
258        write_file(
259            root,
260            "harn.toml",
261            r#"
262[fmt]
263separator_width = 42
264"#,
265        );
266        let sub = root.join("a").join("b");
267        std::fs::create_dir_all(&sub).unwrap();
268        let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
269        let cfg = load_for_path(&harn_file).expect("load");
270        assert_eq!(cfg.fmt.separator_width, Some(42));
271    }
272
273    #[test]
274    fn kebab_case_keys_are_accepted() {
275        // Rule and CLI flag names use kebab-case (e.g. `require-file-header`),
276        // so users sensibly reach for dashes in their harn.toml too. The loader
277        // must accept both spellings.
278        let tmp = tempfile::tempdir().unwrap();
279        write_file(
280            tmp.path(),
281            "harn.toml",
282            r#"
283[fmt]
284line-width = 110
285separator-width = 72
286
287[lint]
288require-file-header = true
289"#,
290        );
291        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
292        let cfg = load_for_path(&harn_file).expect("load");
293        assert_eq!(cfg.fmt.line_width, Some(110));
294        assert_eq!(cfg.fmt.separator_width, Some(72));
295        assert_eq!(cfg.lint.require_file_header, Some(true));
296    }
297
298    #[test]
299    fn walk_stops_at_git_boundary() {
300        // An ancestor `harn.toml` sits above a `.git` dir; the loader
301        // must NOT pick it up — that manifest lives in a different
302        // project (or the user's home) and silently applying its
303        // `[fmt]` / `[lint]` settings would surprise authors.
304        let tmp = tempfile::tempdir().unwrap();
305        let outer = tmp.path();
306        write_file(
307            outer,
308            "harn.toml",
309            r#"
310[fmt]
311line_width = 999
312"#,
313        );
314        let project = outer.join("project");
315        std::fs::create_dir_all(&project).unwrap();
316        std::fs::create_dir_all(project.join(".git")).unwrap();
317        let inner = project.join("src");
318        std::fs::create_dir_all(&inner).unwrap();
319        let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
320        let cfg = load_for_path(&harn_file).expect("load");
321        assert!(
322            cfg.fmt.line_width.is_none(),
323            "must not pick up harn.toml from above the .git boundary: got {:?}",
324            cfg.fmt.line_width,
325        );
326    }
327
328    #[test]
329    fn walk_stops_at_max_depth() {
330        // Build > MAX_PARENT_DIRS of nested directories with no
331        // harn.toml and no .git. The loader should terminate without
332        // recursing all the way to the filesystem root.
333        let tmp = tempfile::tempdir().unwrap();
334        let mut dir = tmp.path().to_path_buf();
335        for i in 0..(MAX_PARENT_DIRS + 4) {
336            dir = dir.join(format!("lvl{i}"));
337        }
338        std::fs::create_dir_all(&dir).unwrap();
339        let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
340        // The walk must not panic, must not hang, and must return
341        // defaults even though a theoretical `harn.toml` could be found
342        // higher up on some systems.
343        let cfg = load_for_path(&harn_file).expect("load");
344        assert!(cfg.fmt.line_width.is_none());
345    }
346
347    #[test]
348    fn ignores_unrelated_sections() {
349        // [package] and [dependencies] are handled by crate::package; this
350        // loader must not choke on their presence.
351        let tmp = tempfile::tempdir().unwrap();
352        write_file(
353            tmp.path(),
354            "harn.toml",
355            r#"
356[package]
357name = "demo"
358version = "0.1.0"
359
360[dependencies]
361foo = { path = "../foo" }
362
363[fmt]
364line_width = 77
365"#,
366        );
367        let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
368        let cfg = load_for_path(&harn_file).expect("load");
369        assert_eq!(cfg.fmt.line_width, Some(77));
370    }
371}