Skip to main content

flexaudio_core/
inventory.rs

1//! Shared discovery policy: collection failures are terminal; entry metadata is best effort.
2use crate::{Error, ErrorContext, ErrorGroup, NativeStatus, Operation, Result};
3
4/// Collect an inventory; a collection/count failure never becomes an empty success.
5/// Entries without a usable identity return Ok(None); optional metadata and format
6/// failures are absorbed explicitly by the adapter. Err always rejects the whole call.
7pub fn collect<I, T>(
8    ids: Result<impl IntoIterator<Item = I>>,
9    mut read: impl FnMut(I) -> Result<Option<T>>,
10) -> Result<Vec<T>> {
11    let mut out = Vec::new();
12    for id in ids? {
13        if let Some(entry) = read(id)? {
14            out.push(entry);
15        }
16    }
17    Ok(out)
18}
19
20/// Keep name-based IDs when readable; otherwise use a selectable native ID and unknown name.
21/// Entries with neither identity are omitted. The native query is lazy for named devices.
22pub fn device_identity(
23    name: Result<String>,
24    native_id: impl FnOnce() -> Result<String>,
25) -> Option<(String, String)> {
26    if let Ok(name) = name {
27        if !name.is_empty() {
28            return Some((name.clone(), name));
29        }
30    }
31    native_id()
32        .ok()
33        .filter(|id| !id.is_empty())
34        .map(|id| (id, String::new()))
35}
36
37/// Match either a name-based inventory ID or a native fallback, even after the name recovers.
38pub fn device_identity_matches(
39    requested_id: &str,
40    name: Result<String>,
41    native_id: impl FnOnce() -> Result<String>,
42) -> bool {
43    if name.is_ok_and(|name| !name.is_empty() && name == requested_id) {
44        return true;
45    }
46    native_id().is_ok_and(|id| !id.is_empty() && id == requested_id)
47}
48
49/// Validate the selected or default output device before device-scoped capture starts.
50pub fn selected_output_device<'a>(
51    devices: &'a [crate::DeviceInfo],
52    requested_id: Option<&str>,
53) -> Result<&'a crate::DeviceInfo> {
54    let device = devices
55        .iter()
56        .find(|device| {
57            device.is_loopback && requested_id.map_or(device.is_default, |id| device.id == id)
58        })
59        .ok_or(Error::DeviceNotFound)?;
60    require_device_format(if device.format_available {
61        Ok((device.sample_rate, device.channels))
62    } else {
63        Err(Error::Backend(
64            "selected output device format is unavailable".into(),
65        ))
66    })?;
67    Ok(device)
68}
69
70/// Discovery format and its availability. Zero/zero is the explicit unavailable sentinel.
71pub fn device_format(format: Result<(u32, u16)>) -> (u32, u16, bool) {
72    match format {
73        Ok((rate, channels)) if rate > 0 && channels > 0 => (rate, channels, true),
74        _ => (0, 0, false),
75    }
76}
77
78/// Checked opening retains the original typed cause and explains how to retry.
79pub fn require_device_format(format: Result<(u32, u16)>) -> Result<(u32, u16)> {
80    let format = format.and_then(|format| {
81        if format.0 == 0 || format.1 == 0 {
82            Err(Error::UnsupportedFormat(
83                "native device format contains a zero rate or channel count".into(),
84            ))
85        } else {
86            Ok(format)
87        }
88    });
89    format.map_err(|cause| Error::Multiple(ErrorGroup::new(
90        Error::Backend("device capture format could not be read; reconnect the device and retry discovery/open".into())
91            .with_context(ErrorContext::new(Operation::Start)),
92        cause,
93        Vec::new(),
94    )))
95}
96
97/// Optional property failures become explicit unknown values.
98pub fn optional<T>(value: Result<Option<T>>) -> Option<T> {
99    value.ok().flatten()
100}
101
102/// Native process disappearance, distinguished from unreadable optional metadata.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum ProcessFailure {
105    /// The process/object disappeared; its entry may be omitted.
106    Vanished,
107    /// The process remains a candidate but its name/path is unknown.
108    UnknownMetadata,
109}
110
111/// Windows: ERROR_INVALID_PARAMETER (87, HRESULT 0x80070057) means vanished.
112/// ERROR_ACCESS_DENIED (5, 0x80070005), and all other metadata errors, mean unknown.
113pub fn windows_process_failure(bits: u32) -> ProcessFailure {
114    if bits == 0x8007_0057 {
115        ProcessFailure::Vanished
116    } else {
117        ProcessFailure::UnknownMetadata
118    }
119}
120
121/// macOS: kAudioHardwareBadObjectError ('!obj', 0x216f626a), ESRCH (3),
122/// and ENOENT (2) mean vanished. Other property/path errors mean unknown metadata.
123pub fn macos_process_failure(code: i32) -> ProcessFailure {
124    if matches!(code, 0x216f_626a | 3 | 2) {
125        ProcessFailure::Vanished
126    } else {
127        ProcessFailure::UnknownMetadata
128    }
129}
130
131/// Classify a Windows process failure from its retained native HRESULT.
132pub fn windows_error_failure(error: &Error) -> ProcessFailure {
133    match error {
134        Error::Context { source, context } => match context.native_status() {
135            Some(NativeStatus::HResult { bits, .. }) => windows_process_failure(bits),
136            _ => windows_error_failure(source),
137        },
138        Error::Diagnostic { source, .. } => windows_error_failure(source),
139        Error::Multiple(group) => windows_error_failure(group.primary()),
140        _ => ProcessFailure::UnknownMetadata,
141    }
142}
143
144/// Interpret an optional process query: outer None means vanished; inner None means unknown.
145/// Used by adapters before the inventory collector, so unknown metadata keeps its PID.
146pub fn process_metadata<T>(
147    value: Result<Option<T>>,
148    classify: impl FnOnce(&Error) -> ProcessFailure,
149) -> Option<Option<T>> {
150    match value {
151        Ok(value) => Some(value),
152        Err(error) if classify(&error) == ProcessFailure::Vanished => None,
153        Err(_) => Some(None),
154    }
155}
156
157/// Classify a Core Audio property failure while retaining structured native status.
158pub fn macos_error_failure(error: &Error) -> ProcessFailure {
159    match error {
160        Error::Context { source, context } => match context.native_status() {
161            Some(NativeStatus::OsStatus { value, .. }) => macos_process_failure(value),
162            _ => macos_error_failure(source),
163        },
164        Error::Diagnostic { source, .. } => macos_error_failure(source),
165        Error::Multiple(group) => macos_error_failure(group.primary()),
166        _ => ProcessFailure::UnknownMetadata,
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    fn fault(code: i32) -> Error {
174        Error::Backend("fixture failed; retry".into()).with_context(
175            ErrorContext::new(Operation::Enumerate).with_native_status(NativeStatus::OsStatus {
176                call: "fixture",
177                value: code,
178            }),
179        )
180    }
181    // Concrete OS property/default/activation failures require native endpoints.
182    // Inject their typed results through the same policy helpers used by the adapters.
183    #[test]
184    fn unreadable_device_name_keeps_selectable_native_identity() {
185        assert_eq!(
186            device_identity(Err(fault(-50)), || Ok("native-id".into())),
187            Some(("native-id".into(), String::new()))
188        );
189        assert_eq!(
190            device_identity(Ok(String::new()), || Ok("native-id".into())),
191            Some(("native-id".into(), String::new()))
192        );
193        assert_eq!(device_identity(Err(fault(-50)), || Err(fault(-1))), None);
194        assert_eq!(
195            device_identity(Ok("name".into()), || panic!("unnecessary native query")),
196            Some(("name".into(), "name".into()))
197        );
198    }
199
200    #[test]
201    fn native_identity_remains_selectable_after_display_name_recovers() {
202        assert!(device_identity_matches(
203            "native-id",
204            Ok("recovered name".into()),
205            || Ok("native-id".into())
206        ));
207        assert!(device_identity_matches(
208            "recovered name",
209            Ok("recovered name".into()),
210            || panic!("unnecessary native query")
211        ));
212        assert!(!device_identity_matches(
213            "missing",
214            Err(fault(-50)),
215            || Err(fault(-1))
216        ));
217    }
218
219    #[test]
220    fn default_output_open_rejects_unavailable_format_and_keeps_cause() {
221        let device = crate::DeviceInfo {
222            id: "fixture".into(),
223            name: "fixture".into(),
224            source_kind: crate::SourceKind::SystemLoopback,
225            sample_rate: 0,
226            channels: 0,
227            format_available: false,
228            is_loopback: true,
229            is_default: true,
230        };
231        let error = selected_output_device(std::slice::from_ref(&device), None).unwrap_err();
232        assert!(error.to_string().contains("could not be read"));
233        assert_eq!(error.kind(), crate::ErrorKind::Backend);
234        assert!(matches!(error, Error::Multiple(_)));
235        assert!(selected_output_device(std::slice::from_ref(&device), Some("fixture")).is_err());
236        let available = crate::DeviceInfo {
237            sample_rate: 44_100,
238            channels: 6,
239            format_available: true,
240            ..device
241        };
242        assert_eq!(
243            selected_output_device(&[available], None)
244                .map(|device| (device.sample_rate, device.channels)),
245            Ok((44_100, 6))
246        );
247        assert_eq!(
248            selected_output_device(&[], None),
249            Err(Error::DeviceNotFound)
250        );
251    }
252
253    #[test]
254    fn optional_default_failure_keeps_inventory_without_a_default() {
255        let default = optional::<u32>(Err(fault(-50)));
256        let entries = collect(Ok([0, 1, 2]), |id| Ok(Some((id, default == Some(id))))).unwrap();
257        assert_eq!(entries, [(0, false), (1, false), (2, false)]);
258    }
259
260    #[test]
261    fn per_entry_format_failure_keeps_neighbors_and_uses_zero_sentinel() {
262        let entries = collect(Ok([0, 1, 2]), |id| {
263            Ok(Some(device_format(if id == 1 {
264                Err(fault(-50))
265            } else {
266                Ok((44_100, 2))
267            })))
268        })
269        .unwrap();
270        assert_eq!(
271            entries,
272            [(44_100, 2, true), (0, 0, false), (44_100, 2, true)]
273        );
274        assert_eq!(device_format(Ok((0, 2))), (0, 0, false));
275        let error = require_device_format(Err(fault(-50))).unwrap_err();
276        assert_eq!(error.kind(), crate::ErrorKind::Backend);
277        assert!(error.to_string().contains("could not be read"));
278        assert!(error.to_string().contains("retry"));
279        let Error::Multiple(group) = error else {
280            panic!("missing typed cause")
281        };
282        assert_eq!(group.secondary().next(), Some(&fault(-50)));
283    }
284    #[test]
285    fn optional_names_do_not_fail_the_inventory() {
286        let entries = collect(Ok([0, 1, 2]), |id| {
287            Ok(Some(optional(if id == 1 {
288                Err(fault(-50))
289            } else {
290                Ok(Some("name"))
291            })))
292        })
293        .unwrap();
294        assert_eq!(entries, [Some("name"), None, Some("name")]);
295    }
296    #[test]
297    fn collection_and_count_faults_fail_the_whole_call_with_cause() {
298        for code in [-50, -1] {
299            assert_eq!(
300                collect::<u32, u32>(Err::<Vec<u32>, _>(fault(code)), |id| Ok(Some(id))),
301                Err(fault(code))
302            );
303        }
304    }
305    #[test]
306    fn native_process_fault_classes_keep_unknown_names_and_omit_vanished() {
307        let windows = collect(Ok([0x8007_0057, 0x8007_0005, 0x8007_001f]), |bits| {
308            let error = Error::Backend("fixture failed; retry".into()).with_context(
309                ErrorContext::new(Operation::Enumerate).with_native_status(NativeStatus::HResult {
310                    call: "OpenProcess",
311                    bits,
312                }),
313            );
314            Ok(process_metadata(
315                Err::<Option<String>, _>(error),
316                windows_error_failure,
317            ))
318        })
319        .unwrap();
320        assert_eq!(windows, [None, None]);
321        let mac = collect(Ok([0x216f_626a, 3, 2, 13, 1, -50]), |code| {
322            Ok(process_metadata(
323                Err::<Option<String>, _>(fault(code)),
324                macos_error_failure,
325            ))
326        })
327        .unwrap();
328        assert_eq!(mac, [None, None, None]);
329    }
330}