1use std::collections::BTreeMap;
12
13use crate::types::ProcessInfo;
14
15pub 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
33fn 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
43fn 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
55fn 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
69fn 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
81pub 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 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}