osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use crate::app::{AppRuntime, UiState};
use crate::app::{CliCommandResult, resolve_known_theme_name};
use crate::cli::rows::output::rows_to_output_result;
use crate::cli::{ThemeArgs, ThemeCommands, ThemeShowArgs, ThemeUseArgs};
use crate::config::ConfigLayer;
use crate::core::command_def::{ArgDef, CommandDef, ValueChoice};
use crate::core::row::Row;
use crate::ui::theme::{DEFAULT_THEME_NAME, normalize_theme_name};
use crate::ui::theme_catalog::{ThemeCatalog, ThemeSource};
use miette::Result;
use miette::miette;

#[derive(Clone, Copy)]
pub(crate) struct ThemeCommandContext<'a> {
    pub(crate) ui: &'a UiState,
    pub(crate) themes: &'a ThemeCatalog,
}

impl<'a> ThemeCommandContext<'a> {
    pub(crate) fn from_parts(runtime: &'a AppRuntime, ui: &'a UiState) -> Self {
        Self {
            ui,
            themes: &runtime.themes,
        }
    }
}

pub(crate) fn run_theme_command(
    config_overrides: &mut ConfigLayer,
    context: ThemeCommandContext<'_>,
    args: ThemeArgs,
) -> Result<CliCommandResult> {
    match args.command {
        ThemeCommands::List => Ok(CliCommandResult::output(
            rows_to_output_result(theme_list_rows(
                context.themes,
                &context.ui.render_settings.theme_name,
            )),
            None,
        )),
        ThemeCommands::Show(ThemeShowArgs { name }) => {
            let selected = name.unwrap_or_else(|| context.ui.render_settings.theme_name.clone());
            Ok(CliCommandResult::output(
                rows_to_output_result(theme_show_rows(context.themes, &selected)?),
                None,
            ))
        }
        ThemeCommands::Use(ThemeUseArgs { name }) => {
            let selected = resolve_known_theme_name(&name, context.themes)?;
            config_overrides.set("theme.name", selected.clone());

            let mut result = CliCommandResult::exit(0);
            result
                .messages
                .success(format!("active theme set to: {selected}"));
            result.messages.info(
                "theme change is for the current process; persistent writes land with `config set`",
            );
            Ok(result)
        }
    }
}

pub(crate) fn theme_command_def(themes: &ThemeCatalog, sort_key: impl Into<String>) -> CommandDef {
    let theme_choices = themes
        .ids()
        .into_iter()
        .map(ValueChoice::new)
        .collect::<Vec<_>>();

    CommandDef::new("theme")
        .about("Inspect and apply themes")
        .sort(sort_key)
        .subcommands([
            CommandDef::new("list")
                .about("List available themes")
                .sort("10"),
            CommandDef::new("show")
                .about("Show a theme definition")
                .sort("11")
                .arg(
                    ArgDef::new("name")
                        .value_name("name")
                        .help("Theme name")
                        .choices(theme_choices.clone()),
                ),
            CommandDef::new("use")
                .about("Set active theme")
                .sort("12")
                .arg(
                    ArgDef::new("name")
                        .value_name("name")
                        .help("Theme name")
                        .required()
                        .choices(theme_choices),
                ),
        ])
}

fn theme_list_rows(themes: &ThemeCatalog, active_theme: &str) -> Vec<Row> {
    let active = normalize_theme_name(active_theme);
    themes
        .entries
        .values()
        .map(|entry| {
            let origin = entry
                .origin
                .as_ref()
                .map(|path| serde_json::Value::from(path.to_string_lossy().to_string()))
                .unwrap_or(serde_json::Value::Null);
            crate::row! {
                "id" => entry.theme.id.to_string(),
                "name" => entry.theme.name.to_string(),
                "source" => match entry.source {
                    ThemeSource::Builtin => "builtin",
                    ThemeSource::Custom => "custom",
                },
                "origin" => origin,
                "active" => entry.theme.id == active.as_str(),
                "default" => entry.theme.id == DEFAULT_THEME_NAME,
            }
        })
        .collect()
}

fn theme_show_rows(themes: &ThemeCatalog, name: &str) -> Result<Vec<Row>> {
    let selected = resolve_known_theme_name(name, themes)?;
    let entry = themes
        .entries
        .get(&selected)
        .ok_or_else(|| miette!("theme missing: {selected}"))?;
    let theme = &entry.theme;
    let palette = &theme.palette;
    let origin = entry
        .origin
        .as_ref()
        .map(|path| serde_json::Value::from(path.to_string_lossy().to_string()))
        .unwrap_or(serde_json::Value::Null);
    let bg = palette
        .bg
        .clone()
        .map(serde_json::Value::from)
        .unwrap_or(serde_json::Value::Null);
    let bg_alt = palette
        .bg_alt
        .clone()
        .map(serde_json::Value::from)
        .unwrap_or(serde_json::Value::Null);

    Ok(vec![crate::row! {
        "id" => theme.id.to_string(),
        "name" => theme.name.to_string(),
        "base" => theme
            .base
            .as_deref()
            .map(serde_json::Value::from)
            .unwrap_or(serde_json::Value::Null),
        "source" => match entry.source {
            ThemeSource::Builtin => "builtin",
            ThemeSource::Custom => "custom",
        },
        "origin" => origin,
        "text" => palette.text.to_string(),
        "muted" => palette.muted.to_string(),
        "accent" => palette.accent.to_string(),
        "info" => palette.info.to_string(),
        "warning" => palette.warning.to_string(),
        "success" => palette.success.to_string(),
        "error" => palette.error.to_string(),
        "border" => palette.border.to_string(),
        "title" => palette.title.to_string(),
        "selection" => palette.selection.to_string(),
        "link" => palette.link.to_string(),
        "bg" => bg,
        "bg_alt" => bg_alt,
    }])
}

