1use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5
6use wyvern_host::{HostOptions, ViewerMode};
7
8use crate::error::LoadError;
9
10#[derive(Debug, Clone)]
12pub struct CliArgs {
13 pub host: HostOptions,
15 pub positionals: Vec<String>,
17}
18
19pub fn parse_cli_args(args: &[String]) -> Result<CliArgs, LoadError> {
28 let mut bind = SocketAddr::from(([127, 0, 0, 1], 0));
29 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
156pub 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#[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
225pub 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}