use std::path::{Path, PathBuf};
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use crate::model::{DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH, DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS};
use crate::scan_cache::{DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS, ScanCachePolicy};
use crate::{RebeccaError, Result};
pub const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppPaths {
pub config_dir: PathBuf,
pub config_file: PathBuf,
pub state_dir: PathBuf,
pub cache_dir: PathBuf,
pub history_file: PathBuf,
}
impl AppPaths {
pub fn storage_entries(&self) -> Vec<AppStorageEntry> {
vec![
AppStorageEntry::new(
AppStorageId::ConfigFile,
self.config_file.clone(),
AppStorageLifecycle::Configuration,
AppStorageRetention::Preserve,
),
AppStorageEntry::new(
AppStorageId::ConfigDir,
self.config_dir.clone(),
AppStorageLifecycle::Configuration,
AppStorageRetention::Preserve,
),
AppStorageEntry::new(
AppStorageId::StateDir,
self.state_dir.clone(),
AppStorageLifecycle::DurableState,
AppStorageRetention::Preserve,
),
AppStorageEntry::new(
AppStorageId::CacheDir,
self.cache_dir.clone(),
AppStorageLifecycle::RebuildableCache,
AppStorageRetention::Rebuildable,
),
AppStorageEntry::new(
AppStorageId::HistoryFile,
self.history_file.clone(),
AppStorageLifecycle::AppendOnlyHistory,
AppStorageRetention::Preserve,
),
]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppRuntimeConfig {
pub app_paths: AppPaths,
pub scan_cache_policy: ScanCachePolicy,
pub protected_paths: Vec<PathBuf>,
pub purge: PurgeRuntimeConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PurgeRuntimeConfig {
pub roots: Vec<PathBuf>,
pub max_depth: usize,
pub min_age_days: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppStorageEntry {
pub id: AppStorageId,
pub path: PathBuf,
pub lifecycle: AppStorageLifecycle,
pub retention: AppStorageRetention,
}
impl AppStorageEntry {
fn new(
id: AppStorageId,
path: PathBuf,
lifecycle: AppStorageLifecycle,
retention: AppStorageRetention,
) -> Self {
Self {
id,
path,
lifecycle,
retention,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AppStorageId {
ConfigFile,
ConfigDir,
StateDir,
CacheDir,
HistoryFile,
}
impl AppStorageId {
pub fn label(self) -> &'static str {
match self {
Self::ConfigFile => "Config file",
Self::ConfigDir => "Config dir",
Self::StateDir => "State dir",
Self::CacheDir => "Cache dir",
Self::HistoryFile => "History",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AppStorageLifecycle {
Configuration,
DurableState,
RebuildableCache,
AppendOnlyHistory,
}
impl AppStorageLifecycle {
pub fn label(self) -> &'static str {
match self {
Self::Configuration => "configuration",
Self::DurableState => "durable state",
Self::RebuildableCache => "rebuildable cache",
Self::AppendOnlyHistory => "append-only history",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AppStorageRetention {
Preserve,
Rebuildable,
}
impl AppStorageRetention {
pub fn label(self) -> &'static str {
match self {
Self::Preserve => "preserve",
Self::Rebuildable => "rebuildable",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RebeccaConfig {
pub version: u32,
pub app_paths: RebeccaAppPathsConfig,
pub scan_cache: RebeccaScanCacheConfig,
pub protection: RebeccaProtectionConfig,
pub purge: RebeccaPurgeConfig,
}
impl Default for RebeccaConfig {
fn default() -> Self {
Self {
version: CONFIG_SCHEMA_VERSION,
app_paths: RebeccaAppPathsConfig::default(),
scan_cache: RebeccaScanCacheConfig::default(),
protection: RebeccaProtectionConfig::default(),
purge: RebeccaPurgeConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RebeccaAppPathsConfig {
pub state_dir: Option<PathBuf>,
pub cache_dir: Option<PathBuf>,
pub history_file: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RebeccaScanCacheConfig {
#[serde(default = "default_directory_record_max_age_seconds")]
pub directory_record_max_age_seconds: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RebeccaProtectionConfig {
pub protected_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RebeccaPurgeConfig {
pub roots: Vec<PathBuf>,
#[serde(default = "default_project_artifact_max_depth")]
pub max_depth: usize,
#[serde(default = "default_project_artifact_min_age_days")]
pub min_age_days: u64,
}
impl RebeccaScanCacheConfig {
pub fn policy(&self) -> ScanCachePolicy {
ScanCachePolicy::new(self.directory_record_max_age_seconds)
}
}
impl Default for RebeccaScanCacheConfig {
fn default() -> Self {
Self {
directory_record_max_age_seconds: default_directory_record_max_age_seconds(),
}
}
}
impl RebeccaPurgeConfig {
pub fn runtime(&self) -> PurgeRuntimeConfig {
PurgeRuntimeConfig {
roots: self.roots.clone(),
max_depth: self.max_depth,
min_age_days: self.min_age_days,
}
}
}
impl Default for RebeccaPurgeConfig {
fn default() -> Self {
Self {
roots: Vec::new(),
max_depth: default_project_artifact_max_depth(),
min_age_days: default_project_artifact_min_age_days(),
}
}
}
fn default_directory_record_max_age_seconds() -> u64 {
DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS
}
fn default_project_artifact_max_depth() -> usize {
DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH
}
fn default_project_artifact_min_age_days() -> u64 {
DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS
}
pub fn default_app_paths() -> Result<AppPaths> {
default_runtime_config().map(|config| config.app_paths)
}
pub fn load_app_paths() -> Result<AppPaths> {
default_app_paths()
}
pub fn load_app_paths_from(config_file: &Path) -> Result<AppPaths> {
load_runtime_config_from(config_file).map(|config| config.app_paths)
}
pub fn default_runtime_config() -> Result<AppRuntimeConfig> {
let config_dir = default_config_dir()?;
let config_file = config_dir.join("config.toml");
let config = load_config(&config_file)?;
resolve_runtime_config_with_config_dir(config_dir, config_file, &config)
}
pub fn load_runtime_config() -> Result<AppRuntimeConfig> {
default_runtime_config()
}
pub fn load_runtime_config_from(config_file: &Path) -> Result<AppRuntimeConfig> {
let config_dir = config_file
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let config = load_config(config_file)?;
resolve_runtime_config_with_config_dir(config_dir, config_file.to_path_buf(), &config)
}
pub fn load_config(config_file: &Path) -> Result<RebeccaConfig> {
if !config_file.exists() {
return Ok(RebeccaConfig::default());
}
let raw = std::fs::read_to_string(config_file).map_err(|err| RebeccaError::ConfigRead {
path: config_file.to_path_buf(),
message: err.to_string(),
})?;
if raw.trim().is_empty() {
return Ok(RebeccaConfig::default());
}
let config = toml::from_str(&raw).map_err(|err| RebeccaError::ConfigParse {
path: config_file.to_path_buf(),
message: err.to_string(),
})?;
validate_config(config_file, &config)?;
Ok(config)
}
pub fn resolve_app_paths(config: &RebeccaConfig) -> Result<AppPaths> {
resolve_runtime_config(config).map(|config| config.app_paths)
}
pub fn resolve_runtime_config(config: &RebeccaConfig) -> Result<AppRuntimeConfig> {
let config_dir = default_config_dir()?;
let config_file = config_dir.join("config.toml");
resolve_runtime_config_with_config_dir(config_dir, config_file, config)
}
fn resolve_runtime_config_with_config_dir(
config_dir: PathBuf,
config_file: PathBuf,
config: &RebeccaConfig,
) -> Result<AppRuntimeConfig> {
Ok(AppRuntimeConfig {
app_paths: resolve_app_paths_with_config_dir(config_dir, config_file, config)?,
scan_cache_policy: config.scan_cache.policy(),
protected_paths: config.protection.protected_paths.clone(),
purge: config.purge.runtime(),
})
}
fn resolve_app_paths_with_config_dir(
config_dir: PathBuf,
config_file: PathBuf,
config: &RebeccaConfig,
) -> Result<AppPaths> {
let state_dir = resolve_or_default(
env_path("REBECCA_STATE_DIR").or_else(|| config.app_paths.state_dir.clone()),
default_state_dir,
)?;
let cache_dir = resolve_or_default(
env_path("REBECCA_CACHE_DIR").or_else(|| config.app_paths.cache_dir.clone()),
default_cache_dir,
)?;
let history_file = env_path("REBECCA_HISTORY_FILE")
.or_else(|| config.app_paths.history_file.clone())
.unwrap_or_else(|| state_dir.join("history.jsonl"));
Ok(AppPaths {
config_file,
history_file,
config_dir,
state_dir,
cache_dir,
})
}
fn resolve_or_default(value: Option<PathBuf>, default: fn() -> Result<PathBuf>) -> Result<PathBuf> {
match value {
Some(path) => Ok(path),
None => default(),
}
}
fn validate_config(config_file: &Path, config: &RebeccaConfig) -> Result<()> {
if config.version != CONFIG_SCHEMA_VERSION {
return Err(RebeccaError::ConfigParse {
path: config_file.to_path_buf(),
message: format!(
"unsupported config version {}; supported version is {}",
config.version, CONFIG_SCHEMA_VERSION
),
});
}
if config.scan_cache.directory_record_max_age_seconds == 0 {
return Err(RebeccaError::ConfigParse {
path: config_file.to_path_buf(),
message: "scan_cache.directory_record_max_age_seconds must be at least 1".to_string(),
});
}
for protected_path in &config.protection.protected_paths {
validate_protected_path_config(config_file, protected_path)?;
}
for root in &config.purge.roots {
validate_purge_root_config(config_file, root)?;
}
Ok(())
}
pub fn validate_user_protected_path(path: &Path) -> std::result::Result<(), String> {
if path.as_os_str().is_empty() {
return Err("path cannot be empty".to_string());
}
if !is_absolute_user_path(path) {
return Err(format!("path must be absolute: {}", path.display()));
}
let contains_control_segment = path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::CurDir
)
});
if contains_control_segment {
return Err(format!(
"path cannot contain relative control segments: {}",
path.display()
));
}
Ok(())
}
fn validate_protected_path_config(config_file: &Path, path: &Path) -> Result<()> {
if let Err(message) = validate_user_protected_path(path) {
return Err(RebeccaError::ConfigParse {
path: config_file.to_path_buf(),
message: format!("invalid protection.protected_paths entry: {message}"),
});
}
Ok(())
}
fn validate_purge_root_config(config_file: &Path, path: &Path) -> Result<()> {
if let Err(message) = validate_user_protected_path(path) {
return Err(RebeccaError::ConfigParse {
path: config_file.to_path_buf(),
message: format!("invalid purge.roots entry: {message}"),
});
}
Ok(())
}
fn is_absolute_user_path(path: &Path) -> bool {
if path.is_absolute() {
return true;
}
let normalized = path.as_os_str().to_string_lossy().replace('/', "\\");
if normalized.starts_with("\\\\") {
return true;
}
let bytes = normalized.as_bytes();
bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\'
}
fn default_config_dir() -> Result<PathBuf> {
if let Some(path) = env_path("REBECCA_CONFIG_DIR") {
return Ok(path);
}
let base_dirs = BaseDirs::new().ok_or(RebeccaError::UserDirsUnavailable)?;
Ok(base_dirs.config_dir().join("Rebecca"))
}
fn default_state_dir() -> Result<PathBuf> {
if let Some(path) = env_path("REBECCA_STATE_DIR") {
return Ok(path);
}
let base_dirs = BaseDirs::new().ok_or(RebeccaError::UserDirsUnavailable)?;
Ok(base_dirs.data_local_dir().join("Rebecca").join("state"))
}
fn default_cache_dir() -> Result<PathBuf> {
if let Some(path) = env_path("REBECCA_CACHE_DIR") {
return Ok(path);
}
let base_dirs = BaseDirs::new().ok_or(RebeccaError::UserDirsUnavailable)?;
Ok(base_dirs.cache_dir().join("Rebecca").join("cache"))
}
fn env_path(key: &str) -> Option<PathBuf> {
let value = std::env::var_os(key)?;
if value.is_empty() {
return None;
}
Some(PathBuf::from(value))
}
#[cfg(test)]
mod tests {
use super::{
AppStorageId, AppStorageLifecycle, AppStorageRetention, CONFIG_SCHEMA_VERSION,
RebeccaAppPathsConfig, RebeccaConfig, RebeccaPurgeConfig, RebeccaScanCacheConfig,
default_app_paths, load_app_paths_from, load_config, load_runtime_config_from,
resolve_app_paths, resolve_runtime_config,
};
use crate::model::{DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH, DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS};
use crate::scan_cache::DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS;
#[test]
fn load_config_missing_file_returns_default() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
let config = load_config(&config_file).unwrap();
assert_eq!(config, RebeccaConfig::default());
}
#[test]
fn load_config_empty_file_returns_default() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(&config_file, " \n").unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config, RebeccaConfig::default());
}
#[test]
fn load_config_comment_only_file_returns_default() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(&config_file, "# local Rebecca config\n").unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config, RebeccaConfig::default());
}
#[test]
fn load_config_defaults_to_current_schema_version() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[app_paths]
state_dir = "C:\\Rebecca\\State"
"#,
)
.unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config.version, CONFIG_SCHEMA_VERSION);
assert_eq!(
config.scan_cache.directory_record_max_age_seconds,
DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS
);
assert!(config.purge.roots.is_empty());
assert_eq!(config.purge.max_depth, DEFAULT_PROJECT_ARTIFACT_MAX_DEPTH);
assert_eq!(
config.purge.min_age_days,
DEFAULT_PROJECT_ARTIFACT_MIN_AGE_DAYS
);
}
#[test]
fn load_config_accepts_current_schema_version() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
version = 1
[app_paths]
state_dir = "C:\\Rebecca\\State"
[scan_cache]
directory_record_max_age_seconds = 42
[purge]
roots = ['C:\Projects', 'D:\Work']
max_depth = 4
min_age_days = 0
"#,
)
.unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config.version, CONFIG_SCHEMA_VERSION);
assert_eq!(
config.app_paths.state_dir,
Some(std::path::PathBuf::from(r"C:\Rebecca\State"))
);
assert_eq!(config.scan_cache.directory_record_max_age_seconds, 42);
assert_eq!(
config.purge.roots,
vec![
std::path::PathBuf::from(r"C:\Projects"),
std::path::PathBuf::from(r"D:\Work")
]
);
assert_eq!(config.purge.max_depth, 4);
assert_eq!(config.purge.min_age_days, 0);
}
#[test]
fn load_config_rejects_unsupported_schema_version() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(&config_file, "version = 2\n").unwrap();
let err = load_config(&config_file).unwrap_err();
let message = err.to_string();
assert!(message.contains("unsupported config version 2"));
assert!(message.contains("supported version is 1"));
assert!(message.contains("config.toml"));
}
#[test]
fn load_config_rejects_unknown_fields() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[app_paths]
unknown = "value"
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("config.toml"));
}
#[test]
fn load_config_rejects_invalid_scan_cache_policy() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[scan_cache]
directory_record_max_age_seconds = 0
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(
err.to_string()
.contains("scan_cache.directory_record_max_age_seconds must be at least 1")
);
assert!(err.to_string().contains("config.toml"));
}
#[test]
fn load_config_rejects_unknown_scan_cache_fields() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[scan_cache]
unknown = 1
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("config.toml"));
}
#[test]
fn load_config_accepts_purge_settings() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
let root = temp.path().join("workspace");
std::fs::write(
&config_file,
format!(
r#"
[purge]
roots = ['{}']
max_depth = 3
min_age_days = 14
"#,
root.display()
),
)
.unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config.purge.roots, vec![root]);
assert_eq!(config.purge.max_depth, 3);
assert_eq!(config.purge.min_age_days, 14);
}
#[test]
fn load_config_rejects_unknown_purge_fields() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[purge]
unknown = 1
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(err.to_string().contains("unknown field"));
assert!(err.to_string().contains("config.toml"));
}
#[test]
fn load_config_rejects_relative_purge_roots() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[purge]
roots = ['workspace']
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(err.to_string().contains("invalid purge.roots entry"));
assert!(err.to_string().contains("path must be absolute"));
}
#[test]
fn load_config_reports_read_errors() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::create_dir_all(&config_file).unwrap();
let err = load_config(&config_file).unwrap_err();
let message = err.to_string();
assert!(message.contains("config read failed"));
assert!(message.contains("config.toml"));
}
#[test]
fn resolve_app_paths_honors_config_overrides() {
let config = RebeccaConfig {
version: CONFIG_SCHEMA_VERSION,
app_paths: RebeccaAppPathsConfig {
state_dir: Some(std::path::PathBuf::from(r"C:\Rebecca\State")),
cache_dir: Some(std::path::PathBuf::from(r"C:\Rebecca\Cache")),
history_file: Some(std::path::PathBuf::from(r"C:\Rebecca\State\audit.jsonl")),
},
scan_cache: RebeccaScanCacheConfig {
directory_record_max_age_seconds: 42,
},
protection: super::RebeccaProtectionConfig::default(),
purge: RebeccaPurgeConfig::default(),
};
let paths = resolve_app_paths(&config).unwrap();
assert!(paths.config_dir.ends_with("Rebecca"));
assert_eq!(
paths.state_dir,
std::path::PathBuf::from(r"C:\Rebecca\State")
);
assert_eq!(
paths.cache_dir,
std::path::PathBuf::from(r"C:\Rebecca\Cache")
);
assert_eq!(
paths.history_file,
std::path::PathBuf::from(r"C:\Rebecca\State\audit.jsonl")
);
}
#[test]
fn app_paths_storage_entries_pin_lifecycle_policy() {
let config = RebeccaConfig {
version: CONFIG_SCHEMA_VERSION,
app_paths: RebeccaAppPathsConfig {
state_dir: Some(std::path::PathBuf::from(r"C:\Rebecca\State")),
cache_dir: Some(std::path::PathBuf::from(r"C:\Rebecca\Cache")),
history_file: Some(std::path::PathBuf::from(r"C:\Rebecca\State\audit.jsonl")),
},
scan_cache: RebeccaScanCacheConfig::default(),
protection: super::RebeccaProtectionConfig::default(),
purge: RebeccaPurgeConfig::default(),
};
let paths = resolve_app_paths(&config).unwrap();
let entries = paths.storage_entries();
assert_eq!(
entries.iter().map(|entry| entry.id).collect::<Vec<_>>(),
vec![
AppStorageId::ConfigFile,
AppStorageId::ConfigDir,
AppStorageId::StateDir,
AppStorageId::CacheDir,
AppStorageId::HistoryFile,
]
);
assert_eq!(
entries
.iter()
.find(|entry| entry.id == AppStorageId::CacheDir)
.map(|entry| (entry.lifecycle, entry.retention)),
Some((
AppStorageLifecycle::RebuildableCache,
AppStorageRetention::Rebuildable,
))
);
assert_eq!(
entries
.iter()
.find(|entry| entry.id == AppStorageId::HistoryFile)
.map(|entry| (entry.lifecycle, entry.retention)),
Some((
AppStorageLifecycle::AppendOnlyHistory,
AppStorageRetention::Preserve,
))
);
}
#[test]
fn resolve_runtime_config_derives_scan_cache_policy() {
let purge_root = std::path::PathBuf::from(r"C:\Projects");
let config = RebeccaConfig {
version: CONFIG_SCHEMA_VERSION,
app_paths: RebeccaAppPathsConfig::default(),
scan_cache: RebeccaScanCacheConfig {
directory_record_max_age_seconds: 17,
},
protection: super::RebeccaProtectionConfig::default(),
purge: RebeccaPurgeConfig {
roots: vec![purge_root.clone()],
max_depth: 5,
min_age_days: 0,
},
};
let runtime_config = resolve_runtime_config(&config).unwrap();
assert_eq!(
runtime_config
.scan_cache_policy
.directory_record_max_age_seconds(),
17
);
assert_eq!(runtime_config.purge.roots, vec![purge_root]);
assert_eq!(runtime_config.purge.max_depth, 5);
assert_eq!(runtime_config.purge.min_age_days, 0);
}
#[test]
fn load_config_accepts_protected_paths() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
let protected_path = temp.path().join("AppData").join("Local").join("Keep");
std::fs::write(
&config_file,
format!(
r#"
[protection]
protected_paths = ['{}']
"#,
protected_path.display()
),
)
.unwrap();
let config = load_config(&config_file).unwrap();
assert_eq!(config.protection.protected_paths, vec![protected_path]);
}
#[test]
fn load_config_rejects_invalid_protected_path_entries() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[protection]
protected_paths = ['../Keep']
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(
err.to_string()
.contains("invalid protection.protected_paths entry")
);
assert!(err.to_string().contains("config.toml"));
}
#[test]
fn load_config_rejects_relative_protected_paths() {
let temp = tempfile::tempdir().unwrap();
let config_file = temp.path().join("config.toml");
std::fs::write(
&config_file,
r#"
[protection]
protected_paths = ['Keep/Cache']
"#,
)
.unwrap();
let err = load_config(&config_file).unwrap_err();
assert!(
err.to_string()
.contains("invalid protection.protected_paths entry")
);
assert!(err.to_string().contains("path must be absolute"));
}
#[test]
fn load_app_paths_from_uses_config_file_when_present() {
let temp = tempfile::tempdir().unwrap();
let config_dir = temp.path().join("Rebecca");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
r#"
[app_paths]
state_dir = "C:\\Rebecca\\State"
cache_dir = "C:\\Rebecca\\Cache"
history_file = "C:\\Rebecca\\State\\audit.jsonl"
"#,
)
.unwrap();
let paths = load_app_paths_from(&config_dir.join("config.toml")).unwrap();
assert_eq!(paths.config_file, config_dir.join("config.toml"));
assert_eq!(paths.config_dir, config_dir);
assert_eq!(
paths.state_dir,
std::path::PathBuf::from(r"C:\Rebecca\State")
);
assert_eq!(
paths.cache_dir,
std::path::PathBuf::from(r"C:\Rebecca\Cache")
);
assert_eq!(
paths.history_file,
std::path::PathBuf::from(r"C:\Rebecca\State\audit.jsonl")
);
}
#[test]
fn load_runtime_config_from_uses_config_file_scan_cache_policy() {
let temp = tempfile::tempdir().unwrap();
let config_dir = temp.path().join("Rebecca");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("config.toml"),
r#"
[scan_cache]
directory_record_max_age_seconds = 9
"#,
)
.unwrap();
let config = load_runtime_config_from(&config_dir.join("config.toml")).unwrap();
assert_eq!(
config.scan_cache_policy.directory_record_max_age_seconds(),
9
);
}
#[test]
fn default_app_paths_remain_available() {
assert!(default_app_paths().is_ok());
}
}