Ctrl K
ComunidadGuías…

native/src/cli_executor.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Typed execution primitives for CLI commands that can operate directly on
//! the reusable Rust core without constructing GPUI dashboard state.
//!
//! Process lifecycle, autostart, package replacement and runtime reload belong
//! to the platform/bootstrap layer. These methods cover setup, config, models,
//! microphones and persisted hotkeys.

use crate::cli::{
    CommandResult, ConfigAction, DictionaryAction, HotkeyAction, MicAction, ModelAction,
    SetupOptions, ToggleAction,
};
use crate::system_capabilities::SystemCapabilities;
use origin_speak_lib::{
    AppConfig, AppState, LocalModelInfo, State, add_dictionary_word, delete_dictionary_word,
    delete_local_model, download_local_model, get_audio_devices, get_config, get_dictionary_words,
    get_transcription_settings, get_trigger_hotkey, list_local_models, set_audio_device,
    set_config, set_transcription_model, set_trigger_hotkey, update_dictionary_word,
};
use std::sync::Arc;

pub struct CoreCliExecutor {
    state: Arc<AppState>,
}

impl Default for CoreCliExecutor {
    fn default() -> Self {
        Self::new()
    }
}

impl CoreCliExecutor {
    pub fn new() -> Self {
        Self {
            state: Arc::new(AppState::default()),
        }
    }

    pub fn state(&self) -> Arc<AppState> {
        self.state.clone()
    }

    /// Idempotent first-run setup. Existing downloaded models and matching
    /// settings are retained. When no model is supplied, verified CPU/RAM data
    /// chooses a conservative latency-oriented model.
    pub async fn setup(&self, options: &SetupOptions) -> Result<CommandResult, String> {
        let capabilities = SystemCapabilities::detect();
        let recommendation = capabilities.recommend_model();
        let model = options.model.as_deref().unwrap_or(recommendation.model);

        let models = list_local_models(State::new(self.state.as_ref())).await?;
        let selected = get_transcription_settings(State::new(self.state.as_ref()))
            .await?
            .model;
        let model_info = models
            .iter()
            .find(|entry| entry.id == model)
            .ok_or_else(|| format!("Unknown local transcription model: {model}"))?;
        let mut downloaded = false;
        if !model_info.downloaded {
            download_local_model(State::new(self.state.as_ref()), model.to_string()).await?;
            downloaded = true;
        }
        if selected != model {
            set_transcription_model(State::new(self.state.as_ref()), model.to_string()).await?;
        }

        if let Some(microphone) = options.microphone.as_deref() {
            let config = get_config(State::new(self.state.as_ref())).await?;
            if is_system_default_microphone(microphone) {
                if config.selected_audio_device.is_some() {
                    let mut config = config;
                    config.selected_audio_device = None;
                    set_config(State::new(self.state.as_ref()), config).await?;
                }
            } else if config.selected_audio_device.as_deref() != Some(microphone) {
                set_audio_device(State::new(self.state.as_ref()), microphone.to_string()).await?;
            }
        }

        if let Some(hotkey) = options.hotkey.as_deref() {
            let current = get_trigger_hotkey(State::new(self.state.as_ref())).await?;
            if current != hotkey {
                set_trigger_hotkey(State::new(self.state.as_ref()), hotkey.to_string()).await?;
            }
        }

        let mut config = get_config(State::new(self.state.as_ref())).await?;
        if options
            .autostart
            .is_some_and(|enabled| config.auto_start != enabled)
        {
            config.auto_start = options.autostart.unwrap_or(config.auto_start);
            set_config(State::new(self.state.as_ref()), config).await?;
        }

        let memory = capabilities
            .memory_gib()
            .map(|value| format!("{value} GiB"))
            .unwrap_or_else(|| "unknown".to_string());
        let mut result = CommandResult::success("setup_complete", "Origin Speak setup is ready")
            .field("model", model)
            .field("model_downloaded_now", downloaded.to_string())
            .field("cpu_arch", capabilities.arch)
            .field("logical_cpus", capabilities.logical_cpus.to_string())
            .field("memory", memory)
            .field("accelerator", capabilities.accelerator.to_string())
            .field("recommendation", recommendation.rationale);
        if let Some(microphone) = options.microphone.as_deref() {
            result = result.field(
                "microphone",
                if is_system_default_microphone(microphone) {
                    "system default"
                } else {
                    microphone
                },
            );
        }
        if let Some(hotkey) = options.hotkey.as_deref() {
            result = result.field("hotkey", hotkey);
        }
        if let Some(autostart) = options.autostart {
            result = result.field("autostart", autostart.to_string());
        }
        Ok(result)
    }

