From ec7d1a85b51d8b3bbdfe55cad15b50dd38a69196 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Tue, 2 Jun 2026 17:03:25 -0400 Subject: [PATCH] refactor(audio): extract pure parse_pw_nodes from device enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the pw-cli output parsing out of enumerate_audio_devices into a pure fn parse_pw_nodes(&str) -> Vec (with a push_device helper), leaving only the subprocess call in enumerate_audio_devices. Behavior- preserving — same id-block boundaries, Audio/* filter, Source=>input, description-falls-back-to-name, and sort-by-description. Creates a testable seam (the parsing had zero coverage). Build + clippy clean. Co-Authored-By: Claude Opus 4.8 --- src/audio/pw_cli.rs | 85 ++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/src/audio/pw_cli.rs b/src/audio/pw_cli.rs index 05778b0..725856b 100644 --- a/src/audio/pw_cli.rs +++ b/src/audio/pw_cli.rs @@ -14,52 +14,59 @@ impl std::fmt::Display for AudioDevice { } pub fn enumerate_audio_devices() -> Vec { - let mut devices = Vec::new(); let output = Command::new("pw-cli") .arg("list-objects") .arg("Node") .output(); - - if let Ok(out) = output { - let text = String::from_utf8_lossy(&out.stdout); - let mut current_name = String::new(); - let mut current_desc = String::new(); - let mut current_class = String::new(); - for line in text.lines() { - let line = line.trim(); - if line.starts_with("id ") { - // Save previous if valid - if !current_name.is_empty() && current_class.starts_with("Audio/") { - devices.push(AudioDevice { - name: current_name.clone(), - description: if current_desc.is_empty() { current_name.clone() } else { current_desc.clone() }, - is_input: current_class == "Audio/Source", - }); - } - current_name.clear(); - current_desc.clear(); - current_class.clear(); - } else if let Some(val) = line.strip_prefix("node.name = \"") { - current_name = val.trim_end_matches('"').to_string(); - } else if let Some(val) = line.strip_prefix("node.description = \"") { - current_desc = val.trim_end_matches('"').to_string(); - } else if let Some(val) = line.strip_prefix("media.class = \"") { - current_class = val.trim_end_matches('"').to_string(); - } - } - - // Final one - if !current_name.is_empty() && current_class.starts_with("Audio/") { - devices.push(AudioDevice { - name: current_name.clone(), - description: if current_desc.is_empty() { current_name } else { current_desc }, - is_input: current_class == "Audio/Source", - }); + match output { + Ok(out) => parse_pw_nodes(&String::from_utf8_lossy(&out.stdout)), + Err(_) => Vec::new(), + } +} + +/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* +/// node, then resets the accumulators for the next block. Non-audio or +/// incomplete blocks are dropped (but still reset). +fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec) { + if !name.is_empty() && class.starts_with("Audio/") { + out.push(AudioDevice { + name: name.clone(), + description: if desc.is_empty() { name.clone() } else { desc.clone() }, + is_input: class == "Audio/Source", + }); + } + name.clear(); + desc.clear(); + class.clear(); +} + +/// Parses the text of `pw-cli list-objects Node` into the audio devices we care +/// about. Each object is a block introduced by an `id N, ...` line; within a +/// block we collect `node.name` / `node.description` / `media.class`, and a +/// device is emitted at the next `id` (and at EOF) when the class is `Audio/*` +/// (`Audio/Source` => input). Description falls back to the node name when +/// absent. Returned sorted by description for stable UI display. +fn parse_pw_nodes(text: &str) -> Vec { + let mut devices = Vec::new(); + let mut current_name = String::new(); + let mut current_desc = String::new(); + let mut current_class = String::new(); + + for line in text.lines() { + let line = line.trim(); + if line.starts_with("id ") { + push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); + } else if let Some(val) = line.strip_prefix("node.name = \"") { + current_name = val.trim_end_matches('"').to_string(); + } else if let Some(val) = line.strip_prefix("node.description = \"") { + current_desc = val.trim_end_matches('"').to_string(); + } else if let Some(val) = line.strip_prefix("media.class = \"") { + current_class = val.trim_end_matches('"').to_string(); } } - - // Sort devices for consistent display + push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); + devices.sort_by(|a, b| a.description.cmp(&b.description)); devices }