osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use super::{
    ChainedLoader, ConfigLoader, EnvSecretsLoader, EnvVarLoader, LoaderPipeline, SecretsTomlLoader,
    StaticLayerLoader, TomlFileLoader,
};
use crate::config::{
    ConfigError, ConfigLayer, ConfigSchema, ConfigValue, ResolveOptions, SchemaEntry, Scope,
};

fn make_temp_dir(prefix: &str) -> crate::tests::TestTempDir {
    crate::tests::make_temp_dir(prefix)
}

#[test]
fn toml_file_loader_covers_existing_missing_optional_and_directory_paths_unit() {
    let root = make_temp_dir("osp-config-loader");
    let config_path = root.join("config.toml");
    std::fs::write(&config_path, "[default.ui]\ntheme = \"plain\"\n")
        .expect("config should be written");

    let layer = TomlFileLoader::new(config_path.clone())
        .optional()
        .load()
        .expect("optional existing config should load");
    let config_origin = config_path.to_string_lossy().to_string();
    assert_eq!(layer.entries().len(), 1);
    assert_eq!(layer.entries()[0].key, "ui.theme");
    assert_eq!(
        layer.entries()[0].origin.as_deref(),
        Some(config_origin.as_str())
    );

    let missing_path = root.join("missing.toml");
    let missing = TomlFileLoader::new(missing_path.clone())
        .required()
        .load()
        .expect_err("required missing config should fail");
    let missing_display = missing_path.to_string_lossy().to_string();
    assert!(matches!(
        missing,
        ConfigError::FileRead { path, reason }
        if path == missing_display && reason == "file not found"
    ));

    let optional_missing = TomlFileLoader::new(root.join("optional.toml"))
        .optional()
        .load()
        .expect("optional missing config should be empty");
    assert!(optional_missing.entries().is_empty());

    let err = TomlFileLoader::new(root.to_path_buf())
        .required()
        .load()
        .expect_err("reading a directory as TOML should fail");
    let root_display = root.to_string_lossy().to_string();
    assert!(matches!(
        err,
        ConfigError::FileRead { path, .. } if path == root_display
    ));
}

#[test]
fn secrets_and_env_loaders_mark_secret_entries_and_directory_errors_unit() {
    let root = make_temp_dir("osp-config-secrets");
    let secrets_path = root.join("secrets.toml");
    std::fs::write(&secrets_path, "[default.auth]\ntoken = \"shh\"\n")
        .expect("secrets file should be written");

    let secrets = SecretsTomlLoader::new(secrets_path.clone())
        .with_strict_permissions(false)
        .required()
        .load()
        .expect("secrets file should load");
    let secrets_origin = secrets_path.to_string_lossy().to_string();
    assert_eq!(secrets.entries().len(), 1);
    assert!(secrets.entries()[0].value.is_secret());
    assert_eq!(
        secrets.entries()[0].origin.as_deref(),
        Some(secrets_origin.as_str())
    );

    let env =
        EnvSecretsLoader::from_iter([("IGNORED", "x"), ("OSP_SECRET__AUTH__TOKEN", "env-shh")])
            .load()
            .expect("env secrets should load");
    assert_eq!(env.entries().len(), 1);
    assert_eq!(env.entries()[0].key, "auth.token");
    assert!(env.entries()[0].value.is_secret());
    assert_eq!(
        env.entries()[0].origin.as_deref(),
        Some("OSP_SECRET__AUTH__TOKEN")
    );

    let err = SecretsTomlLoader::new(root.to_path_buf())
        .with_strict_permissions(false)
        .required()
        .load()
        .expect_err("reading a directory as secrets TOML should fail");
    let root_display = root.to_string_lossy().to_string();
    assert!(matches!(
        err,
        ConfigError::FileRead { path, .. } if path == root_display
    ));
}