    pub async fn model(&self, action: &ModelAction) -> Result<CommandResult, String> {
        match action {
            ModelAction::Help => Ok(CommandResult::success(
                "model_help",
                "Origin Speak model management help",
            )
            .human(model_help())),
            ModelAction::List => {
                let models = list_local_models(State::new(self.state.as_ref())).await?;
                let human = render_model_catalog(&models, terminal_width());
                Ok(
                    CommandResult::success("models", "Available transcription models")
                        .field("models", render_models(&models))
                        .field(
                            "installed_count",
                            models
                                .iter()
                                .filter(|model| model.downloaded)
                                .count()
                                .to_string(),
                        )
                        .human(human),
                )
            }
            ModelAction::Installed => {
                let models = list_local_models(State::new(self.state.as_ref())).await?;
                let installed = models
                    .iter()
                    .filter(|model| model.downloaded)
                    .cloned()
                    .collect::<Vec<_>>();
                let summary = if installed.is_empty() {
                    "No transcription models are installed.".to_string()
                } else {
                    render_models(&installed)
                };
                let human = render_installed_models(&installed, terminal_width());
                Ok(
                    CommandResult::success("installed_models", "Installed transcription models")
                        .field("models", summary)
                        .field("installed_count", installed.len().to_string())
                        .human(human),
                )
            }
            ModelAction::Status => {
                let selected = get_transcription_settings(State::new(self.state.as_ref()))
                    .await?
                    .model;
                let config = get_config(State::new(self.state.as_ref())).await?;
                let models = list_local_models(State::new(self.state.as_ref())).await?;
                let downloaded = models
                    .iter()
                    .find(|model| model.id == selected)
                    .is_some_and(|model| model.downloaded);
                Ok(
                    CommandResult::success("model_status", "Transcription model status")
                        .field("selected", selected)
                        .field("downloaded", downloaded.to_string())
                        .field(
                            "gpu_preference",
                            if config.use_gpu {
                                "enabled"
                            } else {
                                "disabled"
                            },
                        ),
                )
            }
            ModelAction::Select(model) => {
                let current = get_transcription_settings(State::new(self.state.as_ref()))
                    .await?
                    .model;
                let models = list_local_models(State::new(self.state.as_ref())).await?;
                let info = models
                    .iter()
                    .find(|entry| entry.id == *model)
                    .ok_or_else(|| unknown_model_error(model, &models))?;
                let installed_now = !info.downloaded;
                if installed_now {
                    download_local_model(State::new(self.state.as_ref()), model.clone())
                        .await
                        .map_err(|error| model_install_error(model, error))?;
                }
                if current != *model {
                    set_transcription_model(State::new(self.state.as_ref()), model.clone()).await?;
                }
                Ok(CommandResult::success(
                    "model_selected",
                    format!("Model {model} is now the default"),
                )
                .field("selected", model)
                .field("installed_now", installed_now.to_string())
                .human_field("previous", current.clone())
                .human_field("changed", (current != *model).to_string())
                .human_field("model_name", info.label.clone()))
            }
            ModelAction::Download(model) => {
                let existing = list_local_models(State::new(self.state.as_ref())).await?;
                let info = existing
                    .iter()
                    .find(|entry| entry.id == *model)
                    .ok_or_else(|| unknown_model_error(model, &existing))?;
                let downloaded = info.downloaded;
                if !downloaded {
                    download_local_model(State::new(self.state.as_ref()), model.clone())
                        .await
                        .map_err(|error| model_install_error(model, error))?;
                }
                Ok(CommandResult::success(
                    "model_downloaded",
                    format!("Model {model} is available"),
                )
                .field("downloaded_now", (!downloaded).to_string())
                .human_field("model", model)
                .human_field("model_name", info.label.clone()))
            }
            ModelAction::Remove(model) => {
                let models = list_local_models(State::new(self.state.as_ref())).await?;
                let info = models
                    .iter()
                    .find(|entry| entry.id == *model)
                    .ok_or_else(|| unknown_model_error(model, &models))?;
                let selected = get_transcription_settings(State::new(self.state.as_ref()))
                    .await?
                    .model;
                ensure_model_removable(&selected, model)?;
                if !info.downloaded {
                    return Ok(CommandResult::success(
                        "model_not_installed",
                        format!("Model {model} is not installed"),
                    )
                    .field("removed", "false")
                    .human_field("model", model)
                    .human_field("model_name", info.label.clone())
                    .human_field("selected", selected));
                }
                let installed_bytes = info.installed_bytes;
                let removed =
                    delete_local_model(State::new(self.state.as_ref()), model.clone()).await?;
                if !removed {
                    return Ok(CommandResult::success(
                        "model_not_installed",
                        format!("Model {model} is not installed"),
                    )
                    .field("removed", "false")
                    .human_field("model", model)
                    .human_field("model_name", info.label.clone())
                    .human_field("selected", selected));
                }
                let remaining = models
                    .iter()
                    .filter(|entry| entry.downloaded && entry.id != *model)
                    .count();
                Ok(
                    CommandResult::success("model_removed", format!("Removed model {model}"))
                        .field("removed", removed.to_string())
                        .human_field("model", model)
                        .human_field("model_name", info.label.clone())
                        .human_field(
                            "freed_bytes",
                            installed_bytes
                                .map(|bytes| bytes.to_string())
                                .unwrap_or_default(),
                        )
                        .human_field("remaining_count", remaining.to_string())
                        .human_field("selected", selected),
                )
            }
        }
    }

