Skip to main content

wyvern/
cli_args.rs

1//! Host option flags (`--bind`, `--ui-root`, `--viewer`) and argv splitting.
2
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5
6use wyvern_host::{HostOptions, ViewerMode};
7
8use crate::error::LoadError;
9
10/// Parsed CLI invocation: host options + remaining positional/stdin args.
11#[derive(Debug, Clone)]
12pub struct CliArgs {
13    /// Options passed to [`wyvern_host::run`] / [`wyvern_host::begin`].
14    pub host: HostOptions,
15    /// Non-flag argv entries (JSON / file path).
16    pub positionals: Vec<String>,
17}
18
19/// Split argv into host flags and positionals.
20///
21/// Product default (c.15+): omitted `--viewer` → [`ViewerMode::Embedded`].
22/// `WYVERN_VIEWER` overrides when set. Unknown flags → usage error.
23///
24/// # Errors
25///
26/// Returns [`LoadError::Usage`] for bad flags or values.
27pub fn parse_cli_args(args: &[String]) -> Result<CliArgs, LoadError> {
28    let mut bind = SocketAddr::from(([127, 0, 0, 1], 0));
29    let mut ui_root = default_ui_root();
30    let mut viewer = viewer_from_env().unwrap_or(ViewerMode::Embedded);
31    let mut allow_non_loopback = false;
32    let mut positionals = Vec::new();
33
34    let mut i = 0;
35    while i < args.len() {
36        let arg = &args[i];
37        if arg == "--bind" {
38            let value = require_flag_value(args, i, "--bind")?;
39            bind = parse_bind(value)?;
40            i += 2;
41            continue;
42        }
43        if let Some(value) = arg.strip_prefix("--bind=") {
44            bind = parse_bind(value)?;
45            i += 1;
46            continue;
47        }
48        if arg == "--allow-non-loopback" {
49            allow_non_loopback = true;
50            i += 1;
51            continue;
52        }
53        if arg == "--ui-root" {
54            let value = require_flag_value(args, i, "--ui-root")?;
55            ui_root = PathBuf::from(value);
56            i += 2;
57            continue;
58        }
59        if let Some(value) = arg.strip_prefix("--ui-root=") {
60            ui_root = PathBuf::from(value);
61            i += 1;
62            continue;
63        }
64        if arg == "--viewer" {
65            let value = require_flag_value(args, i, "--viewer")?;
66            viewer = parse_viewer(value)?;
67            i += 2;
68            continue;
69        }
70        if let Some(value) = arg.strip_prefix("--viewer=") {
71            viewer = parse_viewer(value)?;
72            i += 1;
73            continue;
74        }
75        if arg == "--version" || arg == "-V" {
76            positionals.push(arg.clone());
77            i += 1;
78            continue;
79        }
80        if arg.starts_with('-') {
81            return Err(LoadError::Usage {
82                message: format!("unknown flag '{arg}'\n{}", usage_message()),
83            });
84        }
85        positionals.push(arg.clone());
86        i += 1;
87    }
88
89    let dialog_url_env = matches!(viewer, ViewerMode::None);
90    Ok(CliArgs {
91        host: HostOptions {
92            bind,
93            ui_root,
94            viewer,
95            dialog_url_env,
96            dialog_url_file: std::env::var_os("WYVERN_DIALOG_URL_FILE").map(PathBuf::from),
97            allow_non_loopback,
98            session_timeout: wyvern_host::DEFAULT_SESSION_TIMEOUT,
99            mock_picker: None,
100        },
101        positionals,
102    })
103}
104
105fn parse_bind(value: &str) -> Result<SocketAddr, LoadError> {
106    value.parse().map_err(|e| LoadError::Usage {
107        message: format!(
108            "invalid --bind '{value}': {e}\n\
109             Recovery:\n\
110             - Use host:port form (example: 127.0.0.1:0 for an ephemeral loopback port)\n\
111             - For 0.0.0.0 / LAN binds, also pass --allow-non-loopback\n\
112             - Check the address is a valid IPv4/IPv6 socket address\n\
113             {}",
114            usage_message()
115        ),
116    })
117}
118
119fn require_flag_value<'a>(
120    args: &'a [String],
121    index: usize,
122    flag: &str,
123) -> Result<&'a str, LoadError> {
124    args.get(index + 1)
125        .map(String::as_str)
126        .ok_or_else(|| LoadError::Usage {
127            message: format!("missing value for {flag}\n{}", usage_message()),
128        })
129}
130
131fn parse_viewer(value: &str) -> Result<ViewerMode, LoadError> {
132    ViewerMode::parse(value).ok_or_else(|| LoadError::Usage {
133        message: format!(
134            "invalid --viewer '{value}' (expected embedded|none|system|chrome|safari|edge|firefox)\n{}",
135            usage_message()
136        ),
137    })
138}
139
140fn viewer_from_env() -> Option<ViewerMode> {
141    std::env::var("WYVERN_VIEWER")
142        .ok()
143        .as_deref()
144        .and_then(ViewerMode::parse)
145}
146
147/// Default UI root discovery order:
148///
149/// 1. `WYVERN_UI_ROOT` environment variable
150/// 2. `./ui` (dev workspace — cwd contains ui/)
151/// 3. `./share/wyvern/ui` (cwd install layout)
152/// 4. `<exe_dir>/share/wyvern/ui` (release tarball layout — REQ-0093 / REQ-0116)
153/// 5. `<exe_dir>/ui` (sibling to binary)
154/// 6. Embedded assets extracted to platform cache dir (`cargo install` layout)
155/// 7. Fallback `./ui` — caller receives a clear "UI not found" error downstream
156pub fn default_ui_root() -> PathBuf {
157    if let Ok(path) = std::env::var("WYVERN_UI_ROOT") {
158        return PathBuf::from(path);
159    }
160    let cwd_ui = PathBuf::from("ui");
161    if cwd_ui.is_dir() {
162        return cwd_ui;
163    }
164    let cwd_share = PathBuf::from("share/wyvern/ui");
165    if cwd_share.is_dir() {
166        return cwd_share;
167    }
168    if let Some(exe_dir) = std::env::current_exe()
169        .ok()
170        .and_then(|p| p.parent().map(Path::to_path_buf))
171    {
172        let share = exe_dir.join("share/wyvern/ui");
173        if share.is_dir() {
174            return share;
175        }
176        let sibling_ui = exe_dir.join("ui");
177        if sibling_ui.is_dir() {
178            return sibling_ui;
179        }
180    }
181    // For `cargo install` users: extract embedded assets to the platform cache
182    // directory on first use.  Returns None when the cache dir is unavailable.
183    if let Some(cached) = crate::embedded_ui::extract_to_cache() {
184        return cached;
185    }
186    cwd_ui
187}
188
189/// Canonical usage text for invalid argv / empty stdin.
190pub fn usage_message() -> String {
191    concat!(
192        "Usage: wyvern '<json>' | <file.json> | <file.md> [options]\n",
193        "       echo '<json>' | wyvern [options]\n",
194        "       wyvern browsers list|refresh\n",
195        "       wyvern --version\n",
196        "\n",
197        "Options:\n",
198        "  --bind <ADDR:PORT>         HTTP bind (default 127.0.0.1:0)\n",
199        "  --allow-non-loopback       Permit non-loopback --bind (0.0.0.0 / LAN)\n",
200        "  --ui-root <PATH>           Packaged UI root (default: share/wyvern/ui beside binary)\n",
201        "  --viewer <MODE>            embedded|none|system|chrome|safari|edge|firefox\n",
202        "                             (default: embedded; CI: WYVERN_VIEWER=none)\n",
203        "\n",
204        "Pass exactly one JSON string, .json file, or .md file; or pipe JSON on stdin.",
205    )
206    .to_string()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::sync::{Mutex, MutexGuard};
213
214    /// Single module-level lock for any test that mutates process env or cwd (QA-001).
215    static PROCESS_ENV_LOCK: Mutex<()> = Mutex::new(());
216
217    fn lock_process_env() -> MutexGuard<'static, ()> {
218        PROCESS_ENV_LOCK
219            .lock()
220            .unwrap_or_else(std::sync::PoisonError::into_inner)
221    }
222
223    fn args(items: &[&str]) -> Vec<String> {
224        items.iter().map(|s| (*s).to_string()).collect()
225    }
226
227    #[test]
228    fn parse_defaults_viewer_embedded() {
229        let _lock = lock_process_env();
230        // Ensure env override does not leak from other tests.
231        // SAFETY: exclusive PROCESS_ENV_LOCK held for the duration of this test.
232        unsafe { std::env::remove_var("WYVERN_VIEWER") };
233        let parsed = parse_cli_args(&args(&[r#"{"type":"message"}"#])).expect("parse");
234        assert_eq!(parsed.host.viewer, ViewerMode::Embedded);
235        assert!(!parsed.host.dialog_url_env);
236        assert_eq!(parsed.positionals.len(), 1);
237    }
238
239    #[test]
240    fn parse_viewer_none_explicit() {
241        let parsed =
242            parse_cli_args(&args(&[r#"{"type":"message"}"#, "--viewer", "none"])).expect("parse");
243        assert_eq!(parsed.host.viewer, ViewerMode::None);
244        assert!(parsed.host.dialog_url_env);
245    }
246
247    #[test]
248    fn parse_ui_root_and_bind() {
249        let parsed = parse_cli_args(&args(&[
250            "--ui-root",
251            "./custom-ui",
252            "--bind",
253            "127.0.0.1:0",
254            r#"{"type":"message"}"#,
255        ]))
256        .expect("parse");
257        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
258        assert_eq!(parsed.positionals.len(), 1);
259    }
260
261    #[test]
262    fn parse_bind_rejects_invalid_with_recovery_hint() {
263        let err = parse_cli_args(&args(&["--bind", "not-an-addr"])).expect_err("bind");
264        let LoadError::Usage { message } = err else {
265            panic!("expected Usage");
266        };
267        assert!(message.contains("invalid --bind"), "{message}");
268        assert!(message.contains("Recovery:"), "{message}");
269        assert!(message.contains("--allow-non-loopback"), "{message}");
270    }
271
272    #[test]
273    fn parse_rejects_unknown_flag() {
274        let err = parse_cli_args(&args(&["--nope"])).expect_err("flag");
275        assert!(matches!(err, LoadError::Usage { .. }));
276    }
277
278    #[test]
279    fn default_ui_root_prefers_env_override() {
280        let _lock = lock_process_env();
281
282        let tmp = tempfile::tempdir().expect("tempdir");
283        let custom = tmp.path().join("custom-ui");
284        std::fs::create_dir_all(&custom).expect("mkdir");
285        let previous = std::env::var_os("WYVERN_UI_ROOT");
286        // SAFETY: exclusive PROCESS_ENV_LOCK held for the duration of this test.
287        unsafe { std::env::set_var("WYVERN_UI_ROOT", &custom) };
288        let root = default_ui_root();
289        unsafe {
290            match previous {
291                Some(v) => std::env::set_var("WYVERN_UI_ROOT", v),
292                None => std::env::remove_var("WYVERN_UI_ROOT"),
293            }
294        }
295        assert_eq!(root, custom);
296    }
297
298    #[test]
299    fn default_ui_root_falls_back_to_ui_when_nothing_found() {
300        let _lock = lock_process_env();
301
302        let previous = std::env::var_os("WYVERN_UI_ROOT");
303        // SAFETY: exclusive PROCESS_ENV_LOCK held for the duration of this test.
304        unsafe { std::env::remove_var("WYVERN_UI_ROOT") };
305        // From a temp cwd with no ui/ and no share/, expect the ./ui fallback path.
306        let tmp = tempfile::tempdir().expect("tempdir");
307        let prev_cwd = std::env::current_dir().expect("cwd");
308        std::env::set_current_dir(tmp.path()).expect("chdir");
309        let root = default_ui_root();
310        std::env::set_current_dir(prev_cwd).expect("restore cwd");
311        unsafe {
312            match previous {
313                Some(v) => std::env::set_var("WYVERN_UI_ROOT", v),
314                None => std::env::remove_var("WYVERN_UI_ROOT"),
315            }
316        }
317        // May resolve to exe-adjacent share/ui if present next to the test binary;
318        // otherwise the documented fallback is ./ui.
319        assert!(
320            root.as_os_str() == "ui" || root.ends_with("share/wyvern/ui") || root.ends_with("ui"),
321            "unexpected default_ui_root: {}",
322            root.display()
323        );
324    }
325}