flexaudio-core 0.5.1

Source-agnostic audio capture engine, types, and resampling/normalization core for flexaudio.
Documentation
//! OS-independent post-processing for process enumeration results ([`ProcessInfo`]).
//!
//! Each OS backend returns a raw list as found (a PID may appear in multiple sessions or nodes,
//! and names may be empty). The facade passes it through here to normalize it across all OSes:
//!
//! 1. Drop PID 0 and the excluded PID (the caller's process by default).
//! 2. Merge entries with the same PID (active output wins if any entry is active).
//! 3. Ensure the display name is non-empty (reported name → executable name → bundle ID → `pid <N>`).
//! 4. Sort active entries first, then by display name (case-insensitive), then PID for stable results.

use std::collections::BTreeMap;

use crate::types::ProcessInfo;

/// Extract the executable basename from a path (`/` and `\` are both separators).
///
/// Trailing separators are ignored. Returns `None` if the basename is empty.
///
/// ```
/// use flexaudio_core::process_list::executable_basename;
/// assert_eq!(executable_basename("/usr/bin/firefox").as_deref(), Some("firefox"));
/// assert_eq!(executable_basename(r"C:\Program Files\App\app.exe").as_deref(), Some("app.exe"));
/// assert_eq!(executable_basename(""), None);
/// ```
pub fn executable_basename(path: &str) -> Option<String> {
    path.split(['/', '\\'])
        .rev()
        .map(str::trim)
        .find(|segment| !segment.is_empty())
        .map(str::to_string)
}

/// Merge output activity flags. `Some(true)` wins if either value is true.
/// Otherwise, return `Some(false)` if either value is false, or `None` if both are unknown.
fn merge_activity(a: Option<bool>, b: Option<bool>) -> Option<bool> {
    match (a, b) {
        (Some(true), _) | (_, Some(true)) => Some(true),
        (Some(false), _) | (_, Some(false)) => Some(false),
        (None, None) => None,
    }
}

/// Convert a whitespace-only string to `None`.
fn non_blank(value: Option<String>) -> Option<String> {
    value.and_then(|v| {
        let trimmed = v.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    })
}

/// Merge a duplicate PID into the first entry, filling empty fields without overwriting existing values.
fn merge_into(kept: &mut ProcessInfo, other: ProcessInfo) {
    if kept.name.trim().is_empty() {
        kept.name = other.name;
    }
    if kept.executable.is_none() {
        kept.executable = other.executable;
    }
    if kept.bundle_id.is_none() {
        kept.bundle_id = other.bundle_id;
    }
    kept.is_output_active = merge_activity(kept.is_output_active, other.is_output_active);
}

/// Choose a display name (reported name → executable name → bundle ID → `pid <N>`).
fn display_name(info: &ProcessInfo) -> String {
    let own = info.name.trim();
    if !own.is_empty() {
        return own.to_string();
    }
    info.executable
        .clone()
        .or_else(|| info.bundle_id.clone())
        .unwrap_or_else(|| format!("pid {}", info.pid))
}