    pub async fn dictionary(&self, action: &DictionaryAction) -> Result<CommandResult, String> {
        match action {
            DictionaryAction::List => {
                let words = get_dictionary_words().await?;
                let summary = if words.is_empty() {
                    "No recognition dictionary entries.".to_string()
                } else {
                    words
                        .iter()
                        .map(|entry| {
                            format!(
                                "{}\t{}{}",
                                entry.id,
                                entry.word,
                                entry
                                    .phonetic
                                    .as_deref()
                                    .map(|value| format!("\t{value}"))
                                    .unwrap_or_default()
                            )
                        })
                        .collect::<Vec<_>>()
                        .join("\n")
                };
                Ok(CommandResult::success("dictionary", summary)
                    .field("count", words.len().to_string()))
            }
            DictionaryAction::Add {
                word,
                pronunciation,
            } => {
                let word = validated_dictionary_text("word", word)?;
                let pronunciation = normalized_optional_text(pronunciation.as_deref());
                let added = add_dictionary_word(word.clone(), false).await?;
                if let Some(pronunciation) = pronunciation.clone() {
                    if let Err(error) = update_dictionary_word(
                        added.id.clone(),
                        word.clone(),
                        Some(pronunciation.clone()),
                    )
                    .await
                    {
                        let _ = delete_dictionary_word(added.id.clone()).await;
                        return Err(error);
                    }
                }
                Ok(CommandResult::success(
                    "dictionary_added",
                    format!("Added '{word}' to the recognition dictionary"),
                )
                .field("id", added.id)
                .field("word", word)
                .field(
                    "pronunciation",
                    pronunciation.unwrap_or_else(|| "none".to_string()),
                ))
            }
            DictionaryAction::Update {
                id,
                word,
                pronunciation,
            } => {
                let id = validated_dictionary_text("id", id)?;
                let word = validated_dictionary_text("word", word)?;
                let pronunciation = normalized_optional_text(pronunciation.as_deref());
                update_dictionary_word(id.clone(), word.clone(), pronunciation.clone()).await?;
                Ok(CommandResult::success(
                    "dictionary_updated",
                    format!("Updated recognition dictionary entry '{word}'"),
                )
                .field("id", id)
                .field("word", word)
                .field(
                    "pronunciation",
                    pronunciation.unwrap_or_else(|| "none".to_string()),
                ))
            }
            DictionaryAction::Remove(id) => {
                let id = validated_dictionary_text("id", id)?;
                delete_dictionary_word(id.clone()).await?;
                Ok(CommandResult::success(
                    "dictionary_removed",
                    "Removed recognition dictionary entry",
                )
                .field("id", id))
            }
        }
    }

