cfgmatic-source 5.0.1

Configuration sources (file, env, memory) for cfgmatic framework
Documentation
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"));
}