//! 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, } 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 { 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 { 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 { 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::>(); 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 { 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::>() .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 { 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::>() .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 { 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 { 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 { 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 \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::>(); matches.sort_by_key(|(distance, id)| (*distance, *id)); let suggestions = matches .into_iter() .take(3) .map(|(_, id)| format!(" {id}")) .collect::>() .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::>(); let mut previous = (0..=right_chars.len()).collect::>(); 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::>() .join("\n") } fn terminal_width() -> usize { std::env::var("COLUMNS") .ok() .and_then(|value| value.parse::().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 \n Install without switching origin model install \n View installed models origin model installed\n View active model / GPU origin model status\n Remove installed model origin model remove \n\nExample\n origin model use large-v3", ); output } fn model_help() -> &'static str { "Origin Speak · Model Management\n\nUsage\n origin model [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 Install if needed, then make the model the default\n install Install without changing the default model\n remove 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::(); 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 "); 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 { 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 { 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 { 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, ) -> 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 ")); 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 ")); } #[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); } }