    pub async fn mic(&self, action: &MicAction) -> Result<CommandResult, String> {
        match action {
            MicAction::List => {
                let devices = get_audio_devices().await?;
                let summary = devices
                    .iter()
                    .map(|device| {
                        format!(
                            "{}{}",
                            device.name,
                            if device.is_default { " (default)" } else { "" }
                        )
                    })
                    .collect::<Vec<_>>()
                    .join(", ");
                Ok(
                    CommandResult::success("microphones", "Available microphones")
                        .field("devices", summary),
                )
            }
            MicAction::Status => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                Ok(
                    CommandResult::success("microphone_status", "Microphone selection").field(
                        "selected",
                        config
                            .selected_audio_device
                            .unwrap_or_else(|| "system default".to_string()),
                    ),
                )
            }
            MicAction::Select(name) => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                if is_system_default_microphone(name) {
                    if config.selected_audio_device.is_some() {
                        let mut config = config;
                        config.selected_audio_device = None;
                        set_config(State::new(self.state.as_ref()), config).await?;
                    }
                    return Ok(CommandResult::success(
                        "microphone_selected",
                        "Selected system default microphone",
                    )
                    .field("selected", "system default"));
                }
                if config.selected_audio_device.as_deref() != Some(name) {
                    set_audio_device(State::new(self.state.as_ref()), name.clone()).await?;
                }
                Ok(CommandResult::success(
                    "microphone_selected",
                    format!("Selected microphone {name}"),
                ))
            }
        }
    }

    pub async fn hotkey(&self, action: &HotkeyAction) -> Result<CommandResult, String> {
        match action {
            HotkeyAction::Show => {
                Ok(
                    CommandResult::success("hotkey", "Dictation shortcut configuration").field(
                        "trigger",
                        get_trigger_hotkey(State::new(self.state.as_ref())).await?,
                    ),
                )
            }
            HotkeyAction::Set(chord) => {
                let normalized =
                    set_trigger_hotkey(State::new(self.state.as_ref()), chord.clone()).await?;
                Ok(
                    CommandResult::success("hotkey_saved", "Dictation hotkey saved")
                        .field("hotkey", normalized),
                )
            }
        }
    }

    pub async fn autostart(&self, action: ToggleAction) -> Result<CommandResult, String> {
        let mut config = get_config(State::new(self.state.as_ref())).await?;
        match action {
            ToggleAction::Status => Ok(CommandResult::success(
                "autostart_status",
                "Autostart preference",
            )
            .field("enabled", config.auto_start.to_string())),
            ToggleAction::Enable | ToggleAction::Disable => {
                let enabled = matches!(action, ToggleAction::Enable);
                if config.auto_start != enabled {
                    config.auto_start = enabled;
                    set_config(State::new(self.state.as_ref()), config).await?;
                }
                Ok(CommandResult::success(
                    "autostart_saved",
                    "Autostart preference saved; the resident runtime applies native registration",
                )
                .field("enabled", enabled.to_string()))
            }
        }
    }

    pub async fn config(&self, action: &ConfigAction) -> Result<CommandResult, String> {
        match action {
            ConfigAction::List => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                let value = serde_json::to_string(&config)
                    .map_err(|error| format!("serialize config: {error}"))?;
                Ok(CommandResult::success("config", "Persisted configuration")
                    .field("value", value))
            }
            ConfigAction::Get(path) => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                let value = serde_json::to_value(config)
                    .map_err(|error| format!("serialize config: {error}"))?;
                let selected =
                    json_path(&value, path).ok_or_else(|| format!("Unknown config key: {path}"))?;
                Ok(CommandResult::success(
                    "config_value",
                    format!("{path} = {}", json_scalar(selected)?),
                )
                .field("key", path)
                .field("value", json_scalar(selected)?))
            }
            ConfigAction::Set { key, value } => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                let mut json = serde_json::to_value(config)
                    .map_err(|error| format!("serialize config: {error}"))?;
                let parsed = parse_cli_value(value);
                *json_path_mut(&mut json, key)
                    .ok_or_else(|| format!("Unknown config key: {key}"))? = parsed;
                let saved_value = json_scalar(
                    json_path(&json, key).ok_or_else(|| format!("Unknown config key: {key}"))?,
                )?;
                let config: AppConfig = serde_json::from_value(json)
                    .map_err(|error| format!("Invalid value for {key}: {error}"))?;
                set_config(State::new(self.state.as_ref()), config).await?;
                Ok(
                    CommandResult::success("config_saved", format!("Saved {key}"))
                        .human_field("key", key)
                        .human_field("value", saved_value),
                )
            }
            ConfigAction::Reset(key) => {
                let config = get_config(State::new(self.state.as_ref())).await?;
                let mut current = serde_json::to_value(config)
                    .map_err(|error| format!("serialize config: {error}"))?;
                let defaults = serde_json::to_value(AppConfig::default())
                    .map_err(|error| format!("serialize default config: {error}"))?;
                let default = json_path(&defaults, key)
                    .cloned()
                    .ok_or_else(|| format!("Unknown config key: {key}"))?;
                let default_value = json_scalar(&default)?;
                *json_path_mut(&mut current, key)
                    .ok_or_else(|| format!("Unknown config key: {key}"))? = default;
                let config: AppConfig = serde_json::from_value(current)
                    .map_err(|error| format!("restore default {key}: {error}"))?;
                set_config(State::new(self.state.as_ref()), config).await?;
                Ok(
                    CommandResult::success("config_reset", format!("Reset {key} to its default"))
                        .human_field("key", key)
                        .human_field("value", default_value),
                )
            }
        }
    }
}

