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}