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
107#[derive(Subcommand, Debug)]
108pub enum Commands {
109    /// Run the voice mode (default)
110    Run,
111    /// Download and set up the whisper model
112    Setup {
113        /// Model size to download (tiny.en, base.en, small.en)
114        #[arg(long, short = 'm')]
115        model: Option<ModelSize>,
116    },
117    /// List available audio input devices
118    Devices,
119    /// List available key names for hotkey configuration
120    Keys,
121}
122
123/// Resolved application configuration.
124#[derive(Debug, Clone)]
125pub struct AppConfig {
126    pub whisper_model_path: PathBuf,
127    pub opencode_port: u16,
128    pub toggle_key: char,
129    pub model_size: ModelSize,
130    pub auto_submit: bool,
131    pub server_password: Option<String>,
132    pub data_dir: PathBuf,
133    pub audio_device: Option<String>,
134    pub use_global_hotkey: bool,
135    pub global_hotkey: String,
136    pub push_to_talk: bool,
137    pub approval_mode: bool,
138}
139
140impl AppConfig {
141    /// Load configuration from CLI args + environment variables + defaults.
142    /// Precedence: CLI flags > env vars > defaults.
143    pub fn load(cli: &CliArgs) -> Result<Self> {
144        let data_dir = get_data_dir();
145
146        // Port: CLI > env var > error
147        let port_env = std::env::var("OPENCODE_VOICE_PORT")
148            .ok()
149            .and_then(|s| s.parse::<u16>().ok());
150        let port = cli
151            .port
152            .or(port_env)
153            .context("OpenCode server port is required. Use --port or set OPENCODE_VOICE_PORT")?;
154
155        // Model: CLI > env var > default
156        let model_env = std::env::var("OPENCODE_VOICE_MODEL")
157            .ok()
158            .and_then(|s| s.parse::<ModelSize>().ok());
159        let model_size = cli.model.clone().or(model_env).unwrap_or_default();
160
161        // Device: CLI > env var
162        let device_env = std::env::var("OPENCODE_VOICE_DEVICE").ok();
163        let audio_device = cli.device.clone().or(device_env);
164
165        // Password: env var only
166        let server_password = std::env::var("OPENCODE_SERVER_PASSWORD").ok();
167
168        // Boolean flags: explicit overrides, then defaults
169        let auto_submit = if cli.no_auto_submit {
170            false
171        } else if cli.auto_submit {
172            true
173        } else {
174            true
175        };
176        let push_to_talk = if cli.no_push_to_talk {
177            false
178        } else if cli.push_to_talk {
179            true
180        } else {
181            true
182        };
183        let use_global_hotkey = !cli.no_global;
184        let approval_mode = if cli.no_approval {
185            false
186        } else if cli.approval {
187            true
188        } else {
189            true
190        };
191
192        let whisper_model_path = crate::transcribe::setup::get_model_path(&data_dir, &model_size);
193
194        Ok(AppConfig {
195            opencode_port: port,
196            toggle_key: cli.key.unwrap_or(' '),
197            model_size,
198            auto_submit,
199            server_password,
200            data_dir,
201            audio_device,
202            use_global_hotkey,
203            global_hotkey: cli
204                .hotkey
205                .clone()
206                .unwrap_or_else(|| "right_option".to_string()),
207            push_to_talk,
208            approval_mode,
209            whisper_model_path,
210        })
211    }
212}
213
214/// Returns the platform-appropriate data directory for opencode-voice.
215///
216/// - macOS: ~/Library/Application Support/opencode-voice/
217/// - Linux: $XDG_DATA_HOME/opencode-voice/ or ~/.local/share/opencode-voice/
218pub fn get_data_dir() -> PathBuf {
219    #[cfg(target_os = "macos")]
220    {
221        dirs::data_dir()
222            .unwrap_or_else(|| {
223                dirs::home_dir()
224                    .unwrap_or_else(|| PathBuf::from("."))
225                    .join("Library")
226                    .join("Application Support")
227            })
228            .join("opencode-voice")
229    }
230    #[cfg(not(target_os = "macos"))]
231    {
232        // Linux: XDG_DATA_HOME or ~/.local/share
233        std::env::var("XDG_DATA_HOME")
234            .map(PathBuf::from)
235            .unwrap_or_else(|_| {
236                dirs::home_dir()
237                    .unwrap_or_else(|| PathBuf::from("."))
238                    .join(".local")
239                    .join("share")
240            })
241            .join("opencode-voice")
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_model_size_display() {
251        assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
252        assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
253        assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
254    }
255
256    #[test]
257    fn test_model_size_from_str() {
258        assert!(matches!(
259            "tiny.en".parse::<ModelSize>().unwrap(),
260            ModelSize::TinyEn
261        ));
262        assert!(matches!(
263            "tiny".parse::<ModelSize>().unwrap(),
264            ModelSize::TinyEn
265        ));
266        assert!(matches!(
267            "base.en".parse::<ModelSize>().unwrap(),
268            ModelSize::BaseEn
269        ));
270        assert!(matches!(
271            "base".parse::<ModelSize>().unwrap(),
272            ModelSize::BaseEn
273        ));
274        assert!(matches!(
275            "small.en".parse::<ModelSize>().unwrap(),
276            ModelSize::SmallEn
277        ));
278        assert!(matches!(
279            "small".parse::<ModelSize>().unwrap(),
280            ModelSize::SmallEn
281        ));
282    }
283
284    #[test]
285    fn test_model_size_from_str_invalid() {
286        assert!("large".parse::<ModelSize>().is_err());
287        assert!("medium.en".parse::<ModelSize>().is_err());
288    }
289
290    #[test]
291    fn test_model_size_default() {
292        assert!(matches!(ModelSize::default(), ModelSize::BaseEn));
293    }
294
295    #[test]
296    fn test_get_data_dir_contains_app_name() {
297        let dir = get_data_dir();
298        let dir_str = dir.to_string_lossy();
299        assert!(
300            dir_str.contains("opencode-voice"),
301            "data dir should contain 'opencode-voice': {}",
302            dir_str
303        );
304    }
305
306    #[cfg(target_os = "macos")]
307    #[test]
308    fn test_get_data_dir_macos() {
309        let dir = get_data_dir();
310        let dir_str = dir.to_string_lossy();
311        // On macOS should be under Library/Application Support
312        assert!(
313            dir_str.contains("Library/Application Support"),
314            "macOS data dir should be under Library/Application Support: {}",
315            dir_str
316        );
317    }
318
319    // --- Additional tests added to expand coverage ---
320
321    #[test]
322    fn test_model_size_display_tiny_en() {
323        assert_eq!(ModelSize::TinyEn.to_string(), "tiny.en");
324    }
325
326    #[test]
327    fn test_model_size_display_base_en() {
328        assert_eq!(ModelSize::BaseEn.to_string(), "base.en");
329    }
330
331    #[test]
332    fn test_model_size_display_small_en() {
333        assert_eq!(ModelSize::SmallEn.to_string(), "small.en");
334    }
335
336    #[test]
337    fn test_model_size_fromstr_roundtrip_tiny() {
338        let s = ModelSize::TinyEn.to_string();
339        let parsed: ModelSize = s.parse().unwrap();
340        assert!(matches!(parsed, ModelSize::TinyEn));
341    }
342
343    #[test]
344    fn test_model_size_fromstr_roundtrip_base() {
345        let s = ModelSize::BaseEn.to_string();
346        let parsed: ModelSize = s.parse().unwrap();
347        assert!(matches!(parsed, ModelSize::BaseEn));
348    }
349
350    #[test]
351    fn test_model_size_fromstr_roundtrip_small() {
352        let s = ModelSize::SmallEn.to_string();
353        let parsed: ModelSize = s.parse().unwrap();
354        assert!(matches!(parsed, ModelSize::SmallEn));
355    }
356
357    #[test]
358    fn test_model_size_fromstr_short_aliases() {
359        // "tiny", "base", "small" are also valid aliases
360        assert!(matches!(
361            "tiny".parse::<ModelSize>().unwrap(),
362            ModelSize::TinyEn
363        ));
364        assert!(matches!(
365            "base".parse::<ModelSize>().unwrap(),
366            ModelSize::BaseEn
367        ));
368        assert!(matches!(
369            "small".parse::<ModelSize>().unwrap(),
370            ModelSize::SmallEn
371        ));
372    }
373
374    #[test]
375    fn test_model_size_fromstr_unknown_returns_error() {
376        let result = "large.en".parse::<ModelSize>();
377        assert!(result.is_err());
378        let err_msg = result.unwrap_err().to_string();
379        assert!(
380            err_msg.contains("large.en"),
381            "Error should mention the unknown value"
382        );
383    }
384
385    #[test]
386    fn test_get_data_dir_is_absolute() {
387        let dir = get_data_dir();
388        assert!(
389            dir.is_absolute(),
390            "data dir should be an absolute path: {:?}",
391            dir
392        );
393    }
394
395    #[test]
396    fn test_get_data_dir_ends_with_opencode_voice() {
397        let dir = get_data_dir();
398        let last_component = dir.file_name().unwrap().to_string_lossy();
399        assert_eq!(last_component, "opencode-voice");
400    }
401
402    /// Test AppConfig default field values by constructing a minimal struct literal.
403    /// This verifies the documented defaults: auto_submit=true, push_to_talk=true,
404    /// approval_mode=true, vad_mode=false, use_global_hotkey=true.
405    #[test]
406    fn test_app_config_default_field_values() {
407        let config = AppConfig {
408            whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
409            opencode_port: 3000,
410            toggle_key: ' ',
411            model_size: ModelSize::TinyEn,
412            auto_submit: true,
413            server_password: None,
414            data_dir: std::path::PathBuf::from("/tmp"),
415            audio_device: None,
416            use_global_hotkey: true,
417            global_hotkey: "right_option".to_string(),
418            push_to_talk: true,
419            approval_mode: true,
420        };
421
422        assert!(config.auto_submit, "auto_submit default should be true");
423        assert!(config.push_to_talk, "push_to_talk default should be true");
424        assert!(config.approval_mode, "approval_mode default should be true");
425        assert!(
426            config.use_global_hotkey,
427            "use_global_hotkey default should be true"
428        );
429        assert_eq!(config.toggle_key, ' ', "toggle_key default should be space");
430        assert_eq!(config.global_hotkey, "right_option");
431        assert!(config.server_password.is_none());
432        assert!(config.audio_device.is_none());
433    }
434
435    #[test]
436    fn test_app_config_opencode_port() {
437        let config = AppConfig {
438            whisper_model_path: std::path::PathBuf::from("/tmp/model.bin"),
439            opencode_port: 8080,
440            toggle_key: ' ',
441            model_size: ModelSize::BaseEn,
442            auto_submit: true,
443            server_password: None,
444            data_dir: std::path::PathBuf::from("/tmp"),
445            audio_device: None,
446            use_global_hotkey: true,
447            global_hotkey: "right_option".to_string(),
448            push_to_talk: true,
449            approval_mode: true,
450        };
451
452        assert_eq!(config.opencode_port, 8080);
453    }
454}