use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
const CONFIG_NAME: &str = ".proj-janitor.toml";
#[derive(Deserialize, Default, Debug)]
#[serde(deny_unknown_fields)]
pub(crate) struct Config {
#[serde(default)]
pub(crate) backup: BackupConfig,
#[serde(default)]
pub(crate) prune: PruneConfig,
}
#[derive(Deserialize, Default, Debug)]
#[serde(deny_unknown_fields)]
pub(crate) struct BackupConfig {
#[serde(default)]
pub(crate) dest: Option<PathBuf>,
#[serde(default)]
pub(crate) keep: Option<usize>,
#[serde(default)]
pub(crate) exclude: Vec<String>,
#[serde(default)]
pub(crate) incremental: bool,
}
#[derive(Deserialize, Default, Debug)]
#[serde(deny_unknown_fields)]
pub(crate) struct PruneConfig {
#[serde(default)]
pub(crate) keep: Option<usize>,
}
pub(crate) fn discover_config(explicit: Option<&Path>) -> Result<Option<Config>> {
let path = match explicit {
Some(path) => Some(path.to_path_buf()),
None => find_config(&std::env::current_dir()?),
};
let Some(path) = path else {
return Ok(None);
};
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read config {}", path.display()))?;
let config: Config =
toml::from_str(&text).with_context(|| format!("invalid config {}", path.display()))?;
anyhow::ensure!(
config.backup.keep.is_none_or(|keep| keep >= 1),
"invalid config {}: [backup].keep must be a positive integer",
path.display()
);
anyhow::ensure!(
config.prune.keep.is_none_or(|keep| keep >= 1),
"invalid config {}: [prune].keep must be a positive integer",
path.display()
);
for pattern in &config.backup.exclude {
anyhow::ensure!(
!pattern.is_empty(),
"invalid config {}: [backup].exclude entries must not be empty",
path.display()
);
anyhow::ensure!(
!pattern.contains('{') && !pattern.contains('}'),
"invalid config {}: [backup].exclude brace expansion is not supported: {}",
path.display(),
pattern
);
}
Ok(Some(config))
}
fn find_config(start: &Path) -> Option<PathBuf> {
start.ancestors().find_map(|dir| {
let candidate = dir.join(CONFIG_NAME);
candidate.is_file().then_some(candidate)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT_TEMP: AtomicU32 = AtomicU32::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"projjan-config-test-{}-{}",
std::process::id(),
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn write_config(dir: &Path, content: &str) -> PathBuf {
let path = dir.join(CONFIG_NAME);
fs::write(&path, content).unwrap();
path
}
#[test]
fn explicit_path_loads_backup_dest() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\ndest = \"/tmp/backups\"\n");
let config = discover_config(Some(&path)).unwrap().unwrap();
assert_eq!(config.backup.dest, Some(PathBuf::from("/tmp/backups")));
assert_eq!(config.prune.keep, None);
}
#[test]
fn explicit_path_loads_prune_keep() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[prune]\nkeep = 7\n");
let config = discover_config(Some(&path)).unwrap().unwrap();
assert_eq!(config.prune.keep, Some(7));
assert_eq!(config.backup.dest, None);
}
#[test]
fn explicit_path_loads_backup_keep() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nkeep = 5\n");
let config = discover_config(Some(&path)).unwrap().unwrap();
assert_eq!(config.backup.keep, Some(5));
}
#[test]
fn explicit_path_loads_backup_exclude() {
let temp = TempDir::new();
let path = write_config(
temp.0.as_path(),
"[backup]\nexclude = [\"node_modules\", \"*.log\"]\n",
);
let config = discover_config(Some(&path)).unwrap().unwrap();
assert_eq!(config.backup.exclude, vec!["node_modules", "*.log"]);
assert_eq!(config.backup.dest, None);
assert_eq!(config.backup.keep, None);
}
#[test]
fn empty_exclude_entry_is_rejected() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nexclude = [\"\"]\n");
let err = discover_config(Some(&path)).unwrap_err().to_string();
assert!(
err.contains("[backup].exclude entries must not be empty"),
"{err}"
);
}
#[test]
fn brace_expansion_is_rejected_for_exclude() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nexclude = [\"{a,b}\"]\n");
let err = discover_config(Some(&path)).unwrap_err().to_string();
assert!(err.contains("brace expansion is not supported"), "{err}");
}
#[test]
fn explicit_path_loads_backup_incremental() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nincremental = true\n");
let config = discover_config(Some(&path)).unwrap().unwrap();
assert!(config.backup.incremental);
assert_eq!(config.backup.dest, None);
assert_eq!(config.backup.keep, None);
assert_eq!(config.backup.exclude, Vec::<String>::new());
}
#[test]
fn incremental_defaults_to_false() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\ndest = \"/tmp/x\"\n");
let config = discover_config(Some(&path)).unwrap().unwrap();
assert!(!config.backup.incremental);
}
#[test]
fn zero_keep_is_rejected_for_backup_and_prune() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nkeep = 0\n");
let err = discover_config(Some(&path)).unwrap_err().to_string();
assert!(
err.contains("[backup].keep must be a positive integer"),
"{err}"
);
let path = write_config(temp.0.as_path(), "[prune]\nkeep = 0\n");
let err = discover_config(Some(&path)).unwrap_err().to_string();
assert!(
err.contains("[prune].keep must be a positive integer"),
"{err}"
);
}
#[test]
fn explicit_path_missing_is_error() {
let temp = TempDir::new();
let missing = temp.0.join("nope.toml");
assert!(discover_config(Some(&missing)).is_err());
}
#[test]
fn invalid_toml_is_error() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "backup = [not toml\n");
let err = discover_config(Some(&path)).unwrap_err().to_string();
assert!(err.contains("invalid config"));
}
#[test]
fn unknown_field_is_rejected() {
let temp = TempDir::new();
let path = write_config(temp.0.as_path(), "[backup]\nunknown = 1\n");
assert!(discover_config(Some(&path)).is_err());
}
#[test]
fn no_config_anywhere_returns_none() {
assert!(discover_config(None).unwrap().is_none());
}
#[test]
fn find_config_walks_up_ancestors() {
let temp = TempDir::new();
let nested = temp.0.join("a/b/c");
fs::create_dir_all(&nested).unwrap();
write_config(temp.0.as_path(), "[prune]\nkeep = 3\n");
let found = find_config(&nested).unwrap();
assert_eq!(found, temp.0.join(CONFIG_NAME));
}
}