osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use crate::temp_support::make_temp_dir;
use anyhow::Result;
use clap::Command;
use osp_cli::app::{AppSession, AppStateBuilder, LaunchContext, RuntimeContext, TerminalKind};
use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
use osp_cli::core::command_policy::{CommandPath, VisibilityMode};
use osp_cli::{NativeCommand, NativeCommandContext, NativeCommandOutcome, NativeCommandRegistry};

use super::support::{with_path_prefix, write_executable_script};

fn resolved_config(entries: &[(&str, &str)]) -> osp_cli::config::ResolvedConfig {
    let mut defaults = ConfigLayer::default();
    defaults.set("profile.default", "default");
    for (key, value) in entries {
        defaults.set(*key, (*value).to_string());
    }

    let mut resolver = ConfigResolver::default();
    resolver.set_defaults(defaults);
    resolver
        .resolve(ResolveOptions::default().with_terminal("cli"))
        .expect("config should resolve")
}

#[cfg(unix)]
fn write_named_plugin(dir: &std::path::Path, name: &str) {
    let plugin_path = dir.join(format!("osp-{name}"));
    let script = format!(
        r#"#!/bin/sh
PATH=/usr/bin:/bin:$PATH
if [ "$1" = "--describe" ]; then
  cat <<'JSON'
{{"protocol_version":1,"plugin_id":"{name}","plugin_version":"0.1.0","min_osp_version":"0.1.0","commands":[{{"name":"{name}","about":"{name} plugin","args":[],"flags":{{}},"subcommands":[]}}]}}
JSON
  exit 0
fi

cat <<'JSON'
{{"protocol_version":1,"ok":true,"data":{{"message":"ok"}},"error":null,"meta":{{"format_hint":"table","columns":["message"]}}}}
JSON
"#,
        name = name,
    );
    write_executable_script(&plugin_path, &script);
}

struct NativeLaunchProbe;

impl NativeCommand for NativeLaunchProbe {
    fn command(&self) -> Command {
        Command::new("launch-native").about("Launch-aware native command")
    }

    fn auth(&self) -> Option<osp_cli::core::plugin::DescribeCommandAuthV1> {
        Some(osp_cli::core::plugin::DescribeCommandAuthV1 {
            visibility: Some(osp_cli::core::plugin::DescribeVisibilityModeV1::Authenticated),
            required_capabilities: Vec::new(),
            feature_flags: vec!["launch".to_string()],
        })
    }

    fn execute(
        &self,
        _args: &[String],
        _context: &NativeCommandContext<'_>,
    ) -> Result<NativeCommandOutcome> {
        Ok(NativeCommandOutcome::Exit(0))
    }
}

fn launch_native_registry() -> NativeCommandRegistry {
    NativeCommandRegistry::new().with_command(NativeLaunchProbe)
}

#[cfg(unix)]
#[test]
fn app_state_builder_uses_launch_context_for_plugin_roots_and_path_discovery() {
    let explicit_dir = make_temp_dir("osp-cli-launch-explicit");
    let path_dir = make_temp_dir("osp-cli-launch-path");
    let config_root = make_temp_dir("osp-cli-launch-config-root");
    let cache_root = make_temp_dir("osp-cli-launch-cache-root");
    write_named_plugin(explicit_dir.path(), "explicit-probe");
    write_named_plugin(path_dir.path(), "path-probe");

    let config = resolved_config(&[("extensions.plugins.discovery.path", "true")]);
    let launch = LaunchContext::default()
        .with_plugin_dir(explicit_dir.path().to_path_buf())
        .with_config_root(Some(config_root.path().to_path_buf()))
        .with_cache_root(Some(cache_root.path().to_path_buf()));

    with_path_prefix(path_dir.path(), || {
        let state = AppStateBuilder::from_resolved_config(
            RuntimeContext::new(None, TerminalKind::Cli, None),
            config.clone(),
        )
        .expect("app state builder should derive host inputs")
        .with_launch(launch.clone())
        .build();

        assert_eq!(
            state.runtime.launch.plugin_dirs,
            vec![explicit_dir.path().to_path_buf()]
        );
        assert_eq!(
            state.clients.plugins().config_root(),
            Some(config_root.path())
        );
        assert_eq!(
            state.clients.plugins().cache_root(),
            Some(cache_root.path())
        );

        let plugins = state.clients.plugins().list_plugins();
        assert!(
            plugins
                .iter()
                .any(|plugin| plugin.plugin_id == "explicit-probe")
        );
        assert!(
            plugins
                .iter()
                .any(|plugin| plugin.plugin_id == "path-probe")
        );
    });
}

#[test]
fn app_state_builder_projects_native_registry_into_external_policy() {
    let config = resolved_config(&[]);
    let state = AppStateBuilder::from_resolved_config(
        RuntimeContext::new(None, TerminalKind::Cli, None),
        config,
    )
    .expect("app state builder should derive host inputs")
    .with_native_commands(launch_native_registry())
    .build();

    assert!(
        state
            .clients
            .native_commands()
            .command("launch-native")
            .is_some()
    );
    assert!(
        state
            .runtime
            .auth
            .external_policy()
            .contains(&CommandPath::new(["launch-native"]))
    );
    let policy = state
        .runtime
        .auth
        .external_policy()
        .resolved_policy(&CommandPath::new(["launch-native"]))
        .expect("native policy should resolve");
    assert_eq!(policy.visibility, VisibilityMode::Authenticated);
    assert!(policy.feature_flags.contains("launch"));
}

#[test]
fn app_state_builder_tracks_session_public_helpers_from_resolved_config() {
    let config = resolved_config(&[]);
    let mut state = AppStateBuilder::from_resolved_config(
        RuntimeContext::new(None, TerminalKind::Repl, None),
        config,
    )
    .expect("app state builder should derive host inputs")
    .with_session(
        AppSession::builder()
            .with_prompt_prefix("demo")
            .with_history_enabled(false)
            .build(),
    )
    .build();

    assert_eq!(state.prompt_prefix(), "demo");

    let mut row = serde_json::Map::new();
    row.insert("uid".to_string(), serde_json::json!("alice"));
    state.record_repl_rows("ldap user alice", vec![row.clone()]);
    assert_eq!(state.repl_cache_size(), 1);
    let cached = state
        .cached_repl_rows("ldap user alice")
        .expect("cached rows should be available");
    assert_eq!(cached[0]["uid"], "alice");

    state.record_repl_failure("ldap user missing", "boom", "boom detail");
    let failure = state
        .last_repl_failure()
        .expect("last failure should be recorded");
    assert_eq!(failure.command_line, "ldap user missing");
    assert_eq!(failure.summary, "boom");
}

#[test]
fn app_state_builder_surfaces_invalid_theme_selection_errors() {
    let config = resolved_config(&[("theme.name", "definitely-not-a-theme")]);
    let err = AppStateBuilder::from_resolved_config(
        RuntimeContext::new(None, TerminalKind::Cli, None),
        config,
    )
    .err()
    .expect("invalid theme should fail during host-input derivation");

    let text = err.to_string();
    assert!(text.contains("definitely-not-a-theme"));
    assert!(text.contains("theme"));
}