osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use crate::temp_support::make_temp_dir;
use osp_cli::config::{
    ConfigLayer, ConfigResolver, ConfigSource, ConfigValue, EnvVarLoader, LoaderPipeline,
    ResolveOptions, Scope, StaticLayerLoader, TomlFileLoader, TomlStoreEditOptions,
    set_scoped_value_in_toml, unset_scoped_value_in_toml,
};

fn defaults_layer() -> ConfigLayer {
    let mut defaults = ConfigLayer::default();
    defaults.set("profile.default", "default");
    defaults.set("theme.name", "plain");
    defaults.set("ui.presentation", "austere");
    defaults
}

fn file_pipeline(path: &std::path::Path) -> LoaderPipeline {
    LoaderPipeline::new(StaticLayerLoader::new(defaults_layer()))
        .with_file(TomlFileLoader::new(path.to_path_buf()).required())
}

#[test]
fn config_file_mutation_round_trips_through_reload_and_explain() {
    let temp = make_temp_dir("osp-cli-config-integration");
    let path = temp.path().join("config.toml");

    set_scoped_value_in_toml(
        &path,
        "theme.name",
        &ConfigValue::String("dracula".to_string()),
        &Scope::global(),
        TomlStoreEditOptions::new(),
    )
    .expect("theme should be written");
    set_scoped_value_in_toml(
        &path,
        "ui.presentation",
        &ConfigValue::String("compact".to_string()),
        &Scope::profile("tsd"),
        TomlStoreEditOptions::new(),
    )
    .expect("profile-scoped presentation should be written");

    let options = ResolveOptions::default()
        .with_profile("tsd")
        .with_terminal("cli");
    let resolved = file_pipeline(&path)
        .resolve(options.clone())
        .expect("file-backed config should resolve");
    assert_eq!(resolved.active_profile(), "tsd");
    assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
    assert_eq!(resolved.get_string("ui.presentation"), Some("compact"));

    set_scoped_value_in_toml(
        &path,
        "theme.name",
        &ConfigValue::String("gruvbox".to_string()),
        &Scope::global(),
        TomlStoreEditOptions::new(),
    )
    .expect("theme update should be written");

    let reloaded = file_pipeline(&path)
        .resolve(options.clone())
        .expect("mutated config should reload");
    assert_eq!(reloaded.get_string("theme.name"), Some("gruvbox"));

    let env_pipeline =
        file_pipeline(&path).with_env(EnvVarLoader::from_pairs([("OSP__theme__name", "nord")]));
    let env_resolved = env_pipeline
        .resolve(options.clone())
        .expect("env-backed config should resolve");
    assert_eq!(env_resolved.get_string("theme.name"), Some("nord"));

    let explain = ConfigResolver::from_loaded_layers(
        env_pipeline
            .load_layers()
            .expect("layers should load for explain"),
    )
    .explain_key("theme.name", options)
    .expect("theme explain should succeed");

    let final_entry = explain
        .final_entry
        .as_ref()
        .expect("theme explain should have a winner");
    assert_eq!(final_entry.source, ConfigSource::Environment);
    assert_eq!(final_entry.origin.as_deref(), Some("OSP__theme__name"));

    let file_layer = explain
        .layers
        .iter()
        .find(|layer| layer.source == ConfigSource::ConfigFile)
        .expect("file layer should be present in explain");
    assert!(
        file_layer
            .candidates
            .iter()
            .any(|candidate| candidate.selected_in_layer
                && candidate.value == ConfigValue::String("gruvbox".to_string())),
        "expected explain to retain the file-backed winner before env override: {file_layer:?}"
    );
}

#[test]
fn optional_file_and_env_layers_resolve_without_a_persisted_config_file() {
    let temp = make_temp_dir("osp-cli-config-integration-optional");
    let missing = temp.path().join("missing.toml");

    let resolved = LoaderPipeline::new(StaticLayerLoader::new(defaults_layer()))
        .with_file(TomlFileLoader::new(missing).optional())
        .with_env(EnvVarLoader::from_pairs([
            ("OSP__ui__presentation", "compact"),
            ("OSP__theme__name", "nord"),
        ]))
        .resolve(ResolveOptions::default().with_terminal("cli"))
        .expect("optional missing file plus env layers should resolve");

    assert_eq!(resolved.active_profile(), "default");
    assert_eq!(resolved.get_string("ui.presentation"), Some("compact"));
    assert_eq!(resolved.get_string("theme.name"), Some("nord"));
}

#[test]
fn config_store_round_trips_terminal_profile_scope_and_list_values_through_reload() {
    let temp = make_temp_dir("osp-cli-config-integration-store");
    let path = temp.path().join("config.toml");

    let formats = ConfigValue::List(vec![
        ConfigValue::String("json".to_string()),
        ConfigValue::String("table".to_string()),
    ]);
    set_scoped_value_in_toml(
        &path,
        "theme.path",
        &formats,
        &Scope::global(),
        TomlStoreEditOptions::new(),
    )
    .expect("global list value should be written");
    set_scoped_value_in_toml(
        &path,
        "ui.format",
        &ConfigValue::String("mreg".to_string()),
        &Scope {
            profile: Some("tsd".to_string()),
            terminal: Some("repl".to_string()),
        },
        TomlStoreEditOptions::new(),
    )
    .expect("terminal-profile override should be written");

    let resolved = file_pipeline(&path)
        .resolve(
            ResolveOptions::default()
                .with_profile("tsd")
                .with_terminal("repl"),
        )
        .expect("scoped config should resolve");
    assert_eq!(resolved.get_string("ui.format"), Some("mreg"));
    assert_eq!(
        resolved.get_string_list("theme.path"),
        Some(vec!["json".to_string(), "table".to_string()])
    );

    let unset = unset_scoped_value_in_toml(
        &path,
        "ui.format",
        &Scope {
            profile: Some("tsd".to_string()),
            terminal: Some("repl".to_string()),
        },
        TomlStoreEditOptions::new(),
    )
    .expect("terminal-profile override should unset");
    assert_eq!(
        unset.previous,
        Some(ConfigValue::String("mreg".to_string()))
    );

    let updated = file_pipeline(&path)
        .resolve(
            ResolveOptions::default()
                .with_profile("tsd")
                .with_terminal("repl"),
        )
        .expect("updated config should resolve");
    assert_eq!(updated.get_string("ui.format"), None);
    assert_eq!(
        updated.get_string_list("theme.path"),
        Some(vec!["json".to_string(), "table".to_string()])
    );
}