use serde::Deserialize;
use std::fs;
use tempfile::tempdir;
use crate::config::MergeStrategy;
use crate::domain::Format;
use super::{
CascadeLayer, CascadeLayerBuilder, CascadeLoader, CascadeLoaderBuilder, CascadeScope,
ResolvedCascadeLayer,
};
#[derive(Debug, Deserialize)]
struct AppConfig {
server: ServerConfig,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
host: String,
port: u16,
}
#[test]
fn test_cascade_loader_merges_layers_in_priority_order() {
let temp_dir = tempdir().unwrap();
let system = temp_dir.path().join("system.toml");
let user = temp_dir.path().join("user.toml");
let worktree = temp_dir.path().join("worktree.toml");
fs::write(
&system,
r#"
[server]
host = "system.example"
port = 8080
"#,
)
.unwrap();
fs::write(
&user,
r"
[server]
port = 9000
",
)
.unwrap();
fs::write(
&worktree,
r#"
[server]
host = "worktree.example"
"#,
)
.unwrap();
let loader = CascadeLoader::builder()
.add_optional_file(CascadeScope::System, &system)
.add_layer(CascadeLayer::new(CascadeScope::User, &user).priority(50))
.add_layer(CascadeLayer::new(CascadeScope::Worktree, &worktree).priority(100))
.default_format(Format::Toml)
.build();
let config: AppConfig = loader.load_as().unwrap();
assert_eq!(config.server.host, "worktree.example");
assert_eq!(config.server.port, 9000);
}
#[test]
fn test_cascade_loader_skips_optional_missing_layers() {
let temp_dir = tempdir().unwrap();
let local = temp_dir.path().join("local.toml");
fs::write(
&local,
r#"
[server]
host = "local.example"
port = 8080
"#,
)
.unwrap();
let result = CascadeLoader::builder()
.add_optional_file(CascadeScope::System, temp_dir.path().join("missing.toml"))
.add_file(CascadeScope::Local, &local)
.default_format(Format::Toml)
.build()
.load()
.unwrap();
assert_eq!(result.loaded_layers.len(), 1);
assert_eq!(result.skipped_layers.len(), 1);
}
#[test]
fn test_cascade_loader_strict_conflict() {
let temp_dir = tempdir().unwrap();
let low = temp_dir.path().join("low.json");
let high = temp_dir.path().join("high.json");
fs::write(&low, r#"{"port": 8080}"#).unwrap();
fs::write(&high, r#"{"port": 3000}"#).unwrap();
let result = CascadeLoader::builder()
.merge_strategy(MergeStrategy::Strict)
.add_file(CascadeScope::System, &low)
.add_file(CascadeScope::Local, &high)
.build()
.load();
assert!(result.is_err());
}
#[test]
fn test_cascade_layer_builder_requires_scope_and_path() {
let missing_scope = CascadeLayerBuilder::new()
.path("/tmp/config.toml")
.build()
.unwrap_err();
let missing_path = CascadeLayerBuilder::new()
.scope(CascadeScope::Local)
.build()
.unwrap_err();
assert!(missing_scope.to_string().contains("scope"));
assert!(missing_path.to_string().contains("path"));
}
#[test]
fn test_cascade_layer_builder_sets_all_fields() {
let layer = CascadeLayer::builder()
.scope(CascadeScope::Worktree)
.path("/tmp/config")
.optional(true)
.priority(25)
.format(Format::Json)
.build()
.unwrap();
assert_eq!(layer.scope, CascadeScope::Worktree);
assert_eq!(layer.path, std::path::PathBuf::from("/tmp/config"));
assert!(layer.optional);
assert_eq!(layer.priority, 25);
assert_eq!(layer.format, Some(Format::Json));
}
#[test]
fn test_cascade_loader_builder_applies_options_and_default_format() {
let loader = CascadeLoaderBuilder::new()
.add_optional_file(CascadeScope::System, "/tmp/system")
.merge_strategy(MergeStrategy::Replace)
.fail_fast(false)
.default_format(Format::Yaml)
.build();
assert_eq!(loader.layers.len(), 1);
assert_eq!(loader.options.merge_strategy, MergeStrategy::Replace);
assert!(!loader.options.is_fail_fast());
assert_eq!(loader.default_format, Some(Format::Yaml));
}
#[test]
fn test_resolved_cascade_layer_from_layer() {
let layer = CascadeLayer::new(CascadeScope::Global, "/tmp/global.toml").priority(12);
let resolved = ResolvedCascadeLayer::from(&layer);
assert_eq!(resolved.scope, CascadeScope::Global);
assert_eq!(resolved.path, std::path::PathBuf::from("/tmp/global.toml"));
assert_eq!(resolved.priority, 12);
}
#[test]
fn test_cascade_loader_uses_default_format_for_extensionless_files() {
let temp_dir = tempdir().unwrap();
let extensionless = temp_dir.path().join("config");
fs::write(
&extensionless,
r#"
[server]
host = "extensionless.example"
port = 8081
"#,
)
.unwrap();
let loader = CascadeLoader::builder()
.add_file(CascadeScope::Local, &extensionless)
.default_format(Format::Toml)
.build();
let config: AppConfig = loader.load_as().unwrap();
assert_eq!(config.server.host, "extensionless.example");
assert_eq!(config.server.port, 8081);
}
#[test]
fn test_cascade_loader_collects_failures_when_fail_fast_is_disabled() {
let temp_dir = tempdir().unwrap();
let good = temp_dir.path().join("good.toml");
let bad = temp_dir.path().join("bad.toml");
fs::write(
&good,
r#"
[server]
host = "good.example"
port = 8082
"#,
)
.unwrap();
fs::write(&bad, "[server\nhost = \"broken\"").unwrap();
let result = CascadeLoader::builder()
.add_file(CascadeScope::System, &bad)
.add_file(CascadeScope::Local, &good)
.fail_fast(false)
.default_format(Format::Toml)
.build()
.load()
.unwrap();
assert_eq!(result.loaded_layers.len(), 1);
assert_eq!(result.failed_layers.len(), 1);
assert_eq!(result.failed_layers[0].0.scope, CascadeScope::System);
}
#[test]
fn test_cascade_scope_display_and_custom_scope() {
assert_eq!(CascadeScope::System.to_string(), "system");
assert_eq!(CascadeScope::Global.to_string(), "global");
assert_eq!(CascadeScope::User.to_string(), "user");
assert_eq!(CascadeScope::Local.to_string(), "local");
assert_eq!(CascadeScope::Worktree.to_string(), "worktree");
assert_eq!(
CascadeScope::Custom("tenant".to_string()).to_string(),
"tenant"
);
}
#[test]
fn test_cascade_layer_new_defaults() {
let layer = CascadeLayer::new(CascadeScope::Local, "/tmp/config.toml");
assert_eq!(layer.scope, CascadeScope::Local);
assert_eq!(layer.path, std::path::PathBuf::from("/tmp/config.toml"));
assert_eq!(layer.priority, 0);
assert!(!layer.optional);
assert_eq!(layer.format, None);
}
#[test]
fn test_cascade_load_result_content_accessor_and_to_type_error() {
let result = super::CascadeLoadResult {
content: crate::domain::ParsedContent::from_json(serde_json::json!({
"server": {
"host": "localhost"
}
})),
loaded_layers: Vec::new(),
skipped_layers: Vec::new(),
failed_layers: Vec::new(),
processing_time_ms: 1,
};
assert!(result.content().get("server").is_some());
let typed = result.to_type::<AppConfig>();
assert!(typed.is_err());
}
#[test]
fn test_cascade_loader_new_starts_empty() {
let loader = CascadeLoader::new();
assert!(loader.layers.is_empty());
assert_eq!(loader.default_format, None);
}
#[test]
fn test_cascade_loader_builder_add_layer_and_required_file() {
let loader = CascadeLoaderBuilder::new()
.add_layer(CascadeLayer::new(CascadeScope::Global, "/tmp/global.toml").priority(3))
.add_file(CascadeScope::Local, "/tmp/local.toml")
.build();
assert_eq!(loader.layers.len(), 2);
assert_eq!(loader.layers[0].scope, CascadeScope::Global);
assert_eq!(loader.layers[0].priority, 3);
assert_eq!(loader.layers[1].scope, CascadeScope::Local);
assert!(!loader.layers[1].optional);
}
#[test]
fn test_cascade_loader_builder_options_override_merge_and_fail_fast() {
let base = crate::config::LoadOptions::builder()
.merge_strategy(MergeStrategy::Deep)
.fail_fast(true)
.build();
let loader = CascadeLoaderBuilder::new()
.options(base)
.merge_strategy(MergeStrategy::Shallow)
.fail_fast(false)
.build();
assert_eq!(loader.options.merge_strategy, MergeStrategy::Shallow);
assert!(!loader.options.is_fail_fast());
}
#[cfg(feature = "file")]
#[test]
fn test_cascade_loader_discover_app_handles_missing_app_configs() {
let loader = CascadeLoaderBuilder::new()
.discover_app("cfgmatic-cascade-definitely-missing")
.build();
assert!(loader.layers.is_empty());
}
#[test]
fn test_cascade_loader_reports_aggregate_failure_when_all_layers_fail() {
let temp_dir = tempdir().unwrap();
let bad = temp_dir.path().join("bad.toml");
fs::write(&bad, "[server\nhost = 'broken'").unwrap();
let error = CascadeLoader::builder()
.add_file(CascadeScope::Local, &bad)
.fail_fast(false)
.default_format(Format::Toml)
.build()
.load()
.unwrap_err();
assert!(error.to_string().contains("local"));
assert!(error.to_string().contains("bad.toml"));
}