1use std::fmt;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use serde::Deserialize;
29
30const MANIFEST: &str = "harn.toml";
31
32const MAX_PARENT_DIRS: usize = 16;
40
41#[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 #[serde(default, alias = "require-file-header")]
65 pub require_file_header: Option<bool>,
66 #[serde(default, alias = "complexity-threshold")]
70 pub complexity_threshold: Option<usize>,
71 #[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
113pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
118 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 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 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 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 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 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 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}