1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use std::path::PathBuf;
6
7#[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#[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 #[arg(long, short = 'p', global = true)]
56 pub port: Option<u16>,
57
58 #[arg(long, global = true)]
60 pub device: Option<String>,
61
62 #[arg(long, short = 'm', global = true)]
64 pub model: Option<ModelSize>,
65
66 #[arg(long, short = 'k', global = true)]
68 pub key: Option<char>,
69
70 #[arg(long, global = true)]
72 pub hotkey: Option<String>,
73
74 #[arg(long = "no-global", global = true)]
76 pub no_global: bool,
77
78 #[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 #[arg(long = "no-push-to-talk", global = true)]
88 pub no_push_to_talk: bool,
89
90 #[arg(long = "auto-submit", global = true, overrides_with = "no_auto_submit")]
92 pub auto_submit: bool,
93
94 #[arg(long = "no-auto-submit", global = true)]
96 pub no_auto_submit: bool,
97
98 #[arg(long = "approval", global = true, overrides_with = "no_approval")]
100 pub approval: bool,
101
102 #[arg(long = "no-approval", global = true)]
104 pub no_approval: bool,
105
106 #[arg(long, global = true)]
108 pub debug: bool,
109}
110
111#[derive(Subcommand, Debug)]
112pub enum Commands {
113 Run,
115 Setup {
117 #[arg(long, short = 'm')]
119 model: Option<ModelSize>,
120 },
121 Devices,
123 Keys,
125}
126
127#[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 pub fn load(cli: &CliArgs) -> Result<Self> {
149 let data_dir = get_data_dir();
150
151 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 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 let device_env = std::env::var("OPENCODE_VOICE_DEVICE").ok();
169 let audio_device = cli.device.clone().or(device_env);
170
171 let server_password = std::env::var("OPENCODE_SERVER_PASSWORD").ok();
173
174 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
221pub 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 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 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 #[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 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]
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}