/// Normalize the raw process list by merging duplicates, excluding PIDs, filling display names, and sorting.
///
/// Pass `Some(pid)` as `exclude_pid` to drop that PID (the facade passes the caller's own
/// `std::process::id()`). PID 0 is always dropped; it means “not a process” on every OS and
/// cannot be used as `target_pid`.
pub fn normalize_process_list(raw: Vec<ProcessInfo>, exclude_pid: Option<u32>) -> Vec<ProcessInfo> {
    let mut by_pid: BTreeMap<u32, ProcessInfo> = BTreeMap::new();
    for mut info in raw {
        if info.pid == 0 || Some(info.pid) == exclude_pid {
            continue;
        }
        info.executable = non_blank(info.executable);
        info.bundle_id = non_blank(info.bundle_id);
        match by_pid.get_mut(&info.pid) {
            Some(kept) => merge_into(kept, info),
            None => {
                by_pid.insert(info.pid, info);
            }
        }
    }

    let mut out: Vec<ProcessInfo> = by_pid
        .into_values()
        .map(|mut info| {
            info.name = display_name(&info);
            info
        })
        .collect();
    // Put active entries (Some(true)) first, then sort deterministically by display name (case-insensitive) and PID.
    out.sort_by(|a, b| {
        let a_active = a.is_output_active == Some(true);
        let b_active = b.is_output_active == Some(true);
        b_active
            .cmp(&a_active)
            .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
            .then_with(|| a.pid.cmp(&b.pid))
    });
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn info(pid: u32, name: &str) -> ProcessInfo {
        ProcessInfo {
            pid,
            name: name.to_string(),
            executable: None,
            bundle_id: None,
            is_output_active: None,
        }
    }

    #[test]
    fn basename_handles_both_separators_and_edge_cases() {
        assert_eq!(
            executable_basename("/usr/lib/firefox/firefox").as_deref(),
            Some("firefox")
        );
        assert_eq!(
            executable_basename(r"C:\Windows\System32\svchost.exe").as_deref(),
            Some("svchost.exe")
        );
        assert_eq!(executable_basename("plain").as_deref(), Some("plain"));
        assert_eq!(
            executable_basename("/trailing/slash/").as_deref(),
            Some("slash")
        );
        assert_eq!(
            executable_basename("/Applications/Music.app/Contents/MacOS/Music").as_deref(),
            Some("Music")
        );
        assert_eq!(executable_basename(""), None);
        assert_eq!(executable_basename("///"), None);
    }

    #[test]
    fn drops_pid_zero_and_excluded_pid() {
        let out = normalize_process_list(
            vec![info(0, "idle"), info(42, "self"), info(7, "app")],
            Some(42),
        );
        assert_eq!(out.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![7]);
    }

    #[test]
    fn without_exclusion_keeps_every_nonzero_pid() {
        let out = normalize_process_list(vec![info(42, "self"), info(7, "app")], None);
        assert_eq!(out.len(), 2);
    }

    #[test]
    fn duplicates_merge_activity_and_fill_blanks() {
        let a = ProcessInfo {
            is_output_active: Some(false),
            ..info(5, "")
        };
        let b = ProcessInfo {
            executable: Some("player".into()),
            is_output_active: Some(true),
            ..info(5, "Player")
        };
        let c = ProcessInfo {
            bundle_id: Some("com.example.player".into()),
            ..info(5, "Other name")
        };
        let out = normalize_process_list(vec![a, b, c], None);
        assert_eq!(out.len(), 1);
        let p = &out[0];
        assert_eq!(p.name, "Player", "the first non-empty name wins");
        assert_eq!(p.executable.as_deref(), Some("player"));
        assert_eq!(p.bundle_id.as_deref(), Some("com.example.player"));
        assert_eq!(
            p.is_output_active,
            Some(true),
            "any active session makes the process active"
        );
    }

    #[test]
    fn merge_activity_truth_table() {
        assert_eq!(merge_activity(None, None), None);
        assert_eq!(merge_activity(Some(false), None), Some(false));
        assert_eq!(merge_activity(None, Some(false)), Some(false));
        assert_eq!(merge_activity(Some(false), Some(true)), Some(true));
        assert_eq!(merge_activity(Some(true), None), Some(true));
    }

    #[test]
    fn display_name_falls_back_in_order() {
        let exe = ProcessInfo {
            executable: Some("chrome.exe".into()),
            bundle_id: Some("com.google.Chrome".into()),
            ..info(1, "  ")
        };
        let bundle = ProcessInfo {
            bundle_id: Some("com.apple.Music".into()),
            ..info(2, "")
        };
        let bare = info(3, "");
        let blank_fields = ProcessInfo {
            executable: Some("   ".into()),
            bundle_id: Some(String::new()),
            ..info(4, "")
        };
        let out = normalize_process_list(vec![exe, bundle, bare, blank_fields], None);
        let names: Vec<(u32, &str)> = out.iter().map(|p| (p.pid, p.name.as_str())).collect();
        assert!(names.contains(&(1, "chrome.exe")));
        assert!(names.contains(&(2, "com.apple.Music")));
        assert!(names.contains(&(3, "pid 3")));
        assert!(
            names.contains(&(4, "pid 4")),
            "blank executable/bundle are treated as absent"
        );
        let blank = out.iter().find(|p| p.pid == 4).unwrap();
        assert_eq!(blank.executable, None);
        assert_eq!(blank.bundle_id, None);
        assert!(out.iter().all(|p| !p.name.trim().is_empty()));
    }

    #[test]
    fn sorts_active_first_then_name_then_pid() {
        let out = normalize_process_list(
            vec![
                info(30, "beta"),
                ProcessInfo {
                    is_output_active: Some(true),
                    ..info(20, "zeta")
                },
                info(11, "Alpha"),
                info(10, "alpha"),
                ProcessInfo {
                    is_output_active: Some(false),
                    ..info(40, "gamma")
                },
            ],
            None,
        );
        let order: Vec<u32> = out.iter().map(|p| p.pid).collect();
        assert_eq!(order, vec![20, 10, 11, 30, 40]);
    }

    #[test]
    fn normalization_is_idempotent() {
        let raw = vec![
            info(3, ""),
            ProcessInfo {
                is_output_active: Some(true),
                ..info(9, "music")
            },
            info(3, "late name"),
        ];
        let once = normalize_process_list(raw, None);
        let twice = normalize_process_list(once.clone(), None);
        assert_eq!(once, twice);
    }
}