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 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
147pub 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 if let Some(cached) = crate::embedded_ui::extract_to_cache() {
184 return cached;
185 }
186 cwd_ui
187}
188
189pub 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 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 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 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 unsafe { std::env::remove_var("WYVERN_UI_ROOT") };
305 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 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}