Skip to main content

opencode_voice/
config.rs

1//! CLI argument parsing and application configuration.
2
3use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use std::path::PathBuf;
6
7/// Whisper model size selection.
8///
9/// English-only variants (`*.en`) are fine-tuned on English and slightly more
10/// accurate for standard accents.  Multilingual variants are trained on 99
11/// languages and handle accented English better because they've seen more
12/// diverse phonetic patterns.
13#[derive(Debug, Clone)]
14pub enum ModelSize {
15    TinyEn,
16    BaseEn,
17    SmallEn,
18    Tiny,
19    Base,
20    Small,
21}
22
23impl Default for ModelSize {
24    fn default() -> Self {
25        ModelSize::BaseEn
26    }
27}
28
29impl std::fmt::Display for ModelSize {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            ModelSize::TinyEn => write!(f, "tiny.en"),
33            ModelSize::BaseEn => write!(f, "base.en"),
34            ModelSize::SmallEn => write!(f, "small.en"),
35            ModelSize::Tiny => write!(f, "tiny"),
36            ModelSize::Base => write!(f, "base"),
37            ModelSize::Small => write!(f, "small"),
38        }
39    }
40}
41
42impl ModelSize {
43    /// Returns `true` for multilingual models (without the `.en` suffix).
44    pub fn is_multilingual(&self) -> bool {
45        matches!(self, ModelSize::Tiny | ModelSize::Base | ModelSize::Small)
46    }
47}
48
49impl std::str::FromStr for ModelSize {
50    type Err = anyhow::Error;
51
52    fn from_str(s: &str) -> Result<Self> {
53        match s {
54            "tiny.en" => Ok(ModelSize::TinyEn),
55            "base.en" => Ok(ModelSize::BaseEn),
56            "small.en" => Ok(ModelSize::SmallEn),
57            "tiny" => Ok(ModelSize::Tiny),
58            "base" => Ok(ModelSize::Base),
59            "small" => Ok(ModelSize::Small),
60            _ => Err(anyhow::anyhow!(
61                "Unknown model size: {}. Valid: tiny.en, tiny, base.en, base, small.en, small",
62                s
63            )),
64        }
65    }
66}
67
68/// OpenCode voice input CLI tool.
69#[derive(Parser, Debug)]
70#[command(name = "opencode-voice", about = "Voice input for OpenCode", version)]
71pub struct CliArgs {
72    #[command(subcommand)]
73    pub command: Option<Commands>,
74
75    /// OpenCode server port (required for the run subcommand)
76    #[arg(long, short = 'p', global = true)]
77    pub port: Option<u16>,
78
79    /// Audio device name
80    #[arg(long, global = true)]
81    pub device: Option<String>,
82
83    /// Whisper model size (tiny.en, base.en, small.en)
84    #[arg(long, short = 'm', global = true)]
85    pub model: Option<ModelSize>,
86
87    /// Toggle key character (default: space)
88    #[arg(long, short = 'k', global = true)]
89    pub key: Option<char>,
90
91    /// Global hotkey name (default: right_option)
92    #[arg(long, global = true)]
93    pub hotkey: Option<String>,
94
95    /// Disable global hotkey, use terminal key only
96    #[arg(long = "no-global", global = true)]
97    pub no_global: bool,
98
99    /// Enable push-to-talk mode (default: true)
100    #[arg(
101        long = "push-to-talk",
102        global = true,
103        overrides_with = "no_push_to_talk"
104    )]
105    pub push_to_talk: bool,
106
107    /// Disable push-to-talk mode
108    #[arg(long = "no-push-to-talk", global = true)]
109    pub no_push_to_talk: bool,
110
111    /// Enable auto-submit after transcription (default: true)
112    #[arg(long = "auto-submit", global = true, overrides_with = "no_auto_submit")]
113    pub auto_submit: bool,
114
115    /// Disable auto-submit
116    #[arg(long = "no-auto-submit", global = true)]
117    pub no_auto_submit: bool,
118
119    /// Handle OpenCode permission and question prompts via voice (default: true)
120    #[arg(
121        long = "handle-prompts",
122        global = true,
123        overrides_with = "no_handle_prompts"
124    )]
125    pub handle_prompts: bool,
126
127    /// Disable voice handling of OpenCode prompts
128    #[arg(long = "no-handle-prompts", global = true)]
129    pub no_handle_prompts: bool,
130
131    /// Debug mode: log key events, audio info, transcripts to stderr; skip OpenCode
132    #[arg(long, global = true)]
133    pub debug: bool,
134}
135
136#[derive(Subcommand, Debug)]
137pub enum Commands {
138    /// Run the voice mode (default)
139    Run,
140    /// Download and set up the whisper model
141    Setup {
142        /// Model size to download (tiny, base, small, tiny.en, base.en, small.en)
143        #[arg(long, short = 'm')]
144        model: Option<ModelSize>,
145    },
146    /// List available audio input devices
147    Devices,
148    /// List available key names for hotkey configuration
149    Keys,
150}
151
152/// Resolved application configuration.
153#[derive(Debug, Clone)]
154pub struct AppConfig {
155    pub whisper_model_path: PathBuf,
156    pub opencode_port: u16,
157    pub toggle_key: char,
158    pub model_size: ModelSize,
159    pub auto_submit: bool,
160    pub server_password: Option<String>,
161    pub data_dir: PathBuf,
162    pub audio_device: Option<String>,
163    pub use_global_hotkey: bool,
164    pub global_hotkey: String,
165    pub push_to_talk: bool,
166    pub handle_prompts: bool,
167    pub debug: bool,
168}
169
170impl AppConfig {
171    /// Load configuration from CLI args + environment variables + defaults.
172    /// Precedence: CLI flags > env vars > defaults.
173    pub fn load(cli: &CliArgs) -> Result<Self> {
174        let data_dir = get_data_dir();
175
176        // Port: CLI > env var > default (0 in debug mode) > error
177        let port_env = std::env::var("OPENCODE_VOICE_PORT")
178            .ok()
179            .and_then(|s| s.parse::<u16>().ok());
180        let port = cli
181            .port
182            .or(port_env)
183            .or(if cli.debug { Some(0) } else { None })
184            .context("OpenCode server port is required. Use --port or set OPENCODE_VOICE_PORT")?;
185
186        // Model: CLI > env var > default
187        let model_env = std::env::var("OPENCODE_VOICE_MODEL")
188            .ok()
189            .and_then(|s| s.parse::<ModelSize>().ok());
190        let model_size = cli.model.clone().or(model_env).unwrap_or_default();
191
192        // Device: CLI > env var
193        let device_env = std::env::var("OPENCODE_VOICE_DEVICE").ok();
194        let audio_device = cli.device.clone().or(device_env);
195
196        // Password: env var only
197        let server_password = std::env::var("OPENCODE_SERVER_PASSWORD").ok();
198
199        // Boolean flags: explicit overrides, then defaults
200        let auto_submit = if cli.no_auto_submit {
201            false
202        } else if cli.auto_submit {
203            true
204        } else {
205            true
206        };
207        let push_to_talk = if cli.no_push_to_talk {
208            false
209        } else if cli.push_to_talk {
210            true
211        } else {
212            true
213        };
214        let use_global_hotkey = !cli.no_global;
215        let handle_prompts = if cli.no_handle_prompts {
216            false
217        } else if cli.handle_prompts {
218            true
219        } else {
220            true
221        };
222        let whisper_model_path = crate::transcribe::setup::get_model_path(&data_dir, &model_size);
223
224        Ok(AppConfig {
225            opencode_port: port,
226            toggle_key: cli.key.unwrap_or(' '),
227            model_size,
228            auto_submit,
229            server_password,
230            data_dir,
231            audio_device,
232            use_global_hotkey,
233            global_hotkey: cli
234                .hotkey
235                .clone()
236                .unwrap_or_else(|| "right_option".to_string()),
237            push_to_talk,
238            handle_prompts,
239            debug: cli.debug,
240            whisper_model_path,
241        })
242    }
243}
244
245/// Returns the platform-appropriate data directory for opencode-voice.
246///
247/// - macOS: ~/Library/Application Support/opencode-voice/
248/// - Linux: $XDG_DATA_HOME/opencode-voice/ or ~/.local/share/opencode-voice/
249pub fn get_data_dir() -> PathBuf {
250    #[cfg(target_os = "macos")]
251    {
252        dirs::data_dir()
253            .unwrap_or_else(|| {
254                dirs::home_dir()
255                    .unwrap_or_else(|| PathBuf::from("."))
256                    .join("Library")
257                    .join("Application Support")
258            })
259            .join("opencode-voice")
260    }
261    #[cfg(not(target_os = "macos"))]
262    {
263        // Linux: XDG_DATA_HOME or ~/.local/share
264        std::env::var("XDG_DATA_HOME")
265            .map(PathBuf::from)
266            .unwrap_or_else(|_| {
267                dirs::home_dir()
268                    .unwrap_or_else(|| PathBuf::from("."))
269                    .join(".local")
270                    .join("share")
271            })
272            .join("opencode-voice")
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_model_size_display() {
282        assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
283        assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
284        assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
285    }
286
287    #[test]
288    fn test_model_size_from_str() {
289        assert!(matches!(
290            "tiny.en".parse::<ModelSize>().unwrap(),
291            ModelSize::TinyEn
292        ));
293        assert!(matches!(
294            "tiny".parse::<ModelSize>().unwrap(),
295            ModelSize::Tiny
296        ));
297        assert!(matches!(
298            "base.en".parse::<ModelSize>().unwrap(),
299            ModelSize::BaseEn
300        ));
301        assert!(matches!(
302            "base".parse::<ModelSize>().unwrap(),
303            ModelSize::Base
304        ));
305        assert!(matches!(
306            "small.en".parse::<ModelSize>().unwrap(),
307            ModelSize::SmallEn
308        ));
309        assert!(matches!(
310            "small".parse::<ModelSize>().unwrap(),
311            ModelSize::Small
312        ));
313    }
314
315    #[test]
316    fn test_model_size_from_str_invalid() {
317        assert!("large".parse::<ModelSize>().is_err());
318        assert!("medium.en".parse::<ModelSize>().is_err());
319    }
320
321    #[test]
322    fn test_model_size_default() {
323        assert!(matches!(ModelSize::default(), ModelSize::BaseEn));
324    }
325
326    #[test]
327    fn test_get_data_dir_contains_app_name() {
328        let dir = get_data_dir();
329        let dir_str = dir.to_string_lossy();
330        assert!(
331            dir_str.contains("opencode-voice"),
332            "data dir should contain 'opencode-voice': {}",
333            dir_str
334        );
335    }
336
337    #[cfg(target_os = "macos")]
338    #[test]
339    fn test_get_data_dir_macos() {
340        let dir = get_data_dir();
341        let dir_str = dir.to_string_lossy();
342        // On macOS should be under Library/Application Support
343        assert!(
344            dir_str.contains("Library/Application Support"),
345            "macOS data dir should be under Library/Application Support: {}",
346            dir_str
347        );
348    }
349
350    // --- Additional tests added to expand coverage ---
351
352    #[test]
353    fn test_model_size_display_tiny_en() {
354        assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
355    }
356
357    #[test]
358    fn test_model_size_display_base_en() {
359        assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
360    }
361
362    #[test]
363    fn test_model_size_display_small_en() {
364        assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
365    }
366
367    #[test]
368    fn test_model_size_fromstr_roundtrip_tiny() {
369        let s = ModelSize::TinyEn.to_string();
370        let parsed: ModelSize = s.parse().unwrap();
371        assert!(matches!(parsed, ModelSize::TinyEn));
372    }
373
374    #[test]
375    fn test_model_size_fromstr_roundtrip_base() {
376        let s = ModelSize::BaseEn.to_string();
377        let parsed: ModelSize = s.parse().unwrap();
378        assert!(matches!(parsed, ModelSize::BaseEn));
379    }
380
381    #[test]
382    fn test_model_size_fromstr_roundtrip_small() {
383        let s = ModelSize::SmallEn.to_string();
384        let parsed: ModelSize = s.parse().unwrap();
385        assert!(matches!(parsed, ModelSize::SmallEn));
386    }
387
388    #[test]
389    fn test_model_size_fromstr_short_aliases_are_multilingual() {
390        // "tiny", "base", "small" (without .en) map to multilingual variants
391        assert!(matches!(
392            "tiny".parse::<ModelSize>().unwrap(),
393            ModelSize::Tiny
394        ));
395        assert!(matches!(
396            "base".parse::<ModelSize>().unwrap(),
397            ModelSize::Base
398        ));
399        assert!(matches!(
400            "small".parse::<ModelSize>().unwrap(),
401            ModelSize::Small
402        ));
403    }
404
405    #[test]
406    fn test_model_size_is_multilingual() {
407        assert!(!ModelSize::TinyEn.is_multilingual());
408        assert!(!ModelSize::BaseEn.is_multilingual());
409        assert!(!ModelSize::SmallEn.is_multilingual());
410        assert!(ModelSize::Tiny.is_multilingual());
411        assert!(ModelSize::Base.is_multilingual());
412        assert!(ModelSize::Small.is_multilingual());
413    }
414
415    #[test]
416    fn test_model_size_fromstr_unknown_returns_error() {
417        let result = "large.en".parse::<ModelSize>();
418        assert!(result.is_err());
419        let err_msg = result.unwrap_err().to_string();
420        assert!(
421            err_msg.contains("large.en"),
422            "Error should mention the unknown value"
423        );
424    }
425
426    #[test]
427    fn test_get_data_dir_is_absolute() {
428        let dir = get_data_dir();
429        assert!(
430            dir.is_absolute(),
431            "data dir should be an absolute path: {:?}",
432            dir
433        );
434    }
435
436    #[test]
437    fn test_get_data_dir_ends_with_opencode_voice() {
438        let dir = get_data_dir();
439        let last_component = dir.file_name().unwrap().to_string_lossy();
440        assert_eq!(last_component, "opencode-voice");
441    }
442
443    /// Test AppConfig default field values by constructing a minimal struct literal.
444    /// This verifies the documented defaults: auto_submit=true, push_to_talk=true,
445    /// handle_prompts=true, use_global_hotkey=true.
446    #[test]
447    fn test_app_config_default_field_values() {
448        let config = AppConfig {
449            whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
450            opencode_port: 3000,
451            toggle_key: ' ',
452            model_size: ModelSize::TinyEn,
453            auto_submit: true,
454            server_password: None,
455            data_dir: std::path::PathBuf::from("/tmp"),
456            audio_device: None,
457            use_global_hotkey: true,
458            global_hotkey: "right_option".to_string(),
459            push_to_talk: true,
460            handle_prompts: true,
461            debug: false,
462        };
463
464        assert!(config.auto_submit, "auto_submit default should be true");
465        assert!(config.push_to_talk, "push_to_talk default should be true");
466        assert!(
467            config.handle_prompts,
468            "handle_prompts default should be true"
469        );
470        assert!(
471            config.use_global_hotkey,
472            "use_global_hotkey default should be true"
473        );
474        assert_eq!(config.toggle_key, ' ', "toggle_key default should be space");
475        assert_eq!(config.global_hotkey, "right_option");
476        assert!(config.server_password.is_none());
477        assert!(config.audio_device.is_none());
478    }
479
480    #[test]
481    fn test_app_config_opencode_port() {
482        let config = AppConfig {
483            whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
484            opencode_port: 8080,
485            toggle_key: ' ',
486            model_size: ModelSize::BaseEn,
487            auto_submit: true,
488            server_password: None,
489            data_dir: std::path::PathBuf::from("/tmp"),
490            audio_device: None,
491            use_global_hotkey: true,
492            global_hotkey: "right_option".to_string(),
493            push_to_talk: true,
494            handle_prompts: true,
495            debug: false,
496        };
497
498        assert_eq!(config.opencode_port, 8080);
499    }
500}