#[test]
fn chained_loader_and_pipeline_resolve_layers_with_optionals_unit() {
    let chained = ChainedLoader::new(StaticLayerLoader::new({
        let mut layer = ConfigLayer::default();
        layer.insert("theme.name", "plain", Scope::global());
        layer
    }))
    .with(EnvVarLoader::from_pairs([("OSP__THEME__NAME", "dracula")]));
    let merged = chained.load().expect("chained loader should merge");
    assert_eq!(merged.entries().len(), 2);

    let resolved = LoaderPipeline::new(StaticLayerLoader::new({
        let mut layer = ConfigLayer::default();
        layer.insert("theme.name", "plain", Scope::global());
        layer
    }))
    .with_env(EnvVarLoader::from_pairs([("OSP__THEME__NAME", "dracula")]))
    .resolve(ResolveOptions::default())
    .expect("pipeline should resolve");
    assert_eq!(resolved.get_string("theme.name"), Some("dracula"));

    let layers = LoaderPipeline::new(StaticLayerLoader::new(ConfigLayer::default()))
        .load_layers()
        .expect("optional loaders should default to empty");
    assert!(layers.file.entries().is_empty());
    assert!(layers.secrets.entries().is_empty());
    assert!(layers.env.entries().is_empty());
    assert!(layers.cli.entries().is_empty());
    assert!(layers.session.entries().is_empty());
}

#[test]
fn pipeline_builder_covers_schema_collected_env_and_optional_layer_paths_unit() {
    let env: EnvVarLoader = [("OSP__THEME__NAME", "nord")].into_iter().collect();
    let secrets: EnvSecretsLoader = [("OSP_SECRET__AUTH__TOKEN", "tok")].into_iter().collect();

    let layers = LoaderPipeline::new(StaticLayerLoader::new(ConfigLayer::default()))
        .with_env(env)
        .with_secrets(secrets)
        .with_schema(ConfigSchema::default())
        .load_layers()
        .expect("pipeline should load collected loaders");

    assert_eq!(layers.env.entries().len(), 1);
    assert_eq!(layers.secrets.entries().len(), 1);

    let _env_loader = EnvVarLoader::from_process_env();
    let _secret_loader = EnvSecretsLoader::from_process_env();

    let layers = LoaderPipeline::new(StaticLayerLoader::new(ConfigLayer::default()))
        .with_file(StaticLayerLoader::new({
            let mut layer = ConfigLayer::default();
            layer.insert("theme.name", "plain", Scope::global());
            layer
        }))
        .with_presentation(StaticLayerLoader::new({
            let mut layer = ConfigLayer::default();
            layer.insert("repl.intro", "compact", Scope::global());
            layer
        }))
        .with_cli(StaticLayerLoader::new({
            let mut layer = ConfigLayer::default();
            layer.insert("theme.name", "nord", Scope::global());
            layer
        }))
        .with_session(StaticLayerLoader::new({
            let mut layer = ConfigLayer::default();
            layer.insert("session.cache.max_results", 32_i64, Scope::global());
            layer
        }))
        .load_layers()
        .expect("pipeline should load all optional layers");

    assert_eq!(layers.file.entries().len(), 1);
    assert_eq!(layers.presentation.entries().len(), 1);
    assert_eq!(layers.cli.entries().len(), 1);
    assert_eq!(layers.session.entries().len(), 1);
}

#[test]
fn pipeline_resolver_preserves_custom_schema_for_explain_unit() {
    let mut defaults = ConfigLayer::default();
    defaults.set("profile.default", "default");
    defaults.set("custom.answer", "42");

    let mut schema = ConfigSchema::default();
    schema.insert(
        "custom.answer",
        SchemaEntry::integer().with_doc("Custom integer used in tests"),
    );

    let resolver = LoaderPipeline::new(StaticLayerLoader::new(defaults))
        .with_schema(schema)
        .resolver()
        .expect("pipeline resolver should preserve schema");

    let explain = resolver
        .explain_key("custom.answer", ResolveOptions::default())
        .expect("custom schema key should explain through the pipeline resolver");

    assert_eq!(
        explain
            .final_entry
            .expect("custom.answer should have a winning value")
            .value
            .reveal(),
        &ConfigValue::Integer(42)
    );
}