1use std::path::PathBuf;
4
5use clap::Args as ClapArgs;
6use clap::ValueEnum;
7
8use crate::cli::output;
9use crate::host::bootstrap::BrowserChoice;
10use crate::sdk::fetch::{FetchCookie, NetworkBodies, RenderMode, Wait};
11use crate::sdk::{Client, InlineConfig};
12use crate::shared::artifacts::Artifact;
13use crate::shared::error::{Error, ErrorCode};
14use crate::shared::ids::TabId;
15use crate::shared::time::parse_duration;
16
17#[derive(ValueEnum, Debug, Clone, Copy, Default)]
18pub enum NetworkBodiesArg {
19 #[default]
20 Off,
21 Xhr,
22 All,
23}
24
25impl From<NetworkBodiesArg> for NetworkBodies {
26 fn from(v: NetworkBodiesArg) -> Self {
27 match v {
28 NetworkBodiesArg::Off => NetworkBodies::Off,
29 NetworkBodiesArg::Xhr => NetworkBodies::Xhr,
30 NetworkBodiesArg::All => NetworkBodies::All,
31 }
32 }
33}
34
35impl std::fmt::Display for NetworkBodiesArg {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.write_str(match self {
38 Self::Off => "off",
39 Self::Xhr => "xhr",
40 Self::All => "all",
41 })
42 }
43}
44
45#[derive(ValueEnum, Debug, Clone, Copy, Default)]
46pub enum NetworkRedactArg {
47 #[default]
48 On,
49 Off,
50}
51
52impl std::fmt::Display for NetworkRedactArg {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.write_str(match self {
55 Self::On => "on",
56 Self::Off => "off",
57 })
58 }
59}
60
61#[derive(ClapArgs, Debug)]
62pub struct Args {
63 pub url: String,
65 #[arg(long = "endpoint-url", help_heading = "Connection")]
68 pub endpoint: Option<String>,
69 #[arg(long = "token-secret", help_heading = "Connection")]
71 pub token: Option<String>,
72 #[arg(long, default_value = "auto", help_heading = "Connection")]
76 pub browser: String,
77 #[arg(long = "browser-bin", value_name = "PATH", help_heading = "Connection")]
80 pub browser_bin: Option<PathBuf>,
81 #[arg(long, default_value = "auto", help_heading = "Rendering")]
84 pub render: String,
85 #[arg(
88 long,
89 default_value = "new",
90 value_name = "new|<id>",
91 help_heading = "Connection"
92 )]
93 pub tab: String,
94 #[arg(long, default_value = "load", help_heading = "Rendering")]
97 pub wait: String,
98 #[arg(long = "header", value_name = "K:V", help_heading = "Request")]
100 pub headers: Vec<String>,
101 #[arg(long = "cookie", value_name = "name=value", help_heading = "Request")]
103 pub cookies: Vec<String>,
104 #[arg(long, help_heading = "Request")]
106 pub user_agent: Option<String>,
107 #[arg(long, value_name = "js", help_heading = "Rendering")]
110 pub evaluate_after_wait: Vec<String>,
111 #[arg(long, value_delimiter = ',', help_heading = "Rendering")]
115 pub want: Vec<String>,
116 #[arg(long, default_value = "GET", help_heading = "Request")]
118 pub method: String,
119 #[arg(long, help_heading = "Request")]
122 pub data: Option<String>,
123 #[arg(long, help_heading = "Request")]
125 pub data_file: Option<PathBuf>,
126 #[arg(long = "form", value_name = "key=value", help_heading = "Request")]
130 pub form: Vec<String>,
131 #[arg(long, default_value_t = NetworkBodiesArg::Off, help_heading = "Network capture")]
134 pub network_bodies: NetworkBodiesArg,
135 #[arg(long, default_value_t = 1_048_576, help_heading = "Network capture")]
137 pub network_body_max_bytes: u64,
138 #[arg(long, default_value_t = NetworkRedactArg::On, help_heading = "Network capture")]
142 pub network_redact: NetworkRedactArg,
143 #[arg(long, help_heading = "Output")]
146 pub out: Option<PathBuf>,
147 #[arg(long, help_heading = "Cookies")]
154 pub cookie_jar: Option<PathBuf>,
155 #[arg(long, help_heading = "Cookies")]
159 pub no_cookie_jar: bool,
160 #[arg(long, default_value_t = 500, help_heading = "Rendering")]
163 pub observe_main_wait_ms: u64,
164 #[arg(long, default_value_t = 1_073_741_824, help_heading = "HTTP transport")]
170 pub max_response_bytes: u64,
171 #[arg(long, default_value_t = 0, help_heading = "Retry")]
177 pub retry: u32,
178 #[arg(long, default_value_t = 250, help_heading = "Retry")]
180 pub backoff_ms: u64,
181 #[arg(long = "proxy-url", help_heading = "HTTP transport")]
186 pub proxy: Option<String>,
187 #[arg(long, help_heading = "HTTP transport")]
191 pub ca_cert: Option<PathBuf>,
192 #[arg(long, help_heading = "HTTP transport")]
196 pub tls_insecure: bool,
197 #[arg(long, default_value = "30s", help_heading = "HTTP transport")]
199 pub timeout: String,
200 #[arg(long, help_heading = "Network capture")]
204 pub capture_ws: bool,
205 #[arg(long, help_heading = "Network capture")]
208 pub capture_sse: bool,
209}
210
211pub async fn run(args: Args) -> Result<(), Error> {
212 let render = RenderMode::parse(&args.render)?;
213 let wait = Wait::parse(&args.wait)?;
214 let timeout = parse_duration(&args.timeout)?;
215 let network_bodies = NetworkBodies::from(args.network_bodies);
216 let network_redact = matches!(args.network_redact, NetworkRedactArg::On);
217
218 if args.data.is_some() && args.data_file.is_some() {
220 return Err(Error::new(
221 ErrorCode::InvalidArgument,
222 "--data and --data-file are mutually exclusive",
223 ));
224 }
225 if (args.data.is_some() || args.data_file.is_some()) && !args.form.is_empty() {
226 return Err(Error::new(
227 ErrorCode::InvalidArgument,
228 "--data/--data-file and --form are mutually exclusive",
229 ));
230 }
231 let body_bytes: Option<Vec<u8>> = if let Some(data) = &args.data {
232 if let Some(path) = data.strip_prefix('@') {
233 Some(
234 tokio::fs::read(path)
235 .await
236 .map_err(|e| Error::new(ErrorCode::IoError, format!("--data @{path}: {e}")))?,
237 )
238 } else {
239 Some(data.as_bytes().to_vec())
240 }
241 } else if let Some(path) = &args.data_file {
242 Some(tokio::fs::read(path).await.map_err(|e| {
243 Error::new(
244 ErrorCode::IoError,
245 format!("--data-file {}: {e}", path.display()),
246 )
247 })?)
248 } else {
249 None
250 };
251
252 let want: std::collections::BTreeSet<Artifact> = if args.want.is_empty() {
253 Artifact::ALL.iter().copied().collect()
254 } else {
255 let mut s = std::collections::BTreeSet::new();
256 for token in &args.want {
257 let a = parse_artifact(token)?;
258 s.insert(a);
259 }
260 s
261 };
262
263 let client = match args.endpoint.as_deref() {
264 Some(ep) => {
265 let mut c = Client::connect(ep)?;
266 if let Some(t) = args.token.as_deref() {
267 c = c.with_token(t);
268 }
269 c
270 }
271 None if matches!(render, RenderMode::None) => Client::http_only()?,
272 None => {
273 let browser = args
274 .browser
275 .parse::<BrowserChoice>()
276 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--browser: {e}")))?;
277 let cfg = InlineConfig {
278 browser,
279 browser_bin: args.browser_bin.clone(),
280 };
281 if matches!(render, RenderMode::Auto) {
282 Client::inline_ephemeral_lazy(cfg).await?
283 } else {
284 Client::inline_ephemeral_with(cfg).await?
285 }
286 }
287 };
288
289 let mut builder = client
290 .fetch(args.url.clone())
291 .render(render)
292 .wait(wait)
293 .timeout(timeout)
294 .want(want)
295 .network_bodies(network_bodies)
296 .network_body_max_bytes(args.network_body_max_bytes)
297 .network_redact(network_redact)
298 .method(args.method);
299 if let Some(bytes) = body_bytes {
300 builder = builder.body(bytes);
301 }
302 for raw in &args.form {
303 let (k, v) = raw.split_once('=').ok_or_else(|| {
304 Error::new(
305 ErrorCode::InvalidArgument,
306 format!("--form: expected key=value, got {raw:?}"),
307 )
308 })?;
309 builder = builder.form_field(k, v);
310 }
311 for raw in args.headers {
312 let (name, value) = parse_header_arg(&raw)?;
313 builder = builder.header(name, value);
314 }
315 for raw in args.cookies {
316 builder = builder.cookie_full(parse_cookie_arg(&raw)?);
317 }
318 if let Some(user_agent) = args.user_agent {
319 builder = builder.user_agent(user_agent);
320 }
321 for js in args.evaluate_after_wait {
322 builder = builder.evaluate_after_wait(js);
323 }
324 if args.tab != "new" {
325 builder = builder.tab(TabId::new(args.tab));
326 }
327 if let Some(out) = args.out {
328 builder = builder.out_dir(out);
329 }
330 builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
331 builder = builder.max_response_bytes(args.max_response_bytes);
332 builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
333 if let Some(url) = args.proxy {
334 builder = builder.proxy(url);
335 }
336 if let Some(path) = args.ca_cert {
337 builder = builder.ca_cert(path);
338 }
339 if args.tls_insecure {
340 builder = builder.tls_insecure(true);
341 }
342 if args.capture_ws {
343 builder = builder.capture_ws(true);
344 }
345 if args.capture_sse {
346 builder = builder.capture_sse(true);
347 }
348 if args.no_cookie_jar {
349 builder = builder.no_cookie_jar();
350 } else {
351 let cookie_jar = args.cookie_jar.or_else(|| {
352 std::env::var_os("AFHTTP_COOKIE_JAR")
353 .filter(|v| !v.is_empty())
354 .map(PathBuf::from)
355 });
356 if let Some(jar) = cookie_jar {
357 builder = builder.cookie_jar(jar);
358 }
359 }
360
361 let result = builder.send().await?;
362 output::emit("fetch", &result)
363}
364
365fn parse_artifact(token: &str) -> Result<Artifact, Error> {
366 Ok(match token {
367 "body" => Artifact::Body,
368 "rendered_html" => Artifact::RenderedHtml,
369 "text" => Artifact::Text,
370 "screenshot" => Artifact::Screenshot,
371 "network" => Artifact::Network,
372 "console" => Artifact::Console,
373 "observation" => Artifact::Observation,
374 "storage" => Artifact::Storage,
375 other => {
376 return Err(Error::new(
377 ErrorCode::InvalidArgument,
378 format!("--want: unknown artifact {other:?}"),
379 ));
380 }
381 })
382}
383
384fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
385 let (name, value) = raw.split_once(':').ok_or_else(|| {
386 Error::new(
387 ErrorCode::InvalidArgument,
388 format!("--header: expected K:V, got {raw:?}"),
389 )
390 })?;
391 let name = name.trim();
392 if name.is_empty() {
393 return Err(Error::new(
394 ErrorCode::InvalidArgument,
395 format!("--header: header name must not be empty in {raw:?}"),
396 ));
397 }
398 Ok((name.to_string(), value.trim_start().to_string()))
399}
400
401fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
402 if !raw.contains('=') {
403 return Err(Error::new(
404 ErrorCode::InvalidArgument,
405 format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
406 ));
407 }
408 let cookie = FetchCookie::parse(raw.to_string())
409 .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
410 .into_owned();
411 if cookie.name().trim().is_empty() {
412 return Err(Error::new(
413 ErrorCode::InvalidArgument,
414 format!("--cookie: cookie name must not be empty in {raw:?}"),
415 ));
416 }
417 Ok(cookie)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn header_arg_accepts_colon_separator() {
426 assert_eq!(
427 parse_header_arg("X-Test: yes").unwrap(),
428 ("X-Test".to_string(), "yes".to_string())
429 );
430 }
431
432 #[test]
433 fn header_arg_rejects_missing_colon() {
434 let err = parse_header_arg("X-Test").err().unwrap();
435 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
436 }
437
438 #[test]
439 fn cookie_arg_accepts_equals_separator() {
440 let cookie = parse_cookie_arg("sid=abc=def").unwrap();
441 assert_eq!(cookie.name_value(), ("sid", "abc=def"));
442 }
443
444 #[test]
445 fn cookie_arg_accepts_full_set_cookie_attributes() {
446 let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
447 assert_eq!(cookie.name_value(), ("sid", "abc"));
448 assert_eq!(cookie.path(), Some("/"));
449 assert_eq!(cookie.secure(), Some(true));
450 assert_eq!(cookie.http_only(), Some(true));
451 assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
452 }
453
454 #[test]
455 fn cookie_arg_rejects_missing_equals() {
456 let err = parse_cookie_arg("sid").err().unwrap();
457 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
458 }
459}