fn parse_cli_value(raw: &str) -> serde_json::Value {
    serde_json::from_str(raw).unwrap_or_else(|_| serde_json::Value::String(raw.to_string()))
}

fn is_system_default_microphone(value: &str) -> bool {
    value.eq_ignore_ascii_case("default") || value.eq_ignore_ascii_case("system default")
}

fn ensure_model_removable(selected: &str, target: &str) -> Result<(), String> {
    if selected == target {
        return Err(format!(
            "Cannot remove '{target}' because it is the current default model.\n\nSwitch to another installed model first:\n  origin model use <other-model-id>\n\nThen retry:\n  origin model remove {target}\n\nRun 'origin model installed' to see possible replacements."
        ));
    }
    Ok(())
}

fn unknown_model_error(requested: &str, models: &[LocalModelInfo]) -> String {
    let mut matches = models
        .iter()
        .map(|model| (edit_distance(requested, &model.id), model.id.as_str()))
        .collect::<Vec<_>>();
    matches.sort_by_key(|(distance, id)| (*distance, *id));
    let suggestions = matches
        .into_iter()
        .take(3)
        .map(|(_, id)| format!("  {id}"))
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "Model not found: {requested}\n\nClosest supported models:\n{suggestions}\n\nRun 'origin model list' to view all supported models."
    )
}

fn model_install_error(model: &str, error: String) -> String {
    format!(
        "Could not install model '{model}'. The default model was not changed.\n\nDetails: {error}\n\nCheck your network connection and available disk space, then retry:\n  origin model use {model}"
    )
}

fn edit_distance(left: &str, right: &str) -> usize {
    let right_chars = right.chars().collect::<Vec<_>>();
    let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
    for (left_index, left_char) in left.chars().enumerate() {
        let mut current = vec![left_index + 1];
        for (right_index, right_char) in right_chars.iter().enumerate() {
            current.push(
                (current[right_index] + 1).min(
                    (previous[right_index + 1] + 1)
                        .min(previous[right_index] + usize::from(left_char != *right_char)),
                ),
            );
        }
        previous = current;
    }
    previous[right_chars.len()]
}

