use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
const MANIFEST: &str = "harn.toml";
const MAX_PARENT_DIRS: usize = 16;
#[derive(Debug, Default, Clone)]
pub struct HarnConfig {
pub fmt: FmtConfig,
pub lint: LintConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct FmtConfig {
#[serde(default, alias = "line-width")]
pub line_width: Option<usize>,
#[serde(default, alias = "separator-width")]
pub separator_width: Option<usize>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct LintConfig {
#[serde(default)]
pub disabled: Option<Vec<String>>,
#[serde(default, alias = "require-file-header")]
pub require_file_header: Option<bool>,
#[serde(default, alias = "complexity-threshold")]
pub complexity_threshold: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
struct RawManifest {
#[serde(default)]
fmt: FmtConfig,
#[serde(default)]
lint: LintConfig,
}
#[derive(Debug)]
pub enum ConfigError {
Parse {
path: PathBuf,
message: String,
},
#[allow(dead_code)]
Io {
path: PathBuf,
error: std::io::Error,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Parse { path, message } => {
write!(f, "failed to parse {}: {message}", path.display())
}
ConfigError::Io { path, error } => {
write!(f, "failed to read {}: {error}", path.display())
}
}
}
}
impl std::error::Error for ConfigError {}
pub fn load_for_path(start: &Path) -> Result<HarnConfig, ConfigError> {
let base = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(start)
};
let mut cursor: Option<PathBuf> = if base.is_dir() {
Some(base)
} else {
base.parent().map(Path::to_path_buf)
};
let mut steps = 0usize;
while let Some(dir) = cursor {
if steps >= MAX_PARENT_DIRS {
break;
}
steps += 1;
let candidate = dir.join(MANIFEST);
if candidate.is_file() {
return parse_manifest(&candidate);
}
if dir.join(".git").exists() {
break;
}
cursor = dir.parent().map(Path::to_path_buf);
}
Ok(HarnConfig::default())
}
fn parse_manifest(path: &Path) -> Result<HarnConfig, ConfigError> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Ok(HarnConfig::default()),
};
let raw: RawManifest = toml::from_str(&content).map_err(|e| ConfigError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})?;
Ok(HarnConfig {
fmt: raw.fmt,
lint: raw.lint,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write as _;
fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
let mut f = File::create(&path).expect("create file");
f.write_all(content.as_bytes()).expect("write");
path
}
#[test]
fn no_manifest_yields_defaults() {
let tmp = tempfile::tempdir().unwrap();
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert!(cfg.fmt.line_width.is_none());
assert!(cfg.fmt.separator_width.is_none());
assert!(cfg.lint.disabled.is_none());
assert!(cfg.lint.require_file_header.is_none());
}
#[test]
fn full_config_parses() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"harn.toml",
r#"
[fmt]
line_width = 120
separator_width = 60
[lint]
disabled = ["unused-import", "missing-harndoc"]
require_file_header = true
"#,
);
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert_eq!(cfg.fmt.line_width, Some(120));
assert_eq!(cfg.fmt.separator_width, Some(60));
assert_eq!(
cfg.lint.disabled.as_deref(),
Some(["unused-import".to_string(), "missing-harndoc".to_string()].as_slice())
);
assert_eq!(cfg.lint.require_file_header, Some(true));
}
#[test]
fn partial_config_leaves_other_keys_default() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"harn.toml",
r#"
[fmt]
line_width = 80
"#,
);
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert_eq!(cfg.fmt.line_width, Some(80));
assert!(cfg.fmt.separator_width.is_none());
assert!(cfg.lint.disabled.is_none());
}
#[test]
fn malformed_manifest_is_an_error() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"harn.toml",
"[fmt]\nline_width = \"not-a-number\"\n",
);
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
match load_for_path(&harn_file) {
Err(ConfigError::Parse { .. }) => {}
other => panic!("expected Parse error, got {other:?}"),
}
}
#[test]
fn walks_up_two_directories() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_file(
root,
"harn.toml",
r#"
[fmt]
separator_width = 42
"#,
);
let sub = root.join("a").join("b");
std::fs::create_dir_all(&sub).unwrap();
let harn_file = write_file(&sub, "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert_eq!(cfg.fmt.separator_width, Some(42));
}
#[test]
fn kebab_case_keys_are_accepted() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"harn.toml",
r#"
[fmt]
line-width = 110
separator-width = 72
[lint]
require-file-header = true
"#,
);
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert_eq!(cfg.fmt.line_width, Some(110));
assert_eq!(cfg.fmt.separator_width, Some(72));
assert_eq!(cfg.lint.require_file_header, Some(true));
}
#[test]
fn walk_stops_at_git_boundary() {
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path();
write_file(
outer,
"harn.toml",
r#"
[fmt]
line_width = 999
"#,
);
let project = outer.join("project");
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(project.join(".git")).unwrap();
let inner = project.join("src");
std::fs::create_dir_all(&inner).unwrap();
let harn_file = write_file(&inner, "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert!(
cfg.fmt.line_width.is_none(),
"must not pick up harn.toml from above the .git boundary: got {:?}",
cfg.fmt.line_width,
);
}
#[test]
fn walk_stops_at_max_depth() {
let tmp = tempfile::tempdir().unwrap();
let mut dir = tmp.path().to_path_buf();
for i in 0..(MAX_PARENT_DIRS + 4) {
dir = dir.join(format!("lvl{i}"));
}
std::fs::create_dir_all(&dir).unwrap();
let harn_file = write_file(&dir, "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert!(cfg.fmt.line_width.is_none());
}
#[test]
fn ignores_unrelated_sections() {
let tmp = tempfile::tempdir().unwrap();
write_file(
tmp.path(),
"harn.toml",
r#"
[package]
name = "demo"
version = "0.1.0"
[dependencies]
foo = { path = "../foo" }
[fmt]
line_width = 77
"#,
);
let harn_file = write_file(tmp.path(), "main.harn", "pipeline default(t) {}\n");
let cfg = load_for_path(&harn_file).expect("load");
assert_eq!(cfg.fmt.line_width, Some(77));
}
}