#[cfg(test)]
mod tests {
    use super::{
        ThemeCommandContext, run_theme_command, theme_command_def, theme_list_rows, theme_show_rows,
    };
    use crate::app::CliCommandResult;
    use crate::app::ReplCommandOutput;
    use crate::app::UiState;
    use crate::cli::{ThemeArgs, ThemeCommands, ThemeShowArgs};
    use crate::config::ConfigLayer;
    use crate::core::output::OutputFormat;
    use crate::core::row::Row;
    use crate::ui::RenderSettings;
    use crate::ui::messages::MessageLevel;
    use crate::ui::theme::find_builtin_theme;
    use crate::ui::theme_catalog::{ThemeCatalog, ThemeEntry, ThemeSource};
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    fn builtin_theme_catalog() -> ThemeCatalog {
        let builtin = find_builtin_theme("nord").expect("builtin theme").clone();
        let entry = ThemeEntry {
            theme: builtin,
            source: ThemeSource::Builtin,
            origin: None,
        };

        ThemeCatalog {
            entries: BTreeMap::from([("nord".to_string(), entry)]),
            issues: Vec::new(),
        }
    }

    fn custom_theme_catalog() -> ThemeCatalog {
        let builtin = find_builtin_theme("nord").expect("builtin theme").clone();
        let custom = ThemeEntry {
            theme: builtin,
            source: ThemeSource::Custom,
            origin: Some(PathBuf::from("/tmp/themes/nord.toml")),
        };

        ThemeCatalog {
            entries: BTreeMap::from([("nord".to_string(), custom)]),
            issues: Vec::new(),
        }
    }

    fn test_ui_state() -> UiState {
        UiState::new(
            RenderSettings::test_plain(OutputFormat::Table),
            MessageLevel::Info,
            0,
        )
    }

    fn extract_output_rows(result: CliCommandResult) -> Option<Vec<Row>> {
        let output = match result.output? {
            ReplCommandOutput::Output(output) => output.output,
            ReplCommandOutput::Json(_) | ReplCommandOutput::Text(_) => return None,
        };
        output.into_rows()
    }

    #[test]
    fn theme_rows_and_commands_resolve_builtin_and_custom_sources_unit() {
        let rows = theme_list_rows(&custom_theme_catalog(), "nord");
        let row = &rows[0];

        assert_eq!(
            row.get("source").and_then(|value| value.as_str()),
            Some("custom")
        );
        assert_eq!(
            row.get("origin").and_then(|value| value.as_str()),
            Some("/tmp/themes/nord.toml")
        );

        let ui = test_ui_state();
        let themes = builtin_theme_catalog();
        let mut overrides = ConfigLayer::default();

        let result = run_theme_command(
            &mut overrides,
            ThemeCommandContext {
                ui: &ui,
                themes: &themes,
            },
            ThemeArgs {
                command: ThemeCommands::List,
            },
        )
        .expect("theme list should succeed");

        let rows = extract_output_rows(result).expect("rows");
        let row = &rows[0];
        assert_eq!(
            row.get("source").and_then(|value| value.as_str()),
            Some("builtin")
        );

        let rows = theme_show_rows(&custom_theme_catalog(), "nord").expect("theme should resolve");
        let row = &rows[0];

        assert_eq!(
            row.get("source").and_then(|value| value.as_str()),
            Some("custom")
        );
        assert_eq!(
            row.get("origin").and_then(|value| value.as_str()),
            Some("/tmp/themes/nord.toml")
        );

        let mut ui = test_ui_state();
        ui.render_settings.theme_name = "nord".to_string();
        let themes = builtin_theme_catalog();
        let mut overrides = ConfigLayer::default();

        let result = run_theme_command(
            &mut overrides,
            ThemeCommandContext {
                ui: &ui,
                themes: &themes,
            },
            ThemeArgs {
                command: ThemeCommands::Show(ThemeShowArgs { name: None }),
            },
        )
        .expect("theme show should succeed");

        let rows = extract_output_rows(result).expect("rows");
        let row = &rows[0];
        assert_eq!(row.get("id").and_then(|value| value.as_str()), Some("nord"));
        assert_eq!(
            row.get("source").and_then(|value| value.as_str()),
            Some("builtin")
        );
    }

    #[test]
    fn theme_output_helpers_and_command_def_expose_runtime_choices_unit() {
        assert!(extract_output_rows(CliCommandResult::text("hello")).is_none());

        let def = theme_command_def(&builtin_theme_catalog(), "20");
        assert_eq!(def.name, "theme");
        assert_eq!(def.sort_key.as_deref(), Some("20"));
        let show = def
            .subcommands
            .iter()
            .find(|subcommand| subcommand.name == "show")
            .expect("show subcommand");
        assert_eq!(show.args[0].choices[0].value, "nord");
    }
}