diff options
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 150 |
1 files changed, 148 insertions, 2 deletions
diff --git a/src/config.rs b/src/config.rs index d5417421..8733d5cc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crossterm::event::{KeyCode, KeyModifiers}; use serde::Deserialize; @@ -162,6 +162,15 @@ pub struct AdvancedConfig { #[serde(default)] pub struct SoundConfig { pub enabled: bool, + /// Optional mp3 file path used for all notification sounds. + /// Relative paths are resolved from the config file's directory. + pub path: Option<PathBuf>, + /// Optional mp3 file path for "done" notifications. + /// Relative paths are resolved from the config file's directory. + pub done_path: Option<PathBuf>, + /// Optional mp3 file path for "request" notifications. + /// Relative paths are resolved from the config file's directory. + pub request_path: Option<PathBuf>, pub agents: AgentSoundOverrides, } @@ -198,6 +207,57 @@ impl SoundConfig { !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) } + + pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> { + let path = match sound { + crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()), + crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()), + }?; + + Some(resolve_config_relative_path(path)) + } + + pub fn diagnostics(&self) -> Vec<String> { + let mut diagnostics = Vec::new(); + for (field, path) in [ + ("ui.sound.path", self.path.as_ref()), + ("ui.sound.done_path", self.done_path.as_ref()), + ("ui.sound.request_path", self.request_path.as_ref()), + ] { + let Some(path) = path else { + continue; + }; + + let resolved = resolve_config_relative_path(path); + if resolved + .extension() + .and_then(|ext| ext.to_str()) + .is_none_or(|ext| !ext.eq_ignore_ascii_case("mp3")) + { + diagnostics.push(format!( + "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound", + path.display(), + resolved.display() + )); + continue; + } + + if !resolved.exists() { + diagnostics.push(format!( + "missing sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } else if !resolved.is_file() { + diagnostics.push(format!( + "invalid sound file: {field} = {} resolves to {}; using default sound", + path.display(), + resolved.display() + )); + } + } + diagnostics + } } impl AgentSoundOverrides { @@ -269,6 +329,9 @@ impl Default for SoundConfig { fn default() -> Self { Self { enabled: true, + path: None, + done_path: None, + request_path: None, agents: AgentSoundOverrides::default(), } } @@ -343,9 +406,26 @@ impl Config { pub fn collect_diagnostics(&self) -> Vec<String> { let (prefix_diag, _, keybind_diags, _) = self.validated_keybinds(); - prefix_diag.into_iter().chain(keybind_diags).collect() + prefix_diag + .into_iter() + .chain(keybind_diags) + .chain(self.ui.sound.diagnostics()) + .collect() + } +} + +fn resolve_config_relative_path(path: &Path) -> PathBuf { + if path.is_absolute() { + return path.to_path_buf(); } + config_path() + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) +} + +impl Config { fn validated_keybinds( &self, ) -> ( @@ -1197,6 +1277,9 @@ rename_workspace = "g" let toml = r#" [ui.sound] enabled = true +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +request_path = "/tmp/request.mp3" [ui.sound.agents] droid = "off" @@ -1204,12 +1287,75 @@ claude = "on" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.ui.sound.enabled); + assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3"))); + assert_eq!( + config.ui.sound.done_path, + Some(PathBuf::from("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.request_path, + Some(PathBuf::from("/tmp/request.mp3")) + ); assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); } #[test] + fn sound_path_resolution_prefers_specific_over_global() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/all.mp3" +done_path = "sounds/done.mp3" +"#, + ) + .unwrap(); + + let config_root = config_path().parent().unwrap().to_path_buf(); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Done), + Some(config_root.join("sounds/done.mp3")) + ); + assert_eq!( + config.ui.sound.path_for(crate::sound::Sound::Request), + Some(config_root.join("sounds/all.mp3")) + ); + } + + #[test] + fn missing_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +done_path = "sounds/missing.mp3" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any( + |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound") + )); + } + + #[test] + fn non_mp3_sound_file_produces_diagnostic() { + let config: Config = toml::from_str( + r#" +[ui.sound] +path = "sounds/notification.wav" +"#, + ) + .unwrap(); + + let diagnostics = config.collect_diagnostics(); + assert!(diagnostics.iter().any(|diag| { + diag.contains("ui.sound.path") && diag.contains("expected an mp3 file") + })); + } + + #[test] fn advanced_allow_nested_parses() { let toml = r#" [advanced] |
