Skip to main content

flexaudio_core/
process_list.rs

1//! OS-independent post-processing for process enumeration results ([`ProcessInfo`]).
2//!
3//! Each OS backend returns a raw list as found (a PID may appear in multiple sessions or nodes,
4//! and names may be empty). The facade passes it through here to normalize it across all OSes:
5//!
6//! 1. Drop PID 0 and the excluded PID (the caller's process by default).
7//! 2. Merge entries with the same PID (active output wins if any entry is active).
8//! 3. Ensure the display name is non-empty (reported name → executable name → bundle ID → `pid <N>`).
9//! 4. Sort active entries first, then by display name (case-insensitive), then PID for stable results.
10
11use std::collections::BTreeMap;
12
13use crate::types::ProcessInfo;
14
15/// Extract the executable basename from a path (`/` and `\` are both separators).
16///
17/// Trailing separators are ignored. Returns `None` if the basename is empty.
18///
19/// ```
20/// use flexaudio_core::process_list::executable_basename;
21/// assert_eq!(executable_basename("/usr/bin/firefox").as_deref(), Some("firefox"));
22/// assert_eq!(executable_basename(r"C:\Program Files\App\app.exe").as_deref(), Some("app.exe"));
23/// assert_eq!(executable_basename(""), None);
24/// ```
25pub fn executable_basename(path: &str) -> Option<String> {
26    path.split(['/', '\\'])
27        .rev()
28        .map(str::trim)
29        .find(|segment| !segment.is_empty())
30        .map(str::to_string)
31}
32
33/// Merge output activity flags. `Some(true)` wins if either value is true.
34/// Otherwise, return `Some(false)` if either value is false, or `None` if both are unknown.
35fn merge_activity(a: Option<bool>, b: Option<bool>) -> Option<bool> {
36    match (a, b) {
37        (Some(true), _) | (_, Some(true)) => Some(true),
38        (Some(false), _) | (_, Some(false)) => Some(false),
39        (None, None) => None,
40    }
41}
42
43/// Convert a whitespace-only string to `None`.
44fn non_blank(value: Option<String>) -> Option<String> {
45    value.and_then(|v| {
46        let trimmed = v.trim();
47        if trimmed.is_empty() {
48            None
49        } else {
50            Some(trimmed.to_string())
51        }
52    })
53}
54
55/// Merge a duplicate PID into the first entry, filling empty fields without overwriting existing values.
56fn merge_into(kept: &mut ProcessInfo, other: ProcessInfo) {
57    if kept.name.trim().is_empty() {
58        kept.name = other.name;
59    }
60    if kept.executable.is_none() {
61        kept.executable = other.executable;
62    }
63    if kept.bundle_id.is_none() {
64        kept.bundle_id = other.bundle_id;
65    }
66    kept.is_output_active = merge_activity(kept.is_output_active, other.is_output_active);
67}
68
69/// Choose a display name (reported name → executable name → bundle ID → `pid <N>`).
70fn display_name(info: &ProcessInfo) -> String {
71    let own = info.name.trim();
72    if !own.is_empty() {
73        return own.to_string();
74    }
75    info.executable
76        .clone()
77        .or_else(|| info.bundle_id.clone())
78        .unwrap_or_else(|| format!("pid {}", info.pid))
79}
80
81/// Normalize the raw process list by merging duplicates, excluding PIDs, filling display names, and sorting.
82///
83/// Pass `Some(pid)` as `exclude_pid` to drop that PID (the facade passes the caller's own
84/// `std::process::id()`). PID 0 is always dropped; it means “not a process” on every OS and
85/// cannot be used as `target_pid`.
86pub fn normalize_process_list(raw: Vec<ProcessInfo>, exclude_pid: Option<u32>) -> Vec<ProcessInfo> {
87    let mut by_pid: BTreeMap<u32, ProcessInfo> = BTreeMap::new();
88    for mut info in raw {
89        if info.pid == 0 || Some(info.pid) == exclude_pid {
90            continue;
91        }
92        info.executable = non_blank(info.executable);
93        info.bundle_id = non_blank(info.bundle_id);
94        match by_pid.get_mut(&info.pid) {
95            Some(kept) => merge_into(kept, info),
96            None => {
97                by_pid.insert(info.pid, info);
98            }
99        }
100    }
101
102    let mut out: Vec<ProcessInfo> = by_pid
103        .into_values()
104        .map(|mut info| {
105            info.name = display_name(&info);
106            info
107        })
108        .collect();
109    // Put active entries (Some(true)) first, then sort deterministically by display name (case-insensitive) and PID.
110    out.sort_by(|a, b| {
111        let a_active = a.is_output_active == Some(true);
112        let b_active = b.is_output_active == Some(true);
113        b_active
114            .cmp(&a_active)
115            .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
116            .then_with(|| a.pid.cmp(&b.pid))
117    });
118    out
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn info(pid: u32, name: &str) -> ProcessInfo {
126        ProcessInfo {
127            pid,
128            name: name.to_string(),
129            executable: None,
130            bundle_id: None,
131            is_output_active: None,
132        }
133    }
134
135    #[test]
136    fn basename_handles_both_separators_and_edge_cases() {
137        assert_eq!(
138            executable_basename("/usr/lib/firefox/firefox").as_deref(),
139            Some("firefox")
140        );
141        assert_eq!(
142            executable_basename(r"C:\Windows\System32\svchost.exe").as_deref(),
143            Some("svchost.exe")
144        );
145        assert_eq!(executable_basename("plain").as_deref(), Some("plain"));
146        assert_eq!(
147            executable_basename("/trailing/slash/").as_deref(),
148            Some("slash")
149        );
150        assert_eq!(
151            executable_basename("/Applications/Music.app/Contents/MacOS/Music").as_deref(),
152            Some("Music")
153        );
154        assert_eq!(executable_basename(""), None);
155        assert_eq!(executable_basename("///"), None);
156    }
157
158    #[test]
159    fn drops_pid_zero_and_excluded_pid() {
160        let out = normalize_process_list(
161            vec![info(0, "idle"), info(42, "self"), info(7, "app")],
162            Some(42),
163        );
164        assert_eq!(out.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![7]);
165    }
166
167    #[test]
168    fn without_exclusion_keeps_every_nonzero_pid() {
169        let out = normalize_process_list(vec![info(42, "self"), info(7, "app")], None);
170        assert_eq!(out.len(), 2);
171    }
172
173    #[test]
174    fn duplicates_merge_activity_and_fill_blanks() {
175        let a = ProcessInfo {
176            is_output_active: Some(false),
177            ..info(5, "")
178        };
179        let b = ProcessInfo {
180            executable: Some("player".into()),
181            is_output_active: Some(true),
182            ..info(5, "Player")
183        };
184        let c = ProcessInfo {
185            bundle_id: Some("com.example.player".into()),
186            ..info(5, "Other name")
187        };
188        let out = normalize_process_list(vec![a, b, c], None);
189        assert_eq!(out.len(), 1);
190        let p = &out[0];
191        assert_eq!(p.name, "Player", "the first non-empty name wins");
192        assert_eq!(p.executable.as_deref(), Some("player"));
193        assert_eq!(p.bundle_id.as_deref(), Some("com.example.player"));
194        assert_eq!(
195            p.is_output_active,
196            Some(true),
197            "any active session makes the process active"
198        );
199    }
200
201    #[test]
202    fn merge_activity_truth_table() {
203        assert_eq!(merge_activity(None, None), None);
204        assert_eq!(merge_activity(Some(false), None), Some(false));
205        assert_eq!(merge_activity(None, Some(false)), Some(false));
206        assert_eq!(merge_activity(Some(false), Some(true)), Some(true));
207        assert_eq!(merge_activity(Some(true), None), Some(true));
208    }
209
210    #[test]
211    fn display_name_falls_back_in_order() {
212        let exe = ProcessInfo {
213            executable: Some("chrome.exe".into()),
214            bundle_id: Some("com.google.Chrome".into()),
215            ..info(1, "  ")
216        };
217        let bundle = ProcessInfo {
218            bundle_id: Some("com.apple.Music".into()),
219            ..info(2, "")
220        };
221        let bare = info(3, "");
222        let blank_fields = ProcessInfo {
223            executable: Some("   ".into()),
224            bundle_id: Some(String::new()),
225            ..info(4, "")
226        };
227        let out = normalize_process_list(vec![exe, bundle, bare, blank_fields], None);
228        let names: Vec<(u32, &str)> = out.iter().map(|p| (p.pid, p.name.as_str())).collect();
229        assert!(names.contains(&(1, "chrome.exe")));
230        assert!(names.contains(&(2, "com.apple.Music")));
231        assert!(names.contains(&(3, "pid 3")));
232        assert!(
233            names.contains(&(4, "pid 4")),
234            "blank executable/bundle are treated as absent"
235        );
236        let blank = out.iter().find(|p| p.pid == 4).unwrap();
237        assert_eq!(blank.executable, None);
238        assert_eq!(blank.bundle_id, None);
239        assert!(out.iter().all(|p| !p.name.trim().is_empty()));
240    }
241
242    #[test]
243    fn sorts_active_first_then_name_then_pid() {
244        let out = normalize_process_list(
245            vec![
246                info(30, "beta"),
247                ProcessInfo {
248                    is_output_active: Some(true),
249                    ..info(20, "zeta")
250                },
251                info(11, "Alpha"),
252                info(10, "alpha"),
253                ProcessInfo {
254                    is_output_active: Some(false),
255                    ..info(40, "gamma")
256                },
257            ],
258            None,
259        );
260        let order: Vec<u32> = out.iter().map(|p| p.pid).collect();
261        assert_eq!(order, vec![20, 10, 11, 30, 40]);
262    }
263
264    #[test]
265    fn normalization_is_idempotent() {
266        let raw = vec![
267            info(3, ""),
268            ProcessInfo {
269                is_output_active: Some(true),
270                ..info(9, "music")
271            },
272            info(3, "late name"),
273        ];
274        let once = normalize_process_list(raw, None);
275        let twice = normalize_process_list(once.clone(), None);
276        assert_eq!(once, twice);
277    }
278}