Skip to main content

leviath_sys/
browser.rs

1//! Opening a URL in the user's default browser.
2//!
3//! The launcher differs per OS (`open`, `xdg-open`, `cmd /c start`). The
4//! platform selection is a pure function tested against every OS string, and
5//! the actual process spawn is injected, so nothing here needs a `#[cfg]` and
6//! every branch is reachable under test on a single platform.
7
8use std::process::Command;
9
10/// The launcher command and arguments for `url` on `os`.
11///
12/// `os` is the value of `std::env::consts::OS`. An unrecognized OS falls back
13/// to `xdg-open`, the freedesktop standard, which covers the BSDs and other
14/// Unixes.
15///
16/// On Windows the empty `""` title argument to `start` matters: `start` treats
17/// a single quoted argument as a window title, so a URL would be swallowed
18/// without a placeholder title ahead of it.
19pub fn open_command_for(os: &str, url: &str) -> (String, Vec<String>) {
20    match os {
21        "macos" => ("open".to_string(), vec![url.to_string()]),
22        "windows" => (
23            "cmd".to_string(),
24            vec![
25                "/C".to_string(),
26                "start".to_string(),
27                String::new(),
28                url.to_string(),
29            ],
30        ),
31        _ => ("xdg-open".to_string(), vec![url.to_string()]),
32    }
33}
34
35/// Open `url` in the default browser, spawning via `spawn`.
36///
37/// `spawn` is injected so the real process launch is isolated from the logic:
38/// production passes [`spawn_detached`], tests pass a recording stub. Returns
39/// whether the launcher was spawned successfully - not whether the user
40/// actually saw the page, which is unknowable.
41pub fn open_url_via(spawn: fn(&mut Command) -> std::io::Result<()>, url: &str) -> bool {
42    let (program, args) = open_command_for(std::env::consts::OS, url);
43    let mut cmd = Command::new(program);
44    cmd.args(args);
45    match spawn(&mut cmd) {
46        Ok(()) => true,
47        Err(e) => {
48            tracing::warn!(error = %e, "Failed to launch browser");
49            false
50        }
51    }
52}
53
54/// Spawn `cmd` fire-and-forget, discarding its output.
55///
56/// The browser launcher is detached: we neither wait for it nor read its
57/// pipes, since it may outlive this process.
58pub fn spawn_detached(cmd: &mut Command) -> std::io::Result<()> {
59    cmd.stdin(std::process::Stdio::null())
60        .stdout(std::process::Stdio::null())
61        .stderr(std::process::Stdio::null())
62        .spawn()
63        .map(|_child| ())
64}
65
66/// Open `url` in the default browser.
67pub fn open_url(url: &str) -> bool {
68    open_url_via(spawn_detached, url)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn macos_uses_open() {
77        let (program, args) = open_command_for("macos", "https://x.example");
78        assert_eq!(program, "open");
79        assert_eq!(args, vec!["https://x.example"]);
80    }
81
82    #[test]
83    fn windows_uses_start_with_a_placeholder_title() {
84        let (program, args) = open_command_for("windows", "https://x.example");
85        assert_eq!(program, "cmd");
86        // The empty title placeholder must sit before the URL, or `start`
87        // consumes the URL as the window title.
88        assert_eq!(args, vec!["/C", "start", "", "https://x.example"]);
89    }
90
91    #[test]
92    fn other_unixes_fall_back_to_xdg_open() {
93        let (program, args) = open_command_for("linux", "https://x.example");
94        assert_eq!(program, "xdg-open");
95        assert_eq!(args, vec!["https://x.example"]);
96
97        // An unknown OS also uses the freedesktop launcher.
98        assert_eq!(open_command_for("dragonfly", "https://x").0, "xdg-open");
99    }
100
101    #[test]
102    fn open_url_via_reports_spawn_success() {
103        fn ok(_: &mut Command) -> std::io::Result<()> {
104            Ok(())
105        }
106        assert!(open_url_via(ok, "https://x.example"));
107    }
108
109    /// The failure arm logs, and `tracing::warn!` evaluates its field values
110    /// only when a subscriber is interested. Without one installed the `%e`
111    /// field closure never runs - so this test exercised the branch while
112    /// leaving the logging inside it unexecuted. `with_tracing` is the same
113    /// always-on-subscriber shim `leviath-cli` uses for exactly this.
114    #[test]
115    fn open_url_via_reports_spawn_failure() {
116        fn boom(_: &mut Command) -> std::io::Result<()> {
117            Err(std::io::Error::other("no browser"))
118        }
119        with_tracing(|| assert!(!open_url_via(boom, "https://x.example")));
120    }
121
122    /// An always-enabled [`tracing::Subscriber`], so macro bodies actually run.
123    struct AlwaysOnSubscriber;
124
125    impl tracing::Subscriber for AlwaysOnSubscriber {
126        fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
127            true
128        }
129        fn register_callsite(
130            &self,
131            _metadata: &'static tracing::Metadata<'static>,
132        ) -> tracing::subscriber::Interest {
133            tracing::subscriber::Interest::always()
134        }
135        fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
136            tracing::span::Id::from_u64(1)
137        }
138        fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
139        fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
140        fn event(&self, event: &tracing::Event<'_>) {
141            // Callsites are cached as always-enabled, so the macros never call
142            // `enabled`; call it here so it is exercised.
143            assert!(self.enabled(event.metadata()));
144        }
145        fn enter(&self, _span: &tracing::span::Id) {}
146        fn exit(&self, _span: &tracing::span::Id) {}
147        fn max_level_hint(&self) -> Option<tracing::metadata::LevelFilter> {
148            Some(tracing::metadata::LevelFilter::TRACE)
149        }
150    }
151
152    /// Exercise the span methods the logging path never reaches on its own, so
153    /// the shim above is fully covered rather than only its `event` arm.
154    #[test]
155    fn always_on_subscriber_span_methods_are_all_no_ops() {
156        with_tracing(|| {
157            let span = tracing::info_span!("test-span", field = tracing::field::Empty);
158            span.record("field", 1);
159            let other = tracing::info_span!("other-span");
160            span.follows_from(&other);
161            let _enter = span.enter();
162            tracing::info!(parent: &span, "inside span");
163        });
164    }
165
166    /// Install [`AlwaysOnSubscriber`] once per test binary and run `f` under it.
167    fn with_tracing<T>(f: impl FnOnce() -> T) -> T {
168        static INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
169        INSTALLED.get_or_init(|| {
170            // Required: callsites registered before the global default is set
171            // are cached as interest=never, so without this the macro bodies
172            // stay unreachable.
173            let _ = tracing::subscriber::set_global_default(AlwaysOnSubscriber);
174            tracing::callsite::rebuild_interest_cache();
175        });
176        f()
177    }
178
179    #[test]
180    fn spawn_detached_launches_a_real_process() {
181        // `true` exits immediately; this exercises the real spawn path without
182        // opening anything. On the off chance `true` is absent, a spawn error
183        // is still a valid Ok/Err from the function under test.
184        let mut cmd = Command::new("true");
185        let _ = spawn_detached(&mut cmd);
186    }
187
188    #[test]
189    fn spawn_detached_errors_on_a_missing_program() {
190        let mut cmd = Command::new("/nonexistent/browser/launcher");
191        assert!(spawn_detached(&mut cmd).is_err());
192    }
193
194    #[test]
195    fn open_url_runs_the_real_launcher_without_opening_a_browser() {
196        // Drives the real public entry point. The target is a bare, non-existent
197        // name rather than a URL, so whichever launcher the host resolves
198        // (`open`, `xdg-open`, or `cmd /C start`) errors on a missing file
199        // instead of opening a browser. This exercises the real `open_url`
200        // delegation on every platform without launching anything. The return
201        // value is host-dependent (whether the launcher itself is present), so
202        // we only require the call not to panic.
203        let _ = open_url("leviath-open-url-test-target");
204    }
205}