use std::collections::BTreeSet; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DeviceCatalog { pub cameras: Vec, pub microphones: Vec, pub speakers: Vec, } impl DeviceCatalog { pub fn discover() -> Self { Self::discover_with_camera_override(std::env::var("LESAVKA_LAUNCHER_CAMERA_DIR").ok()) } pub fn is_empty(&self) -> bool { self.cameras.is_empty() && self.microphones.is_empty() && self.speakers.is_empty() } fn discover_with_camera_override(override_dir: Option) -> Self { let cameras = discover_camera_devices(override_dir); let microphones = discover_pactl_devices("sources"); let speakers = discover_pactl_devices("sinks"); Self { cameras, microphones, speakers, } } } fn discover_camera_devices(override_dir: Option) -> Vec { let dir = override_dir.unwrap_or_else(|| "/dev/v4l/by-id".to_string()); let Ok(iter) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut set = BTreeSet::new(); for entry in iter.flatten() { let path = entry.path(); if let Some(name) = path.file_name() { set.insert(name.to_string_lossy().to_string()); } } set.into_iter().collect() } fn discover_pactl_devices(kind: &str) -> Vec { let output = std::process::Command::new("pactl") .args(["list", "short", kind]) .output(); let Ok(output) = output else { return Vec::new(); }; if !output.status.success() { return Vec::new(); } parse_pactl_short(&String::from_utf8_lossy(&output.stdout)) } pub fn parse_pactl_short(stdout: &str) -> Vec { let mut set = BTreeSet::new(); for line in stdout.lines() { let mut cols = line.split_whitespace(); let _id = cols.next(); if let Some(name) = cols.next() { set.insert(name.to_string()); } } set.into_iter().collect() } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn mk_temp_dir(prefix: &str) -> PathBuf { let mut path = std::env::temp_dir(); let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); path.push(format!("lesavka-{prefix}-{}-{nanos}", std::process::id())); std::fs::create_dir_all(&path).expect("create temp dir"); path } #[test] fn parse_pactl_short_collects_second_column_and_sorts_unique() { let input = "0 alsa_input.usb.test module-x\n1 alsa_input.usb.test module-x\n2 alsa_input.pci module-y\n"; let parsed = parse_pactl_short(input); assert_eq!( parsed, vec![ "alsa_input.pci".to_string(), "alsa_input.usb.test".to_string(), ] ); } #[test] fn parse_pactl_short_ignores_blank_or_short_lines() { let input = "\nweird\n3\n4 sink.a\tmodule\n"; let parsed = parse_pactl_short(input); assert_eq!(parsed, vec!["sink.a".to_string()]); } #[test] fn camera_discovery_reads_entry_names_from_override_dir() { let tmp = mk_temp_dir("camera-discovery"); std::fs::write(tmp.join("usb-cam-a"), "").expect("write"); std::fs::write(tmp.join("usb-cam-b"), "").expect("write"); let devices = discover_camera_devices(Some(tmp.to_string_lossy().to_string())); assert_eq!(devices, vec!["usb-cam-a".to_string(), "usb-cam-b".to_string()]); let _ = std::fs::remove_dir_all(tmp); } #[test] fn camera_discovery_returns_empty_when_directory_missing() { let devices = discover_camera_devices(Some("/tmp/does-not-exist-lesavka".to_string())); assert!(devices.is_empty()); } #[test] fn camera_discovery_default_path_is_stable_without_overrides() { let _ = discover_camera_devices(None); } #[test] fn discover_uses_override_and_tolerates_missing_pactl() { let tmp = mk_temp_dir("discover-override"); std::fs::write(tmp.join("cam"), "").expect("write"); let catalog = DeviceCatalog::discover_with_camera_override(Some(tmp.to_string_lossy().to_string())); assert_eq!(catalog.cameras, vec!["cam".to_string()]); let _ = std::fs::remove_dir_all(tmp); } #[test] fn discover_is_stable_with_process_environment_defaults() { let _ = DeviceCatalog::discover(); } #[test] fn catalog_empty_reflects_collections() { let mut catalog = DeviceCatalog::default(); assert!(catalog.is_empty()); catalog.speakers.push("sink-1".to_string()); assert!(!catalog.is_empty()); } }