use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectConfig {
pub write_policy: WritePolicy,
pub exclude: Exclude,
pub output: Output,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WritePolicy {
pub ensure_final_newline: Option<bool>,
pub normalize_eol: Option<String>,
pub trim_trailing_whitespace: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Exclude {
#[serde(default)]
pub globs: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Output {
pub color: Option<String>,
}
pub fn find_and_load(start: &Path) -> Option<(ProjectConfig, PathBuf)> {
let mut dir = start.to_path_buf();
loop {
let candidate = dir.join(".patchloom.toml");
if candidate.is_file() {
let content = match std::fs::read_to_string(&candidate) {
Ok(c) => c,
Err(e) => {
eprintln!("warning: could not read {}: {}", candidate.display(), e);
return None;
}
};
match toml_edit::de::from_str::<ProjectConfig>(&content) {
Ok(config) => return Some((config, dir)),
Err(e) => {
eprintln!("warning: malformed {}: {}", candidate.display(), e);
return None;
}
}
}
if !dir.pop() {
return None;
}
}
}
pub fn apply_config(global: &mut crate::cli::global::GlobalFlags, config: &ProjectConfig) {
if !global.ensure_final_newline && config.write_policy.ensure_final_newline == Some(true) {
global.ensure_final_newline = true;
}
if global.normalize_eol.is_none() {
global.normalize_eol =
config
.write_policy
.normalize_eol
.as_deref()
.and_then(|eol| match eol {
"lf" => Some(crate::cli::global::EolMode::Lf),
"crlf" => Some(crate::cli::global::EolMode::Crlf),
_ => None,
});
}
if !global.trim_trailing_whitespace
&& config.write_policy.trim_trailing_whitespace == Some(true)
{
global.trim_trailing_whitespace = true;
}
if !config.exclude.globs.is_empty() {
let mut merged = config.exclude.globs.clone();
merged.append(&mut global.glob);
global.glob = merged;
}
if matches!(global.color, crate::cli::global::ColorMode::Auto)
&& let Some(ref color) = config.output.color
{
global.color = match color.as_str() {
"always" => crate::cli::global::ColorMode::Always,
"never" => crate::cli::global::ColorMode::Never,
_ => crate::cli::global::ColorMode::Auto,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn find_and_load_from_dir() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".patchloom.toml"),
r#"
[write_policy]
ensure_final_newline = true
normalize_eol = "lf"
[exclude]
globs = ["target/**"]
[output]
color = "always"
"#,
)
.unwrap();
let (config, found_dir) = find_and_load(dir.path()).unwrap();
assert_eq!(found_dir, dir.path());
assert_eq!(config.write_policy.ensure_final_newline, Some(true));
assert_eq!(config.write_policy.normalize_eol.as_deref(), Some("lf"));
assert_eq!(config.exclude.globs, vec!["target/**"]);
assert_eq!(config.output.color.as_deref(), Some("always"));
}
#[test]
fn find_and_load_walks_up() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".patchloom.toml"),
"[write_policy]\nensure_final_newline = true\n",
)
.unwrap();
let sub = dir.path().join("sub/deep");
std::fs::create_dir_all(&sub).unwrap();
let (config, found_dir) = find_and_load(&sub).unwrap();
assert_eq!(found_dir, dir.path());
assert_eq!(config.write_policy.ensure_final_newline, Some(true));
}
#[test]
fn find_and_load_returns_none_when_missing() {
let dir = TempDir::new().unwrap();
assert!(find_and_load(dir.path()).is_none());
}
#[test]
fn apply_config_sets_defaults() {
let config = ProjectConfig {
write_policy: WritePolicy {
ensure_final_newline: Some(true),
normalize_eol: Some("lf".into()),
trim_trailing_whitespace: Some(true),
},
exclude: Exclude {
globs: vec!["target/**".into()],
},
output: Output {
color: Some("always".into()),
},
};
let mut global = crate::cli::global::GlobalFlags::default();
apply_config(&mut global, &config);
assert!(global.ensure_final_newline);
assert!(matches!(
global.normalize_eol,
Some(crate::cli::global::EolMode::Lf)
));
assert!(global.trim_trailing_whitespace);
assert_eq!(global.glob, vec!["target/**"]);
assert!(matches!(
global.color,
crate::cli::global::ColorMode::Always
));
}
#[test]
fn apply_config_cli_flags_win() {
let config = ProjectConfig {
write_policy: WritePolicy {
ensure_final_newline: Some(true),
..WritePolicy::default()
},
exclude: Exclude {
globs: vec!["config_glob".into()],
},
..ProjectConfig::default()
};
let mut global = crate::cli::global::GlobalFlags {
ensure_final_newline: true,
glob: vec!["user_glob".into()],
color: crate::cli::global::ColorMode::Never,
..crate::cli::global::GlobalFlags::default()
};
apply_config(&mut global, &config);
assert!(global.ensure_final_newline);
assert_eq!(global.glob, vec!["config_glob", "user_glob"]);
assert!(matches!(global.color, crate::cli::global::ColorMode::Never));
}
#[test]
fn find_and_load_rejects_unknown_keys() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".patchloom.toml"),
"[write_policy]\nensur_final_newline = true\n",
)
.unwrap();
assert!(find_and_load(dir.path()).is_none());
}
#[test]
fn find_and_load_returns_none_on_malformed_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(".patchloom.toml"),
"this is not valid { toml [",
)
.unwrap();
assert!(find_and_load(dir.path()).is_none());
}
#[test]
fn apply_config_unknown_eol_value_ignored() {
let config = ProjectConfig {
write_policy: WritePolicy {
normalize_eol: Some("CRLF".into()), ..WritePolicy::default()
},
..ProjectConfig::default()
};
let mut global = crate::cli::global::GlobalFlags::default();
apply_config(&mut global, &config);
assert!(global.normalize_eol.is_none());
}
#[test]
fn apply_config_unknown_color_value_stays_auto() {
let config = ProjectConfig {
output: Output {
color: Some("yes".into()), },
..ProjectConfig::default()
};
let mut global = crate::cli::global::GlobalFlags::default();
apply_config(&mut global, &config);
assert!(matches!(global.color, crate::cli::global::ColorMode::Auto));
}
}