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
107#[derive(Subcommand, Debug)]
108pub enum Commands {
109 Run,
111 Setup {
113 #[arg(long, short = 'm')]
115 model: Option<ModelSize>,
116 },
117 Devices,
119 Keys,
121}
122
123#[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 pub fn load(cli: &CliArgs) -> Result<Self> {
144 let data_dir = get_data_dir();
145
146 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 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 let device_env = std::env::var("OPENCODE_VOICE_DEVICE").ok();
163 let audio_device = cli.device.clone().or(device_env);
164
165 let server_password = std::env::var("OPENCODE_SERVER_PASSWORD").ok();
167
168 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
214pub 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 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 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 #[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 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]
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}