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    // Packaged shared assets are never overridden by `--ui-root` (d.1 dual mount).
30    let shared_ui_root = default_ui_root();
31    let mut ui_root = shared_ui_root.clone();
32    let mut viewer = viewer_from_env().unwrap_or(ViewerMode::Embedded);
33    let mut allow_non_loopback = false;
34    let mut positionals = Vec::new();
35
36    let mut i = 0;
37    while i < args.len() {
38        let arg = &args[i];
39        if arg == "--bind" {
40            let value = require_flag_value(args, i, "--bind")?;
41            bind = parse_bind(value)?;
42            i += 2;
43            continue;
44        }
45        if let Some(value) = arg.strip_prefix("--bind=") {
46            bind = parse_bind(value)?;
47            i += 1;
48            continue;
49        }
50        if arg == "--allow-non-loopback" {
51            allow_non_loopback = true;
52            i += 1;
53            continue;
54        }
55        if arg == "--ui-root" {
56            let value = require_flag_value(args, i, "--ui-root")?;
57            ui_root = PathBuf::from(value);
58            i += 2;
59            continue;
60        }
61        if let Some(value) = arg.strip_prefix("--ui-root=") {
62            ui_root = PathBuf::from(value);
63            i += 1;
64            continue;
65        }
66        if arg == "--viewer" {
67            let value = require_flag_value(args, i, "--viewer")?;
68            viewer = parse_viewer(value)?;
69            i += 2;
70            continue;
71        }
72        if let Some(value) = arg.strip_prefix("--viewer=") {
73            viewer = parse_viewer(value)?;
74            i += 1;
75            continue;
76        }
77        if arg == "--version" || arg == "-V" {
78            positionals.push(arg.clone());
79            i += 1;
80            continue;
81        }
82        if arg.starts_with('-') {
83            return Err(LoadError::Usage {
84                message: format!("unknown flag '{arg}'\n{}", usage_message()),
85            });
86        }
87        positionals.push(arg.clone());
88        i += 1;
89    }
90
91    let dialog_url_env = matches!(viewer, ViewerMode::None);
92    Ok(CliArgs {
93        host: HostOptions {
94            bind,
95            ui_root,
96            shared_ui_root,
97            viewer,
98            dialog_url_env,
99            dialog_url_file: std::env::var_os("WYVERN_DIALOG_URL_FILE").map(PathBuf::from),
100            allow_non_loopback,
101            session_timeout: wyvern_host::DEFAULT_SESSION_TIMEOUT,
102            mock_picker: None,
103        },
104        positionals,
105    })
106}
107
108fn parse_bind(value: &str) -> Result<SocketAddr, LoadError> {
109    value.parse().map_err(|e| LoadError::Usage {
110        message: format!(
111            "invalid --bind '{value}': {e}\n\
112             Recovery:\n\
113             - Use host:port form (example: 127.0.0.1:0 for an ephemeral loopback port)\n\
114             - For 0.0.0.0 / LAN binds, also pass --allow-non-loopback\n\
115             - Check the address is a valid IPv4/IPv6 socket address\n\
116             {}",
117            usage_message()
118        ),
119    })
120}
121
122fn require_flag_value<'a>(
123    args: &'a [String],
124    index: usize,
125    flag: &str,
126) -> Result<&'a str, LoadError> {
127    args.get(index + 1)
128        .map(String::as_str)
129        .ok_or_else(|| LoadError::Usage {
130            message: format!("missing value for {flag}\n{}", usage_message()),
131        })
132}
133
134fn parse_viewer(value: &str) -> Result<ViewerMode, LoadError> {
135    ViewerMode::parse(value).ok_or_else(|| LoadError::Usage {
136        message: format!(
137            "invalid --viewer '{value}' (expected embedded|none|system|chrome|safari|edge|firefox)\n{}",
138            usage_message()
139        ),
140    })
141}
142
143fn viewer_from_env_with(value: Option<&str>) -> Option<ViewerMode> {
144    value.and_then(ViewerMode::parse)
145}
146
147fn viewer_from_env() -> Option<ViewerMode> {
148    viewer_from_env_with(
149        std::env::var("WYVERN_VIEWER")
150            .ok()
151            .as_deref()
152            .filter(|s| !s.is_empty()),
153    )
154}
155
156/// Default UI root discovery order:
157///
158/// 1. `WYVERN_UI_ROOT` environment variable
159/// 2. `./ui` (dev workspace — cwd contains ui/)
160/// 3. `./share/wyvern/ui` (cwd install layout)
161/// 4. `<exe_dir>/share/wyvern/ui` (release tarball layout — REQ-0093 / REQ-0116)
162/// 5. `<exe_dir>/ui` (sibling to binary)
163/// 6. Embedded assets extracted to platform cache dir (`cargo install` layout)
164/// 7. Fallback `./ui` — caller receives a clear "UI not found" error downstream
165pub fn default_ui_root() -> PathBuf {
166    default_ui_root_with(
167        std::env::var("WYVERN_UI_ROOT").ok().as_deref(),
168        std::env::current_dir().ok().as_deref(),
169        std::env::current_exe()
170            .ok()
171            .as_deref()
172            .and_then(|p| p.parent()),
173        true,
174    )
175}
176
177/// Resolve the default UI root from injectable inputs (QA-001 — no `set_var` in tests).
178#[must_use]
179pub fn default_ui_root_with(
180    ui_root_var: Option<&str>,
181    cwd: Option<&Path>,
182    exe_dir: Option<&Path>,
183    use_embedded_cache: bool,
184) -> PathBuf {
185    if let Some(path) = ui_root_var {
186        return PathBuf::from(path);
187    }
188    if let Some(cwd) = cwd {
189        let cwd_ui = cwd.join("ui");
190        if cwd_ui.is_dir() {
191            return cwd_ui;
192        }
193        let cwd_share = cwd.join("share/wyvern/ui");
194        if cwd_share.is_dir() {
195            return cwd_share;
196        }
197    } else {
198        let cwd_ui = PathBuf::from("ui");
199        if cwd_ui.is_dir() {
200            return cwd_ui;
201        }
202        let cwd_share = PathBuf::from("share/wyvern/ui");
203        if cwd_share.is_dir() {
204            return cwd_share;
205        }
206    }
207    if let Some(exe_dir) = exe_dir {
208        let share = exe_dir.join("share/wyvern/ui");
209        if share.is_dir() {
210            return share;
211        }
212        let sibling_ui = exe_dir.join("ui");
213        if sibling_ui.is_dir() {
214            return sibling_ui;
215        }
216    }
217    if use_embedded_cache {
218        if let Some(cached) = crate::embedded_ui::extract_to_cache() {
219            return cached;
220        }
221    }
222    PathBuf::from("ui")
223}
224
225/// Canonical usage text for invalid argv / empty stdin.
226pub fn usage_message() -> String {
227    concat!(
228        "Usage: wyvern '<json>' | <file.json> | <file.md> [options]\n",
229        "       echo '<json>' | wyvern [options]\n",
230        "       wyvern browsers list|refresh\n",
231        "       wyvern --version\n",
232        "\n",
233        "Options:\n",
234        "  --bind <ADDR:PORT>         HTTP bind (default 127.0.0.1:0)\n",
235        "  --allow-non-loopback       Permit non-loopback --bind (0.0.0.0 / LAN)\n",
236        "  --ui-root <PATH>           Packaged UI root (default: share/wyvern/ui beside binary)\n",
237        "  --viewer <MODE>            embedded|none|system|chrome|safari|edge|firefox\n",
238        "                             (default: embedded; CI: WYVERN_VIEWER=none)\n",
239        "\n",
240        "Pass exactly one JSON string, .json file, or .md file; or pipe JSON on stdin.",
241    )
242    .to_string()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn args(items: &[&str]) -> Vec<String> {
250        items.iter().map(|s| (*s).to_string()).collect()
251    }
252
253    #[test]
254    fn viewer_from_env_parses_embedded() {
255        assert_eq!(
256            viewer_from_env_with(Some("embedded")),
257            Some(ViewerMode::Embedded)
258        );
259    }
260
261    #[test]
262    fn default_viewer_mode_when_env_unset() {
263        assert_eq!(viewer_from_env_with(None), None);
264        assert_eq!(
265            viewer_from_env_with(None).unwrap_or(ViewerMode::Embedded),
266            ViewerMode::Embedded
267        );
268    }
269
270    #[test]
271    fn parse_viewer_none_explicit() {
272        let parsed =
273            parse_cli_args(&args(&[r#"{"type":"message"}"#, "--viewer", "none"])).expect("parse");
274        assert_eq!(parsed.host.viewer, ViewerMode::None);
275        assert!(parsed.host.dialog_url_env);
276    }
277
278    #[test]
279    fn parse_ui_root_and_bind() {
280        let parsed = parse_cli_args(&args(&[
281            "--ui-root",
282            "./custom-ui",
283            "--bind",
284            "127.0.0.1:0",
285            r#"{"type":"message"}"#,
286        ]))
287        .expect("parse");
288        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
289        assert_eq!(parsed.positionals.len(), 1);
290    }
291
292    #[test]
293    fn parse_bind_rejects_invalid_with_recovery_hint() {
294        let err = parse_cli_args(&args(&["--bind", "not-an-addr"])).expect_err("bind");
295        let LoadError::Usage { message } = err else {
296            panic!("expected Usage");
297        };
298        assert!(message.contains("invalid --bind"), "{message}");
299        assert!(message.contains("Recovery:"), "{message}");
300        assert!(message.contains("--allow-non-loopback"), "{message}");
301    }
302
303    #[test]
304    fn parse_rejects_unknown_flag() {
305        let err = parse_cli_args(&args(&["--nope"])).expect_err("flag");
306        assert!(matches!(err, LoadError::Usage { .. }));
307    }
308
309    #[test]
310    fn default_ui_root_prefers_env_override() {
311        let tmp = tempfile::tempdir().expect("tempdir");
312        let custom = tmp.path().join("custom-ui");
313        std::fs::create_dir_all(&custom).expect("mkdir");
314        let root = default_ui_root_with(Some(custom.to_str().expect("utf8")), None, None, false);
315        assert_eq!(root, custom);
316    }
317
318    #[test]
319    fn default_ui_root_falls_back_to_ui_when_nothing_found() {
320        let tmp = tempfile::tempdir().expect("tempdir");
321        let root = default_ui_root_with(None, Some(tmp.path()), None, false);
322        assert_eq!(root, PathBuf::from("ui"));
323    }
324}