Skip to main content

media/
gstreamer.rs

1//! Shared GStreamer probe — structured diagnostic for capture/playback
2//! preconditions (M-MEDIA.1 / AUT-97).
3//!
4//! [`probe`] runs the actual checks; the returned [`GStreamerProbe`] is
5//! the structured form (`Option<version>` per binary, requested-plugin
6//! map, `PATH` snapshot for CI diagnosis). [`is_available`] is the
7//! `bool` shortcut callers use as a skip-guard for integration tests.
8//!
9//! # Why this exists
10//!
11//! Capture / playback paths all assume `gst-launch-1.0` and
12//! `gst-discoverer-1.0` are on `PATH`. CI on Linux apt-installs
13//! `gstreamer1.0-tools`, but per CLAUDE.md's "GStreamer / CI" lessons
14//! some nextest processes still get `ENOENT` on spawn — the cause is
15//! unclear, but the skip guard makes it a non-issue.
16//!
17//! Structured diagnostic (vs the old `bool` helper) means the failure
18//! message can say *why* — `gst-launch-1.0 missing, gst-discoverer-1.0
19//! present, PATH=/usr/bin:/usr/local/bin:…` — instead of just "false."
20//!
21//! # Quick start
22//!
23//! ```no_run
24//! use media::gstreamer::{is_available, probe};
25//!
26//! if !is_available() {
27//!     eprintln!("skipping GStreamer test:\n{}", probe());
28//!     return;
29//! }
30//! // …run the integration test…
31//! ```
32
33use std::collections::BTreeMap;
34use std::process::{Command, Stdio};
35
36/// Structured GStreamer probe result.
37///
38/// `gst_launch` / `gst_discoverer` contain the trimmed `--version`
39/// output when the binary is callable, or `None` when the spawn fails.
40/// `plugins` is the requested-plugin map (from
41/// [`probe_with_plugins`]); empty when [`probe`] is used.
42#[derive(Debug, Clone, Eq, PartialEq)]
43pub struct GStreamerProbe {
44    /// `gst-launch-1.0 --version` first line, or `None` if the spawn
45    /// failed (binary missing, permission denied, etc.).
46    pub gst_launch: Option<String>,
47    /// `gst-discoverer-1.0 --version` first line, or `None`.
48    pub gst_discoverer: Option<String>,
49    /// Requested-plugin presence map (plugin-name → present?). Filled
50    /// only by [`probe_with_plugins`]. Sorted by name for deterministic
51    /// output.
52    pub plugins: BTreeMap<String, bool>,
53    /// Snapshot of `PATH` at probe time. Useful in CI logs when a
54    /// previously-working install starts spawning `ENOENT`.
55    pub path: String,
56}
57
58impl GStreamerProbe {
59    /// True when both `gst-launch-1.0` and `gst-discoverer-1.0` are
60    /// callable. Plugin-presence is not considered — explicit
61    /// plugin requirements should be checked via [`Self::has_plugin`].
62    #[must_use]
63    pub fn is_available(&self) -> bool {
64        self.gst_launch.is_some() && self.gst_discoverer.is_some()
65    }
66
67    /// True when the named plugin was checked AND present. Returns
68    /// `false` for both "checked-but-missing" and "not-checked."
69    /// Callers wanting to distinguish should inspect [`Self::plugins`]
70    /// directly.
71    #[must_use]
72    pub fn has_plugin(&self, name: &str) -> bool {
73        self.plugins.get(name).copied().unwrap_or(false)
74    }
75}
76
77impl std::fmt::Display for GStreamerProbe {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        writeln!(
80            f,
81            "GStreamer probe:\n  gst-launch-1.0      = {}\n  gst-discoverer-1.0  = {}",
82            self.gst_launch
83                .as_deref()
84                .map_or_else(|| "<missing>".to_owned(), str::to_owned),
85            self.gst_discoverer
86                .as_deref()
87                .map_or_else(|| "<missing>".to_owned(), str::to_owned),
88        )?;
89        if !self.plugins.is_empty() {
90            writeln!(f, "  plugins:")?;
91            for (name, present) in &self.plugins {
92                writeln!(
93                    f,
94                    "    {name:24} = {}",
95                    if *present { "present" } else { "<missing>" }
96                )?;
97            }
98        }
99        write!(f, "  PATH                = {}", self.path)
100    }
101}
102
103/// Probe both CLIs without checking any plugins.
104#[must_use]
105pub fn probe() -> GStreamerProbe {
106    probe_with_plugins(&[])
107}
108
109/// Probe both CLIs and check the named plugins via `gst-inspect-1.0`.
110///
111/// `gst-inspect-1.0 <plugin>` exits 0 when the plugin is registered.
112/// If `gst-inspect-1.0` itself isn't on `PATH`, every requested
113/// plugin entry comes back as `false`.
114#[must_use]
115pub fn probe_with_plugins(plugins: &[&str]) -> GStreamerProbe {
116    let gst_launch = version_of("gst-launch-1.0");
117    let gst_discoverer = version_of("gst-discoverer-1.0");
118
119    let mut plugin_map = BTreeMap::new();
120    for &p in plugins {
121        plugin_map.insert(p.to_owned(), check_plugin(p));
122    }
123
124    GStreamerProbe {
125        gst_launch,
126        gst_discoverer,
127        plugins: plugin_map,
128        path: std::env::var("PATH").unwrap_or_else(|_| "<unset>".to_owned()),
129    }
130}
131
132/// Shortcut: `probe().is_available()`.
133///
134/// Use this in integration tests as a skip-guard so the test
135/// gracefully no-ops when GStreamer isn't installed.
136///
137/// ```no_run
138/// if !media::gstreamer::is_available() {
139///     eprintln!("skipping: GStreamer not on PATH");
140///     return;
141/// }
142/// ```
143#[must_use]
144pub fn is_available() -> bool {
145    probe().is_available()
146}
147
148fn version_of(cmd: &str) -> Option<String> {
149    // Try `--version` first (gst-launch-1.0 supports it). Fall back to
150    // `--help` for binaries that don't (gst-discoverer-1.0 rejects
151    // --version on some GStreamer builds; the previously-shipped
152    // version-only probe gave a false negative there, silently
153    // skipping integration tests that should have run).
154    let version = Command::new(cmd)
155        .arg("--version")
156        .stdout(Stdio::piped())
157        .stderr(Stdio::null())
158        .output();
159    if let Ok(out) = version
160        && out.status.success()
161    {
162        let s = String::from_utf8_lossy(&out.stdout);
163        let first = s.lines().next().unwrap_or("").trim().to_owned();
164        if !first.is_empty() {
165            return Some(first);
166        }
167    }
168    let help = Command::new(cmd)
169        .arg("--help")
170        .stdout(Stdio::piped())
171        .stderr(Stdio::null())
172        .output()
173        .ok()?;
174    if !help.status.success() {
175        return None;
176    }
177    Some(format!("{cmd} (--help responded)"))
178}
179
180fn check_plugin(plugin: &str) -> bool {
181    Command::new("gst-inspect-1.0")
182        .arg(plugin)
183        .stdout(Stdio::null())
184        .stderr(Stdio::null())
185        .status()
186        .is_ok_and(|s| s.success())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn probe_returns_path_snapshot() {
195        let p = probe();
196        // PATH may legitimately be empty, but the snapshot field is
197        // always populated (with "<unset>" if env var is missing).
198        assert!(!p.path.is_empty());
199    }
200
201    #[test]
202    fn probe_plugins_is_empty_when_none_requested() {
203        let p = probe();
204        assert!(p.plugins.is_empty());
205    }
206
207    #[test]
208    fn probe_with_plugins_records_requested_names() {
209        // Use an obviously-unknown plugin so the result is deterministic
210        // regardless of whether GStreamer is installed: it'll come back
211        // as false either way.
212        let p = probe_with_plugins(&["__definitely_not_a_real_plugin__"]);
213        assert_eq!(p.plugins.len(), 1);
214        assert!(!p.has_plugin("__definitely_not_a_real_plugin__"));
215        // Not requested → not present.
216        assert!(!p.has_plugin("videoconvert"));
217    }
218
219    #[test]
220    fn is_available_matches_probe_is_available() {
221        let direct = is_available();
222        let via_probe = probe().is_available();
223        assert_eq!(direct, via_probe);
224    }
225
226    #[test]
227    fn display_reports_missing_when_binary_absent() {
228        // Build a synthetic probe by hand — independent of host state.
229        let synthetic = GStreamerProbe {
230            gst_launch: None,
231            gst_discoverer: Some("gst-discoverer-1.0 version 1.26.8".to_owned()),
232            plugins: BTreeMap::new(),
233            path: "/usr/bin".to_owned(),
234        };
235        let rendered = format!("{synthetic}");
236        assert!(rendered.contains("<missing>"));
237        assert!(rendered.contains("gst-discoverer-1.0 version 1.26.8"));
238        assert!(rendered.contains("/usr/bin"));
239        assert!(!synthetic.is_available());
240    }
241
242    #[test]
243    fn probe_is_send_and_sync() {
244        fn assert_send_sync<T: Send + Sync>() {}
245        assert_send_sync::<GStreamerProbe>();
246    }
247}