use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use xdg::BaseDirectories;
use crate::error::{Error, Result};
pub const ENV_CONFIG_PATH: &str = "STAGECREW_CONFIG_PATH";
pub const ENV_DB_PATH: &str = "STAGECREW_DB_PATH";
pub struct AppPaths {
xdg: BaseDirectories,
config_path_override: Option<PathBuf>,
db_path_override: Option<PathBuf>,
}
impl AppPaths {
#[must_use]
pub fn new() -> Self {
Self::with_overrides(Self::env_path(ENV_CONFIG_PATH), Self::env_path(ENV_DB_PATH))
}
#[must_use]
pub fn with_overrides(
config_path_override: Option<PathBuf>,
db_path_override: Option<PathBuf>,
) -> Self {
let xdg = BaseDirectories::with_prefix("stagecrew");
Self {
xdg,
config_path_override,
db_path_override,
}
}
fn env_path(var: &str) -> Option<PathBuf> {
std::env::var(var)
.ok()
.filter(|s| !s.trim().is_empty())
.map(PathBuf::from)
}
fn ensure_parent_exists(path: &std::path::Path) -> std::io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
Ok(())
}
pub fn config_file(&self) -> std::io::Result<PathBuf> {
if let Some(path) = &self.config_path_override {
Self::ensure_parent_exists(path)?;
return Ok(path.clone());
}
self.xdg.place_config_file("config.toml")
}
pub fn log_file(&self) -> std::io::Result<PathBuf> {
self.xdg.place_cache_file("stagecrew.log")
}
pub fn database_file(&self, config: &Config) -> std::io::Result<PathBuf> {
if let Some(path) = &self.db_path_override {
Self::ensure_parent_exists(path)?;
return Ok(path.clone());
}
if let Some(db_path) = &config.database_path {
Self::ensure_parent_exists(db_path)?;
return Ok(db_path.clone());
}
if let Some(first_tracked) = config.tracked_paths.first()
&& let Some(parent) = first_tracked.parent()
{
let db_dir = parent.join(".stagecrew");
std::fs::create_dir_all(&db_dir)?;
return Ok(db_dir.join("stagecrew.db"));
}
self.xdg.place_data_file("stagecrew.db")
}
}
impl Default for AppPaths {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(default)]
#[non_exhaustive]
pub struct Config {
pub tracked_paths: Vec<PathBuf>,
pub expiration_days: u32,
pub warning_days: u32,
pub auto_remove: bool,
pub scan_interval_hours: u32,
pub scan_start_time: Option<String>,
pub database_path: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
tracked_paths: Vec::new(),
expiration_days: 90,
warning_days: 14,
auto_remove: false,
scan_interval_hours: 24,
scan_start_time: None,
database_path: None,
}
}
}
impl Config {
pub fn load(paths: &AppPaths) -> Result<Self> {
let config_path = paths.config_file()?;
if !config_path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(&config_path).map_err(|e| Error::Filesystem {
path: config_path.clone(),
source: e,
})?;
let mut config: Self =
toml::from_str(&contents).map_err(|e| Error::Config(e.to_string()))?;
config.tracked_paths = config
.tracked_paths
.into_iter()
.map(|p| {
let path_str = p.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
PathBuf::from(expanded.as_ref())
})
.collect();
if let Some(db_path) = config.database_path.take() {
let path_str = db_path.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
config.database_path = Some(PathBuf::from(expanded.as_ref()));
}
config.validate()?;
Ok(config)
}
pub fn save(&self, paths: &AppPaths) -> Result<()> {
self.validate()?;
let config_path = paths.config_file()?;
let contents = self.to_file_contents()?;
std::fs::write(&config_path, contents).map_err(|e| Error::Filesystem {
path: config_path,
source: e,
})?;
Ok(())
}
pub fn to_file_contents(&self) -> Result<String> {
self.validate()?;
let toml_body = toml::to_string_pretty(self).map_err(|e| Error::Config(e.to_string()))?;
Ok(format!("{}\n{toml_body}", schema_comment()))
}
pub fn validate(&self) -> Result<()> {
if let Some(scan_start_time) = &self.scan_start_time {
Timestamp::from_str(scan_start_time).map_err(|e| {
Error::Config(format!(
"scan_start_time must be a valid RFC 3339 timestamp: {e}"
))
})?;
}
Ok(())
}
}
const GITHUB_REPO: &str = "nrminor/stagecrew";
fn schema_url() -> String {
let version = env!("CARGO_PKG_VERSION");
format!("https://github.com/{GITHUB_REPO}/releases/download/v{version}/stagecrew.schema.json")
}
fn schema_comment() -> String {
format!("#:schema {}", schema_url())
}
pub const LOCAL_CONFIG_FILENAME: &str = "stagecrew.toml";
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct LocalConfig {
expiration_days: Option<u32>,
warning_days: Option<u32>,
auto_remove: Option<bool>,
scan_interval_hours: Option<u32>,
}
impl LocalConfig {
fn load(root: &Path) -> Result<Option<Self>> {
let config_path = root.join(LOCAL_CONFIG_FILENAME);
if !config_path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&config_path).map_err(|e| Error::Filesystem {
path: config_path.clone(),
source: e,
})?;
let local: Self = toml::from_str(&contents).map_err(|e| Error::Config(e.to_string()))?;
Ok(Some(local))
}
fn merge_into(&self, base: &Config) -> Config {
Config {
tracked_paths: base.tracked_paths.clone(),
expiration_days: self.expiration_days.unwrap_or(base.expiration_days),
warning_days: self.warning_days.unwrap_or(base.warning_days),
auto_remove: self.auto_remove.unwrap_or(base.auto_remove),
scan_interval_hours: self.scan_interval_hours.unwrap_or(base.scan_interval_hours),
scan_start_time: base.scan_start_time.clone(),
database_path: base.database_path.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct AppConfig {
pub global: Config,
per_root: HashMap<PathBuf, Config>,
}
impl AppConfig {
#[must_use]
pub fn from_global(global: Config) -> Self {
Self {
global,
per_root: HashMap::new(),
}
}
pub fn load_per_root(&mut self, roots: &[PathBuf]) {
self.per_root.clear();
for root in roots {
match LocalConfig::load(root) {
Ok(Some(local)) => {
let merged = local.merge_into(&self.global);
self.per_root.insert(root.clone(), merged);
}
Ok(None) => {
}
Err(e) => {
tracing::warn!(
root = %root.display(),
error = %e,
"Failed to load local config, using global config for this root"
);
}
}
}
}
#[must_use]
pub fn for_root(&self, root: &Path) -> &Config {
self.per_root.get(root).unwrap_or(&self.global)
}
pub fn load(paths: &AppPaths, roots: &[PathBuf]) -> Result<Self> {
let global = Config::load(paths)?;
let mut app_config = Self::from_global(global);
app_config.load_per_root(roots);
Ok(app_config)
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use super::*;
#[test]
fn config_default_values() {
let config = Config::default();
assert!(config.tracked_paths.is_empty());
assert_eq!(config.expiration_days, 90);
assert_eq!(config.warning_days, 14);
assert!(!config.auto_remove);
assert_eq!(config.scan_interval_hours, 24);
assert!(config.scan_start_time.is_none());
assert!(config.database_path.is_none());
}
#[test]
fn config_serializes_to_toml() {
let config = Config {
tracked_paths: vec![PathBuf::from("/data/staging")],
expiration_days: 60,
warning_days: 7,
auto_remove: true,
scan_interval_hours: 12,
scan_start_time: Some("2026-04-03T08:00:00Z".to_string()),
database_path: Some(PathBuf::from("/shared/.stagecrew/db.sqlite")),
};
let toml_str = toml::to_string_pretty(&config).expect("serialization should succeed");
assert!(toml_str.contains("tracked_paths"));
assert!(toml_str.contains("/data/staging"));
assert!(toml_str.contains("expiration_days = 60"));
assert!(toml_str.contains("warning_days = 7"));
assert!(toml_str.contains("auto_remove = true"));
assert!(toml_str.contains("scan_interval_hours = 12"));
assert!(toml_str.contains("scan_start_time = \"2026-04-03T08:00:00Z\""));
assert!(toml_str.contains("database_path"));
}
#[test]
fn config_deserializes_from_toml() {
let toml_str = r#"
tracked_paths = ["/data/staging", "/scratch/user"]
expiration_days = 30
warning_days = 5
auto_remove = false
scan_interval_hours = 6
scan_start_time = "2026-04-03T08:00:00Z"
database_path = "/custom/path/db.sqlite"
"#;
let config: Config = toml::from_str(toml_str).expect("deserialization should succeed");
assert_eq!(config.tracked_paths.len(), 2);
assert_eq!(config.tracked_paths[0], PathBuf::from("/data/staging"));
assert_eq!(config.tracked_paths[1], PathBuf::from("/scratch/user"));
assert_eq!(config.expiration_days, 30);
assert_eq!(config.warning_days, 5);
assert!(!config.auto_remove);
assert_eq!(config.scan_interval_hours, 6);
assert_eq!(
config.scan_start_time.as_deref(),
Some("2026-04-03T08:00:00Z")
);
assert_eq!(
config.database_path,
Some(PathBuf::from("/custom/path/db.sqlite"))
);
}
#[test]
fn config_uses_defaults_for_missing_fields() {
let toml_str = r#"
tracked_paths = ["/data/staging"]
"#;
let config: Config = toml::from_str(toml_str).expect("deserialization should succeed");
assert_eq!(config.tracked_paths.len(), 1);
assert_eq!(config.expiration_days, 90);
assert_eq!(config.warning_days, 14);
assert!(!config.auto_remove);
assert_eq!(config.scan_interval_hours, 24);
assert!(config.scan_start_time.is_none());
assert!(config.database_path.is_none());
}
#[test]
fn config_rejects_invalid_scan_start_time() {
let toml_str = r#"
tracked_paths = ["/data/staging"]
scan_start_time = "not-a-timestamp"
"#;
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
std::fs::write(&config_path, toml_str).expect("write config file");
let paths = AppPaths::with_overrides(Some(config_path), None);
let err = Config::load(&paths).expect_err("invalid scan_start_time should fail validation");
let msg = err.to_string();
assert!(
msg.contains("scan_start_time must be a valid RFC 3339 timestamp"),
"unexpected error message: {msg}"
);
}
#[test]
fn config_schema_includes_scan_start_time() {
let schema = schemars::schema_for!(Config);
let json = serde_json::to_value(&schema).expect("serialize schema");
let properties = json
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema should contain properties object");
assert!(
properties.contains_key("scan_start_time"),
"config schema should expose scan_start_time"
);
}
#[test]
fn database_file_uses_explicit_path_when_set() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let explicit_db = temp_dir.path().join("custom/db.sqlite");
let config = Config {
database_path: Some(explicit_db.clone()),
..Config::default()
};
let paths = AppPaths::new();
let result = paths.database_file(&config).expect("should resolve path");
assert_eq!(result, explicit_db);
assert!(
explicit_db
.parent()
.expect(
"database path should have a parent directory - check that the path is not root"
)
.exists()
);
}
#[test]
fn database_file_derives_from_tracked_paths() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let tracked = temp_dir.path().join("staging/project");
std::fs::create_dir_all(&tracked).expect("create tracked dir");
let config = Config {
tracked_paths: vec![tracked.clone()],
database_path: None,
..Config::default()
};
let paths = AppPaths::new();
let result = paths.database_file(&config).expect("should resolve path");
let expected = temp_dir.path().join("staging/.stagecrew/stagecrew.db");
assert_eq!(result, expected);
assert!(
expected
.parent()
.expect(
"database path should have a parent directory - check that the path is not root"
)
.exists()
);
}
#[test]
fn database_file_falls_back_to_xdg_when_no_tracked_paths() {
let config = Config::default();
let paths = AppPaths::new();
let result = paths.database_file(&config).expect("should resolve path");
assert!(result.ends_with("stagecrew.db"));
assert!(result.to_string_lossy().contains("stagecrew"));
}
#[test]
fn database_file_explicit_path_takes_precedence_over_tracked_paths() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let explicit_db = temp_dir.path().join("explicit/db.sqlite");
let tracked = temp_dir.path().join("staging/project");
std::fs::create_dir_all(&tracked).expect("create tracked dir");
let config = Config {
database_path: Some(explicit_db.clone()),
tracked_paths: vec![tracked],
..Config::default()
};
let paths = AppPaths::new();
let result = paths.database_file(&config).expect("should resolve path");
assert_eq!(result, explicit_db);
}
#[test]
fn config_load_expands_tilde_in_tracked_paths() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let toml_content = r#"
tracked_paths = ["~/Downloads", "~/Documents/staging"]
expiration_days = 90
"#;
std::fs::write(&config_path, toml_content).expect("write config file");
let contents = std::fs::read_to_string(&config_path).expect("read config file");
let mut config: Config = toml::from_str(&contents).expect("parse config");
config.tracked_paths = config
.tracked_paths
.into_iter()
.map(|p| {
let path_str = p.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
PathBuf::from(expanded.as_ref())
})
.collect();
let home_dir = dirs::home_dir().expect("home directory should be available");
assert_eq!(config.tracked_paths[0], home_dir.join("Downloads"));
assert_eq!(config.tracked_paths[1], home_dir.join("Documents/staging"));
}
#[test]
fn config_expands_tilde_in_database_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let toml_content = r#"
tracked_paths = ["/data/staging"]
database_path = "~/.local/share/stagecrew/db.sqlite"
"#;
std::fs::write(&config_path, toml_content).expect("write config file");
let contents = std::fs::read_to_string(&config_path).expect("read config file");
let mut config: Config = toml::from_str(&contents).expect("parse config");
if let Some(db_path) = config.database_path.take() {
let path_str = db_path.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
config.database_path = Some(PathBuf::from(expanded.as_ref()));
}
let home_dir = dirs::home_dir().expect("home directory should be available");
assert_eq!(
config.database_path,
Some(home_dir.join(".local/share/stagecrew/db.sqlite"))
);
}
#[test]
fn config_handles_paths_without_tilde() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let toml_content = r#"
tracked_paths = ["/data/staging", "./relative/path"]
database_path = "/var/lib/stagecrew/db.sqlite"
"#;
std::fs::write(&config_path, toml_content).expect("write config file");
let contents = std::fs::read_to_string(&config_path).expect("read config file");
let mut config: Config = toml::from_str(&contents).expect("parse config");
config.tracked_paths = config
.tracked_paths
.into_iter()
.map(|p| {
let path_str = p.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
PathBuf::from(expanded.as_ref())
})
.collect();
if let Some(db_path) = config.database_path.take() {
let path_str = db_path.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
config.database_path = Some(PathBuf::from(expanded.as_ref()));
}
assert_eq!(config.tracked_paths[0], PathBuf::from("/data/staging"));
assert_eq!(config.tracked_paths[1], PathBuf::from("./relative/path"));
assert_eq!(
config.database_path,
Some(PathBuf::from("/var/lib/stagecrew/db.sqlite"))
);
}
#[test]
fn config_expands_tilde_only_prefix() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let toml_content = r#"
tracked_paths = ["~/projects/~backup"]
"#;
std::fs::write(&config_path, toml_content).expect("write config file");
let contents = std::fs::read_to_string(&config_path).expect("read config file");
let mut config: Config = toml::from_str(&contents).expect("parse config");
config.tracked_paths = config
.tracked_paths
.into_iter()
.map(|p| {
let path_str = p.to_string_lossy();
let expanded = shellexpand::tilde(&path_str);
PathBuf::from(expanded.as_ref())
})
.collect();
let home_dir = dirs::home_dir().expect("home directory should be available");
let expected = home_dir.join("projects/~backup");
assert_eq!(config.tracked_paths[0], expected);
}
#[test]
fn config_file_uses_override_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let custom_config = temp_dir.path().join("custom/config.toml");
let paths = AppPaths::with_overrides(Some(custom_config.clone()), None);
let result = paths.config_file().expect("should resolve path");
assert_eq!(result, custom_config);
assert!(custom_config.parent().expect("has parent").exists());
}
#[test]
fn database_file_uses_override_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let custom_db = temp_dir.path().join("custom/stagecrew.db");
let config = Config {
database_path: Some(PathBuf::from("/should/be/ignored")),
tracked_paths: vec![PathBuf::from("/also/ignored")],
..Config::default()
};
let paths = AppPaths::with_overrides(None, Some(custom_db.clone()));
let result = paths.database_file(&config).expect("should resolve path");
assert_eq!(result, custom_db);
assert!(custom_db.parent().expect("has parent").exists());
}
#[test]
fn database_file_override_beats_config_database_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let override_db = temp_dir.path().join("override/db.sqlite");
let config_db = temp_dir.path().join("config/db.sqlite");
let config = Config {
database_path: Some(config_db),
..Config::default()
};
let paths = AppPaths::with_overrides(None, Some(override_db.clone()));
let result = paths.database_file(&config).expect("should resolve path");
assert_eq!(result, override_db);
}
#[test]
fn config_file_falls_back_to_xdg_when_no_override() {
let paths = AppPaths::with_overrides(None, None);
let result = paths.config_file().expect("should resolve path");
assert!(result.ends_with(std::path::Path::new("stagecrew/config.toml")));
}
#[test]
fn database_file_falls_back_to_config_when_no_override() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_db = temp_dir.path().join("config/db.sqlite");
let config = Config {
database_path: Some(config_db.clone()),
..Config::default()
};
let paths = AppPaths::with_overrides(None, None);
let result = paths.database_file(&config).expect("should resolve path");
assert_eq!(result, config_db);
}
#[test]
fn local_config_loads_from_root_directory() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let toml_content = r"
expiration_days = 30
warning_days = 7
auto_remove = true
";
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), toml_content).expect("write local config");
let local = LocalConfig::load(root)
.expect("load should succeed")
.expect("local config should exist");
assert_eq!(local.expiration_days, Some(30));
assert_eq!(local.warning_days, Some(7));
assert_eq!(local.auto_remove, Some(true));
assert_eq!(local.scan_interval_hours, None);
}
#[test]
fn local_config_returns_none_when_missing() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let result = LocalConfig::load(root).expect("load should succeed");
assert!(result.is_none());
}
#[test]
fn local_config_rejects_tracked_paths() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let toml_content = r#"
tracked_paths = ["/should/not/work"]
expiration_days = 30
"#;
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), toml_content).expect("write local config");
let result = LocalConfig::load(root);
assert!(
result.is_err(),
"should reject tracked_paths in local config"
);
}
#[test]
fn local_config_rejects_unknown_fields() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let toml_content = r#"
expiration_days = 30
unknown_field = "should fail"
"#;
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), toml_content).expect("write local config");
let result = LocalConfig::load(root);
assert!(result.is_err(), "should reject unknown fields");
}
#[test]
#[cfg(unix)]
fn local_config_unreadable_file_returns_error() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let config_path = root.join(LOCAL_CONFIG_FILENAME);
std::fs::write(&config_path, "expiration_days = 30").expect("write local config");
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000))
.expect("set permissions");
let result = LocalConfig::load(root);
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644))
.expect("restore permissions");
assert!(result.is_err(), "should fail on unreadable file");
}
#[test]
fn local_config_nonexistent_root_returns_none() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let nonexistent_root = temp_dir.path().join("does_not_exist");
let result = LocalConfig::load(&nonexistent_root);
let local_config = result.expect("should not error when root doesn't exist");
assert!(
local_config.is_none(),
"should return None when root doesn't exist"
);
}
#[test]
fn local_config_path_is_directory_returns_error() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
std::fs::create_dir(root.join(LOCAL_CONFIG_FILENAME)).expect("create config as directory");
let result = LocalConfig::load(root);
assert!(
result.is_err(),
"should error when config path is a directory"
);
}
#[test]
fn local_config_merge_overrides_base_values() {
let base = Config {
tracked_paths: vec![PathBuf::from("/data")],
expiration_days: 90,
warning_days: 14,
auto_remove: false,
scan_interval_hours: 24,
scan_start_time: Some("2026-04-03T08:00:00Z".to_string()),
database_path: Some(PathBuf::from("/global.db")),
};
let local = LocalConfig {
expiration_days: Some(30),
warning_days: None,
auto_remove: Some(true),
scan_interval_hours: None,
};
let merged = local.merge_into(&base);
assert_eq!(merged.tracked_paths, vec![PathBuf::from("/data")]);
assert_eq!(merged.expiration_days, 30);
assert_eq!(merged.warning_days, 14);
assert!(merged.auto_remove);
assert_eq!(merged.scan_interval_hours, 24);
assert_eq!(
merged.scan_start_time.as_deref(),
Some("2026-04-03T08:00:00Z")
);
assert_eq!(merged.database_path, Some(PathBuf::from("/global.db")));
}
#[test]
fn local_config_merge_preserves_base_when_none() {
let base = Config {
tracked_paths: vec![PathBuf::from("/data")],
expiration_days: 90,
warning_days: 14,
auto_remove: false,
scan_interval_hours: 24,
scan_start_time: Some("2026-04-03T08:00:00Z".to_string()),
database_path: Some(PathBuf::from("/global.db")),
};
let local = LocalConfig::default();
let merged = local.merge_into(&base);
assert_eq!(merged.expiration_days, 90);
assert_eq!(merged.warning_days, 14);
assert!(!merged.auto_remove);
assert_eq!(merged.scan_interval_hours, 24);
assert_eq!(
merged.scan_start_time.as_deref(),
Some("2026-04-03T08:00:00Z")
);
assert_eq!(merged.database_path, Some(PathBuf::from("/global.db")));
}
#[test]
fn local_config_rejects_wrong_field_types() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let toml_content = r#"expiration_days = "not a number""#;
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), toml_content).expect("write local config");
let result = LocalConfig::load(root);
assert!(result.is_err(), "should reject wrong field type");
}
#[test]
fn local_config_rejects_database_path() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path();
let toml_content = r#"database_path = "/absolute/path/to/db.sqlite""#;
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), toml_content).expect("write local config");
let result = LocalConfig::load(root);
assert!(
result.is_err(),
"should reject database_path in local config"
);
}
#[test]
fn local_config_merge_overrides_non_expiration_fields() {
let base = Config::default();
let local = LocalConfig {
expiration_days: None,
warning_days: Some(7),
auto_remove: Some(true),
scan_interval_hours: Some(12),
};
let merged = local.merge_into(&base);
assert_eq!(merged.expiration_days, 90);
assert_eq!(merged.warning_days, 7);
assert!(merged.auto_remove);
assert_eq!(merged.scan_interval_hours, 12);
assert_eq!(merged.database_path, None);
}
#[test]
fn app_config_from_global_has_empty_per_root() {
let global = Config::default();
let app_config = AppConfig::from_global(global.clone());
assert_eq!(app_config.global.expiration_days, global.expiration_days);
assert!(app_config.per_root.is_empty());
}
#[test]
fn app_config_for_root_returns_global_when_no_local() {
let global = Config {
expiration_days: 90,
..Config::default()
};
let app_config = AppConfig::from_global(global);
let result = app_config.for_root(Path::new("/some/root"));
assert_eq!(result.expiration_days, 90);
}
#[test]
fn app_config_load_per_root_discovers_local_configs() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root1 = temp_dir.path().join("root1");
let root2 = temp_dir.path().join("root2");
std::fs::create_dir_all(&root1).expect("create root1");
std::fs::create_dir_all(&root2).expect("create root2");
std::fs::write(root1.join(LOCAL_CONFIG_FILENAME), "expiration_days = 30")
.expect("write root1 config");
let global = Config {
expiration_days: 90,
..Config::default()
};
let mut app_config = AppConfig::from_global(global);
app_config.load_per_root(&[root1.clone(), root2.clone()]);
assert_eq!(app_config.for_root(&root1).expiration_days, 30);
assert_eq!(app_config.for_root(&root2).expiration_days, 90);
}
#[test]
fn app_config_load_per_root_clears_previous() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root1 = temp_dir.path().join("root1");
let root2 = temp_dir.path().join("root2");
std::fs::create_dir_all(&root1).expect("create root1");
std::fs::create_dir_all(&root2).expect("create root2");
std::fs::write(root1.join(LOCAL_CONFIG_FILENAME), "expiration_days = 30")
.expect("write root1 config");
std::fs::write(root2.join(LOCAL_CONFIG_FILENAME), "expiration_days = 60")
.expect("write root2 config");
let global = Config::default();
let mut app_config = AppConfig::from_global(global);
app_config.load_per_root(&[root1.clone(), root2.clone()]);
assert_eq!(app_config.for_root(&root1).expiration_days, 30);
assert_eq!(app_config.for_root(&root2).expiration_days, 60);
app_config.load_per_root(std::slice::from_ref(&root2));
assert_eq!(
app_config.for_root(&root1).expiration_days,
app_config.global.expiration_days
);
assert_eq!(app_config.for_root(&root2).expiration_days, 60);
}
#[test]
fn app_config_handles_malformed_local_config() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let root = temp_dir.path().join("root");
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(
root.join(LOCAL_CONFIG_FILENAME),
"this is not valid toml {{{",
)
.expect("write bad config");
let global = Config {
expiration_days: 90,
..Config::default()
};
let mut app_config = AppConfig::from_global(global);
app_config.load_per_root(std::slice::from_ref(&root));
assert_eq!(app_config.for_root(&root).expiration_days, 90);
}
#[test]
fn app_config_reload_refreshes_configs() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let config_path = temp_dir.path().join("config.toml");
let root = temp_dir.path().join("root");
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(&config_path, "expiration_days = 90").expect("write global config");
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), "expiration_days = 30")
.expect("write local config");
let paths = AppPaths::with_overrides(Some(config_path.clone()), None);
let global = Config::load(&paths).expect("load global");
let mut app_config = AppConfig::from_global(global);
app_config.load_per_root(std::slice::from_ref(&root));
assert_eq!(app_config.global.expiration_days, 90);
assert_eq!(app_config.for_root(&root).expiration_days, 30);
std::fs::write(&config_path, "expiration_days = 120").expect("update global config");
std::fs::write(root.join(LOCAL_CONFIG_FILENAME), "expiration_days = 45")
.expect("update local config");
let app_config = AppConfig::load(&paths, std::slice::from_ref(&root)).expect("reload");
assert_eq!(app_config.global.expiration_days, 120);
assert_eq!(app_config.for_root(&root).expiration_days, 45);
}
}