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