fn render_models(models: &[origin_speak_lib::LocalModelInfo]) -> String {
    models
        .iter()
        .map(|model| {
            let mut states = Vec::new();
            if model.selected {
                states.push("default");
            }
            if model.downloaded {
                states.push("installed");
            }
            if model.recommended {
                states.push("recommended");
            }
            let state = if states.is_empty() {
                String::new()
            } else {
                format!(" [{}]", states.join(", "))
            };
            let size = model
                .installed_bytes
                .map(|bytes| format!(" · {}", format_model_bytes(bytes)))
                .unwrap_or_default();
            format!("{}{}{}\n  {}", model.id, state, size, model.label)
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn terminal_width() -> usize {
    std::env::var("COLUMNS")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .filter(|width| *width >= 30)
        .unwrap_or(120)
}

fn model_status(model: &LocalModelInfo) -> String {
    let mut states = Vec::new();
    if model.selected && model.downloaded {
        states.extend(["Default", "Installed"]);
    } else if model.selected {
        states.extend(["Default", "Missing"]);
    } else if model.downloaded {
        states.push("Installed");
    } else {
        states.push("Available");
    }
    if model.recommended {
        states.push("Recommended");
    }
    states.join(" · ")
}

fn render_model_catalog(models: &[LocalModelInfo], width: usize) -> String {
    let mut output = String::from("Origin Speak · Transcription Models\n\n");
    output.push_str(&render_model_table(models, width));
    let installed = models.iter().filter(|model| model.downloaded).count();
    let defaults = models.iter().filter(|model| model.selected).count();
    output.push_str(&format!(
        "\n\nModels: {} available · {installed} installed · {defaults} default\n\n",
        models.len()
    ));
    output.push_str(
        "Actions\n  Install or switch model    origin model use <model-id>\n  Install without switching  origin model install <model-id>\n  View installed models      origin model installed\n  View active model / GPU    origin model status\n  Remove installed model     origin model remove <model-id>\n\nExample\n  origin model use large-v3",
    );
    output
}

fn model_help() -> &'static str {
    "Origin Speak · Model Management\n\nUsage\n  origin model <command> [model-id]\n\nCommands\n  list                 Show every supported model and its state\n  installed            Show only models occupying local storage\n  status               Show the default model and active CPU/GPU compute\n  use <model-id>       Install if needed, then make the model the default\n  install <model-id>   Install without changing the default model\n  remove <model-id>    Remove an installed, non-default model\n\nExamples\n  origin model list\n  origin model use base.en\n  origin model install large-v3\n  origin model remove base.en\n  origin model status\n\nStable model IDs—not list row numbers—are required in commands and scripts.\nCompatibility aliases: select/switch, download, and delete/uninstall remain supported.\nUse --json anywhere in a command for machine-readable output."
}

fn render_installed_models(models: &[LocalModelInfo], width: usize) -> String {
    let mut output = String::from("Origin Speak · Installed Models\n\n");
    if models.is_empty() {
        output.push_str(
            "No transcription models are installed.\n\nInstall and select the recommended model:\n  origin model use base.en\n\nView all supported models:\n  origin model list",
        );
        return output;
    }
    output.push_str(&render_model_table(models, width));
    let total = models
        .iter()
        .filter_map(|model| model.installed_bytes)
        .sum::<u64>();
    let complete = models.iter().all(|model| model.installed_bytes.is_some());
    output.push_str(&format!("\n\nInstalled models: {}", models.len()));
    if complete {
        output.push_str(&format!(" · Total storage: {}", format_model_bytes(total)));
    }
    output.push_str("\n\nRemove a model\n  origin model remove <model-id>");
    output
}

fn render_model_table(models: &[LocalModelInfo], width: usize) -> String {
    let number_width = models.len().to_string().len().max(1);
    let id_width = models
        .iter()
        .map(|model| display_width(&model.id))
        .max()
        .unwrap_or(5)
        .max(5);
    let name_width = models
        .iter()
        .map(|model| display_width(&model.label))
        .max()
        .unwrap_or(4)
        .max(4);
    let size_width = 10;
    let status_width = models
        .iter()
        .map(|model| display_width(&model_status(model)))
        .max()
        .unwrap_or(6)
        .max(6);
    let required_width =
        1 + number_width + 2 + id_width + 2 + name_width + 2 + size_width + 2 + status_width;
    if width < 88 || required_width > width {
        let mut rows = String::from(" #  MODEL                 STATUS\n");
        rows.push_str(&format!(" {}\n", "─".repeat(width.saturating_sub(2))));
        for (index, model) in models.iter().enumerate() {
            rows.push_str(&format!(
                "{:>2}  {:<20}  {}\n    {}",
                index + 1,
                model.id,
                model_status(model),
                model.label
            ));
            if let Some(bytes) = model.installed_bytes {
                rows.push_str(&format!(" · {}", format_model_bytes(bytes)));
            }
            rows.push('\n');
        }
        return rows.trim_end().to_string();
    }

    let mut rows = format!(
        " {:>number_width$}  {}  {}  {}  STATUS\n",
        "#",
        pad_right("MODEL", id_width),
        pad_right("NAME", name_width),
        pad_right("SIZE", size_width)
    );
    rows.push_str(&format!(
        " {}\n",
        "─".repeat(required_width.saturating_sub(2))
    ));
    for (index, model) in models.iter().enumerate() {
        let size = model
            .installed_bytes
            .map(format_model_bytes)
            .unwrap_or_else(|| "—".to_string());
        rows.push_str(&format!(
            " {:>number_width$}  {}  {}  {}  {}\n",
            index + 1,
            pad_right(&model.id, id_width),
            pad_right(&model.label, name_width),
            pad_right(&size, size_width),
            model_status(model)
        ));
    }
    rows.trim_end().to_string()
}

fn pad_right(value: &str, width: usize) -> String {
    format!(
        "{value}{}",
        " ".repeat(width.saturating_sub(display_width(value)))
    )
}

fn display_width(value: &str) -> usize {
    value.chars().map(character_width).sum()
}

fn character_width(ch: char) -> usize {
    let code = ch as u32;
    if ch.is_control()
        || matches!(code, 0x0300..=0x036f | 0x1ab0..=0x1aff | 0x1dc0..=0x1dff | 0x20d0..=0x20ff | 0xfe20..=0xfe2f)
    {
        0
    } else if matches!(code, 0x1100..=0x115f | 0x2329..=0x232a | 0x2e80..=0xa4cf | 0xac00..=0xd7a3 | 0xf900..=0xfaff | 0xfe10..=0xfe19 | 0xfe30..=0xfe6f | 0xff00..=0xff60 | 0xffe0..=0xffe6 | 0x1f300..=0x1faff | 0x20000..=0x3fffd)
    {
        2
    } else {
        1
    }
}

fn format_model_bytes(bytes: u64) -> String {
    const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
    const MIB: f64 = 1024.0 * 1024.0;
    if bytes >= 1024 * 1024 * 1024 {
        format!("{:.2} GiB", bytes as f64 / GIB)
    } else {
        format!("{:.0} MiB", bytes as f64 / MIB)
    }
}

fn validated_dictionary_text(label: &str, value: &str) -> Result<String, String> {
    let value = value.trim();
    if value.is_empty() {
        return Err(format!("Dictionary {label} cannot be empty"));
    }
    Ok(value.to_string())
}

fn normalized_optional_text(value: Option<&str>) -> Option<String> {
    value.and_then(|value| {
        let value = value.trim();
        (!value.is_empty()).then(|| value.to_string())
    })
}

fn json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
    path.split('.')
        .try_fold(value, |current, segment| current.get(segment))
}

fn json_path_mut<'a>(
    value: &'a mut serde_json::Value,
    path: &str,
) -> Option<&'a mut serde_json::Value> {
    let mut current = value;
    for segment in path.split('.') {
        current = current.get_mut(segment)?;
    }
    Some(current)
}

fn json_scalar(value: &serde_json::Value) -> Result<String, String> {
    match value {
        serde_json::Value::String(value) => Ok(value.clone()),
        other => {
            serde_json::to_string(other).map_err(|error| format!("serialize config value: {error}"))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn model(
        id: &str,
        label: &str,
        downloaded: bool,
        selected: bool,
        recommended: bool,
        bytes: Option<u64>,
    ) -> LocalModelInfo {
        LocalModelInfo {
            id: id.to_string(),
            label: label.to_string(),
            filename: format!("{id}.bin"),
            revision: "test".to_string(),
            sha256: "test".to_string(),
            tier: "test".to_string(),
            english_only: true,
            recommended,
            path: format!("models/{id}.bin"),
            downloaded,
            installed_bytes: bytes,
            selected,
        }
    }

    #[test]
    fn dotted_config_path_mutation_is_precise() {
        let mut value = serde_json::json!({"ui": {"show_overlay": true}, "auto_copy": false});
        *json_path_mut(&mut value, "ui.show_overlay").unwrap() = serde_json::Value::Bool(false);
        assert_eq!(
            json_path(&value, "ui.show_overlay"),
            Some(&serde_json::Value::Bool(false))
        );
        assert_eq!(
            json_path(&value, "auto_copy"),
            Some(&serde_json::Value::Bool(false))
        );
    }

    #[test]
    fn cli_values_accept_json_or_plain_strings() {
        assert_eq!(parse_cli_value("true"), serde_json::Value::Bool(true));
        assert_eq!(
            parse_cli_value("hello world"),
            serde_json::Value::String("hello world".into())
        );
    }

    #[test]
    fn setup_default_microphone_sentinel_is_explicit() {
        assert!(is_system_default_microphone("default"));
        assert!(is_system_default_microphone("SYSTEM DEFAULT"));
        assert!(!is_system_default_microphone("Studio Mic"));
    }

    #[test]
    fn selected_model_cannot_be_removed() {
        let error = ensure_model_removable("base.en", "base.en").unwrap_err();
        assert!(error.contains("origin model use <other-model-id>"));
        assert!(error.contains("origin model remove base.en"));
        assert!(ensure_model_removable("base.en", "tiny.en").is_ok());
    }

    #[test]
    fn model_catalog_has_ordered_rows_and_unambiguous_states() {
        let models = vec![
            model("tiny.en", "Tiny English", false, false, false, None),
            model("base.en", "Base English", true, true, true, Some(1_500_000)),
            model(
                "small.en",
                "Small English",
                true,
                false,
                false,
                Some(2_500_000),
            ),
        ];
        let output = render_model_catalog(&models, 100);
        assert!(output.contains(" 1  tiny.en"));
        assert!(output.contains(" 2  base.en"));
        assert!(output.contains("Default · Installed"));
        assert!(output.contains("Installed"));
        assert!(output.contains("Models: 3 available · 2 installed · 1 default"));
        assert!(output.contains("origin model use <model-id>"));
    }

    #[test]
    fn installed_models_empty_state_is_actionable() {
        let output = render_installed_models(&[], 100);
        assert!(output.contains("No transcription models are installed"));
        assert!(output.contains("origin model use base.en"));
        assert!(output.contains("origin model list"));
    }

    #[test]
    fn installed_models_total_only_uses_known_sizes() {
        let complete = vec![
            model(
                "base.en",
                "Base English",
                true,
                true,
                false,
                Some(1024 * 1024),
            ),
            model(
                "tiny.en",
                "Tiny English",
                true,
                false,
                false,
                Some(2 * 1024 * 1024),
            ),
        ];
        assert!(render_installed_models(&complete, 100).contains("Total storage: 3 MiB"));

        let incomplete = vec![
            complete[0].clone(),
            model("tiny.en", "Tiny English", true, false, false, None),
        ];
        assert!(!render_installed_models(&incomplete, 100).contains("Total storage"));
    }

    #[test]
    fn narrow_model_table_keeps_every_row_and_name() {
        let models = vec![
            model("base.en", "Base English", false, false, true, None),
            model(
                "canary-qwen-2.5b",
                "Canary-Qwen 2.5B (Q8)",
                true,
                true,
                false,
                Some(2_797_548_928),
            ),
            model(
                "qwen3-asr-1.7b",
                "Qwen3-ASR 1.7B (Q8)",
                false,
                false,
                false,
                Some(2_185_030_624),
            ),
        ];
        let output = render_model_table(&models, 48);
        assert!(output.contains(" 1  base.en"));
        assert!(output.contains(" 2  canary-qwen-2.5b"));
        assert!(output.contains(" 3  qwen3-asr-1.7b"));
        assert!(output.contains("Canary-Qwen 2.5B (Q8) · 2.61 GiB"));
        assert!(output.contains("Qwen3-ASR 1.7B (Q8) · 2.03 GiB"));
    }

    #[test]
    fn table_padding_uses_terminal_cell_width_for_unicode() {
        assert_eq!(display_width("Model"), 5);
        assert_eq!(display_width("模型"), 4);
        assert_eq!(display_width("e\u{301}"), 1);
        assert_eq!(display_width(&pad_right("模型", 8)), 8);
    }

    #[test]
    fn invalid_model_error_suggests_real_ids_and_list_command() {
        let models = vec![
            model("large-v3", "Large v3", false, false, false, None),
            model(
                "large-v3-turbo",
                "Large v3 Turbo",
                false,
                false,
                false,
                None,
            ),
            model("base.en", "Base English", false, false, true, None),
        ];
        let error = unknown_model_error("large-v4", &models);
        assert!(error.contains("Model not found: large-v4"));
        assert!(error.contains("large-v3"));
        assert!(error.contains("large-v3-turbo"));
        assert!(error.contains("origin model list"));
    }

    #[test]
    fn failed_install_message_preserves_default_and_has_retry() {
        let error = model_install_error("large-v3", "network unavailable".to_string());
        assert!(error.contains("default model was not changed"));
        assert!(error.contains("network unavailable"));
        assert!(error.contains("origin model use large-v3"));
    }

    #[test]
    fn dictionary_values_are_trimmed_and_empty_pronunciation_is_none() {
        assert_eq!(
            validated_dictionary_text("word", "  Axius  ").unwrap(),
            "Axius"
        );
        assert!(validated_dictionary_text("word", "  ").is_err());
        assert_eq!(
            normalized_optional_text(Some("  ACK-see-us  ")),
            Some("ACK-see-us".into())
        );
        assert_eq!(normalized_optional_text(Some("   ")), None);
    }
}