use clap::{Parser, Subcommand};
use serde_json::Value;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::info;
mod describe;
mod output;
mod session;
mod skill;
mod validate;
use oxibrowser::search;
#[derive(Parser)]
#[command(name = "oxibrowser")]
#[command(
version,
about = "Headless browser for AI agents — single static binary, no Chromium"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Fetch {
url: String,
#[arg(long, default_value = "markdown")]
format: String,
#[arg(long)]
json: bool,
#[arg(long)]
max_bytes: Option<u64>,
#[arg(long)]
fields: Option<String>,
#[arg(long)]
summary: bool,
#[arg(long)]
eval: Option<String>,
#[arg(long)]
click: Option<String>,
#[arg(long)]
fill: Option<String>,
#[arg(long)]
press: Option<String>,
#[arg(long)]
wait: Option<String>,
#[arg(long, default_value_t = 5000)]
wait_timeout: u64,
#[arg(long)]
extract: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
headers: bool,
#[arg(long, default_value_t = 30)]
timeout: u64,
},
Extract {
url: String,
#[arg(long)]
selector: Option<String>,
#[arg(long)]
all: bool,
#[arg(long, default_value = "text")]
attrs: String,
#[arg(long)]
links: bool,
#[arg(long)]
title: bool,
#[arg(long)]
text: bool,
#[arg(long)]
markdown: bool,
#[arg(long)]
max_bytes: Option<u64>,
#[arg(long)]
json: bool,
#[arg(long, default_value_t = 30)]
timeout: u64,
},
Run {
script: String,
#[arg(long, default_value_t = 60)]
timeout: u64,
#[arg(long, hide = true)]
json: bool,
},
Session,
Serve {
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = 9222)]
port: u16,
#[arg(long)]
cookie_file: Option<String>,
},
Describe {
command: Option<String>,
#[arg(long)]
compact: bool,
#[arg(long, hide = true)]
json: bool,
},
Skill {
#[arg(long, hide = true)]
json: bool,
},
Search {
query: Vec<String>,
#[arg(long, default_value = "web", value_parser = clap::builder::PossibleValuesParser::new(["web", "github", "github-issues"]))]
source: String,
#[arg(long, default_value = "ddg")]
engine: String,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
token: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, default_value_t = 10)]
max_results: u32,
#[arg(long, default_value_t = 15)]
timeout: u64,
},
Version {
#[arg(long, hide = true)]
json: bool,
},
}
fn use_json(explicit_json: bool) -> bool {
explicit_json
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
let cli = Cli::parse();
let exit_code = match cli.command {
Commands::Fetch {
url,
format,
json,
max_bytes,
fields,
summary,
eval,
click,
fill,
press,
wait,
wait_timeout,
extract,
all,
headers,
timeout,
} => {
run_fetch(
&url,
&format,
json,
max_bytes,
fields.as_deref(),
summary,
eval.as_deref(),
click.as_deref(),
fill.as_deref(),
press.as_deref(),
wait.as_deref(),
wait_timeout,
extract.as_deref(),
all,
headers,
timeout,
)
.await
}
Commands::Extract {
url,
selector,
all,
attrs,
links,
title,
text,
markdown,
max_bytes,
json,
timeout,
} => {
run_extract(
&url,
selector.as_deref(),
all,
&attrs,
links,
title,
text,
markdown,
max_bytes,
json,
timeout,
)
.await
}
Commands::Run {
script, timeout, ..
} => run_script(&script, timeout).await,
Commands::Session => session::run_session().await,
Commands::Serve {
host,
port,
cookie_file,
} => run_serve(&host, port, cookie_file.as_deref()).await,
Commands::Search {
query,
source,
engine,
repo,
token,
json,
max_results,
timeout,
} => {
run_search(
&query,
&source,
&engine,
repo.as_deref(),
token.as_deref(),
json,
max_results,
timeout,
)
.await
}
Commands::Describe {
command, compact, ..
} => run_describe(command.as_deref(), compact),
Commands::Skill { json } => {
if json {
let resp =
output::CliResponse::success(serde_json::json!({"skill": skill::skill_text()}));
resp.print_json();
0
} else {
print!("{}", skill::skill_text());
0
}
}
Commands::Version { json } => {
if json {
let resp = output::CliResponse::success(
serde_json::json!({"version": env!("CARGO_PKG_VERSION"), "name": "oxibrowser"}),
);
resp.print_json();
0
} else {
println!("oxibrowser {}", env!("CARGO_PKG_VERSION"));
0
}
}
};
if exit_code != 0 {
std::process::exit(exit_code);
}
}
fn print_error(msg: &str, error_code: &str, json: bool) -> i32 {
let code = match error_code {
"INVALID_URL" | "INVALID_SELECTOR" | "INPUT_VALIDATION" | "PATH_TRAVERSAL"
| "SSRF_BLOCKED" => 2,
"TIMEOUT" => 3,
"NETWORK_ERROR" | "HTTP_ERROR" => 4,
_ => 1,
};
if json {
let resp = output::CliResponse::error(msg, error_code);
resp.print_json();
} else {
eprintln!("Error: {msg}");
}
code
}
#[allow(clippy::too_many_arguments)]
async fn run_fetch(
url: &str,
format: &str,
json: bool,
max_bytes: Option<u64>,
fields: Option<&str>,
summary: bool,
eval: Option<&str>,
click: Option<&str>,
fill: Option<&str>,
press: Option<&str>,
wait: Option<&str>,
wait_timeout: u64,
extract_sel: Option<&str>,
all: bool,
headers: bool,
timeout: u64,
) -> i32 {
let start = Instant::now();
let json = use_json(json);
if let Some(e) = validate_fetch_inputs(url, click, fill, wait, extract_sel, eval) {
return print_error(
&e.error.unwrap_or_default(),
&e.error_code.unwrap_or_default(),
json,
);
}
let needs_tab =
click.is_some() || fill.is_some() || press.is_some() || wait.is_some() || eval.is_some();
let config = oxibrowser_core::BrowserConfig::headless();
let browser = match oxibrowser_core::Browser::new(config).await {
Ok(b) => b,
Err(e) => return print_error(&format!("browser init failed: {e}"), "RUNTIME_ERROR", json),
};
let result = if needs_tab {
fetch_with_tab(
start,
&browser,
url,
format,
json,
max_bytes,
fields,
summary,
eval,
click,
fill,
press,
wait,
wait_timeout,
extract_sel,
all,
headers,
timeout,
)
.await
} else {
fetch_direct(
start,
&browser,
url,
format,
json,
max_bytes,
fields,
summary,
extract_sel,
all,
headers,
)
.await
};
browser.close().await.ok();
match result {
Ok(()) => 0,
Err(FetchError { msg, code }) => print_error(&msg, &code, json),
}
}
struct FetchError {
msg: String,
code: String,
}
impl From<oxibrowser_core::error::CoreError> for FetchError {
fn from(e: oxibrowser_core::error::CoreError) -> Self {
FetchError {
msg: format!("{e}"),
code: output::core_error_code(&e).to_string(),
}
}
}
#[allow(clippy::too_many_arguments)]
async fn fetch_direct(
start: Instant,
browser: &oxibrowser_core::Browser,
url: &str,
format: &str,
json: bool,
max_bytes: Option<u64>,
fields: Option<&str>,
summary: bool,
extract_sel: Option<&str>,
all: bool,
headers: bool,
) -> Result<(), FetchError> {
let session = browser.new_page(url).await.map_err(FetchError::from)?;
let guard = session.read().await;
let page = guard.page().ok_or_else(|| FetchError {
msg: "no page loaded".into(),
code: "PAGE_NOT_LOADED".into(),
})?;
if headers {
eprintln!("HTTP {}", page.status());
eprintln!("Content-Type: {}", page.content_type());
}
if summary {
let data = output::build_summary(page);
if json {
let resp = output::CliResponse::success_with_meta(
data,
None,
start.elapsed().as_millis() as u64,
);
resp.print_json();
} else {
let obj = data.as_object().unwrap();
if let Some(v) = obj.get("url").and_then(|v| v.as_str()) {
eprintln!("URL: {v}");
}
if let Some(v) = obj.get("title").and_then(|v| v.as_str()) {
eprintln!("Title: {v}");
}
eprintln!("Status: {}", obj.get("status").unwrap());
if let Some(h) = obj.get("headings").and_then(|v| v.as_array()) {
eprintln!("Headings: {}", h.len());
for h in h {
if let Some(s) = h.as_str() {
eprintln!(" - {s}");
}
}
}
eprintln!("Links: {}", obj.get("links_count").unwrap());
eprintln!("Forms: {}", obj.get("forms_count").unwrap());
eprintln!("Images: {}", obj.get("images_count").unwrap());
eprintln!("Text length: {}", obj.get("text_length").unwrap());
}
return Ok(());
}
if let Some(sel) = extract_sel {
let doc = page.root_frame().document();
if all {
let texts: Vec<String> = doc
.query_selector_all(sel)
.iter()
.filter_map(|id| doc.text_content(*id))
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if json {
let resp = output::CliResponse::success(serde_json::json!({
"selector": sel, "count": texts.len(), "items": texts
}));
resp.print_json();
} else {
for t in &texts {
println!("{t}");
}
}
} else {
let text = doc
.query_text(sel)
.map(|t| t.trim().to_string())
.unwrap_or_default();
if json {
let resp = output::CliResponse::success(serde_json::json!({
"selector": sel, "match": text
}));
resp.print_json();
} else {
println!("{text}");
}
}
return Ok(());
}
let body = match format {
"markdown" | "md" => page.to_markdown(),
"text" => {
let md = page.to_markdown();
md.lines()
.map(|line| {
let l = line.trim();
let l = l.strip_prefix('#').map(|s| s.trim()).unwrap_or(l);
let l = l.strip_prefix('#').map(|s| s.trim()).unwrap_or(l);
let l = l.replace("**", "").replace("__", "");
let l = l.replace("* ", "");
regex_strip_link(&l)
})
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
_ => page.content().to_string(),
};
if json {
let mut data = serde_json::json!({
"url": page.url().to_string(),
"title": page.title().unwrap_or("").to_string(),
"status": page.status(),
"content_type": page.content_type().to_string(),
});
let key = match format {
"markdown" | "md" => "markdown",
"text" => "text",
_ => "html",
};
data.as_object_mut()
.unwrap()
.insert(key.into(), Value::String(body));
if let Some(mb) = max_bytes {
output::truncate_fields(&mut data, mb);
}
if let Some(f) = fields {
output::filter_fields(&mut data, &output::parse_fields(f));
}
output::CliResponse::success_with_meta(data, None, start.elapsed().as_millis() as u64)
.print_json();
} else {
print!("{body}");
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn fetch_with_tab(
start: Instant,
browser: &oxibrowser_core::Browser,
url: &str,
format: &str,
json: bool,
max_bytes: Option<u64>,
fields: Option<&str>,
summary: bool,
eval: Option<&str>,
click: Option<&str>,
fill: Option<&str>,
press: Option<&str>,
wait: Option<&str>,
wait_timeout: u64,
extract_sel: Option<&str>,
all: bool,
headers: bool,
timeout: u64,
) -> Result<(), FetchError> {
let tab = browser.new_tab().await.map_err(FetchError::from)?;
let nav_result = tokio::time::timeout(Duration::from_secs(timeout), tab.goto(url)).await;
match nav_result {
Ok(Ok(nav)) => {
if headers {
eprintln!("HTTP {}", nav.status);
eprintln!("URL: {}", nav.url);
eprintln!("Title: {}", nav.title);
}
}
Ok(Err(e)) => return Err(FetchError::from(e)),
Err(_) => {
return Err(FetchError {
msg: format!("timed out after {timeout}s"),
code: "TIMEOUT".into(),
});
}
}
if let Some(sel) = wait {
tab.wait_for(sel, wait_timeout)
.await
.map_err(FetchError::from)?;
}
if let Some(spec) = fill {
let (sel, val) = spec.split_once(':').ok_or_else(|| FetchError {
msg: "--fill must be selector:value".into(),
code: "INPUT_VALIDATION".into(),
})?;
tab.fill(sel, val).await.map_err(FetchError::from)?;
}
if let Some(sel) = click {
tab.click(sel).await.map_err(FetchError::from)?;
}
if let Some(keys) = press {
tab.press(keys).await.map_err(FetchError::from)?;
}
if let Some(expr) = eval {
let value = tab.evaluate(expr).await.map_err(FetchError::from)?;
if json {
output::CliResponse::success(serde_json::json!({"value": value})).print_json();
} else {
match value {
Value::String(s) => println!("{s}"),
Value::Null => {}
other => println!("{other}"),
}
}
return Ok(());
}
if summary {
let content = tab.content().await.map_err(FetchError::from)?;
let data = serde_json::json!({
"url": content.url, "title": content.title,
"status": content.status, "text_length": content.markdown.len(),
});
if json {
output::CliResponse::success_with_meta(data, None, start.elapsed().as_millis() as u64)
.print_json();
} else {
eprintln!("URL: {}", content.url);
eprintln!("Title: {}", content.title);
eprintln!("Status: {}", content.status);
eprintln!("Text length: {}", content.markdown.len());
}
return Ok(());
}
if let Some(sel) = extract_sel {
let matches = tab.query_all(sel).await.map_err(FetchError::from)?;
if all {
let items: Vec<String> = matches
.into_iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
if json {
output::CliResponse::success(serde_json::json!({
"selector": sel, "count": items.len(), "items": items
}))
.print_json();
} else {
for t in &items {
println!("{t}");
}
}
} else {
let text = matches
.first()
.map(|t| t.trim().to_string())
.unwrap_or_default();
if json {
output::CliResponse::success(serde_json::json!({
"selector": sel, "match": text
}))
.print_json();
} else {
println!("{text}");
}
}
return Ok(());
}
let content = tab.content().await.map_err(FetchError::from)?;
if json {
let mut data = match format {
"markdown" | "md" => serde_json::json!({
"url": content.url, "title": content.title,
"status": content.status, "markdown": content.markdown,
}),
"text" => {
let body = content
.markdown
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
serde_json::json!({
"url": content.url, "title": content.title,
"status": content.status, "text": body,
})
}
_ => serde_json::json!({
"url": content.url, "title": content.title,
"status": content.status, "html": content.html,
}),
};
if let Some(mb) = max_bytes {
output::truncate_fields(&mut data, mb);
}
if let Some(f) = fields {
output::filter_fields(&mut data, &output::parse_fields(f));
}
output::CliResponse::success_with_meta(data, None, start.elapsed().as_millis() as u64)
.print_json();
} else {
match format {
"html" => print!("{}", content.html),
"text" => {
let body = content
.markdown
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
println!("{body}");
}
_ => print!("{}", content.markdown),
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_extract(
url: &str,
selector: Option<&str>,
all: bool,
attrs: &str,
links: bool,
title: bool,
text: bool,
markdown: bool,
max_bytes: Option<u64>,
json: bool,
timeout: u64,
) -> i32 {
let json = use_json(json);
let start = Instant::now();
if let Err(e) = validate::validate_url(url) {
return print_error(&e.to_string(), e.error_code(), json);
}
if let Some(sel) = selector
&& let Err(e) = validate::validate_selector(sel)
{
return print_error(&e.to_string(), e.error_code(), json);
}
let requested_attrs: Vec<&str> = output::parse_fields(attrs);
let config = oxibrowser_core::BrowserConfig::headless();
let browser = match oxibrowser_core::Browser::new(config).await {
Ok(b) => b,
Err(e) => return print_error(&format!("browser init failed: {e}"), "RUNTIME_ERROR", json),
};
let session_result =
tokio::time::timeout(Duration::from_secs(timeout), browser.new_page(url)).await;
let session = match session_result {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
browser.close().await.ok();
return print_error(&format!("{e}"), output::core_error_code(&e), json);
}
Err(_) => {
browser.close().await.ok();
return print_error(&format!("timed out after {timeout}s"), "TIMEOUT", json);
}
};
let guard = session.read().await;
let page = match guard.page() {
Some(p) => p,
None => {
browser.close().await.ok();
return print_error("no page loaded", "PAGE_NOT_LOADED", json);
}
};
let doc = page.root_frame().document();
let mut json_map = serde_json::Map::new();
if title {
json_map.insert(
"title".into(),
Value::String(page.title().unwrap_or("").to_string()),
);
}
if links {
let hrefs: Vec<Value> = doc
.query_selector_all("a[href]")
.iter()
.filter_map(|id| {
doc.get_node(*id)
.and_then(|n| n.href().map(|h| Value::String(h.to_string())))
})
.collect();
json_map.insert("links".into(), Value::Array(hrefs));
}
if text {
json_map.insert(
"text".into(),
Value::String(doc.query_text("body").unwrap_or_default()),
);
}
if markdown {
json_map.insert("markdown".into(), Value::String(page.to_markdown()));
}
if let Some(sel) = selector {
let ids = doc.query_selector_all(sel);
if all {
let items: Vec<Value> = ids
.iter()
.filter_map(|id| {
let mut item = serde_json::Map::new();
for &attr in &requested_attrs {
let val = if attr == "text" {
doc.text_content(*id)
.map(|t| t.trim().to_string())
.unwrap_or_default()
} else {
doc.get_node(*id)
.and_then(|n| n.get_attribute(attr).map(|v| v.to_string()))
.unwrap_or_default()
};
item.insert(attr.into(), Value::String(val));
}
if !item.is_empty() {
Some(Value::Object(item))
} else {
None
}
})
.collect();
json_map.insert("selector".into(), Value::String(sel.into()));
json_map.insert(
"count".into(),
Value::Number(serde_json::Number::from(items.len())),
);
json_map.insert("items".into(), Value::Array(items));
} else {
let mut item = serde_json::Map::new();
if let Some(id) = ids.first() {
for &attr in &requested_attrs {
let val = if attr == "text" {
doc.text_content(*id)
.map(|t| t.trim().to_string())
.unwrap_or_default()
} else {
doc.get_node(*id)
.and_then(|n| n.get_attribute(attr).map(|v| v.to_string()))
.unwrap_or_default()
};
item.insert(attr.into(), Value::String(val));
}
}
json_map.insert("selector".into(), Value::String(sel.into()));
json_map.insert("match".into(), Value::Object(item));
}
}
if !title && !links && !text && !markdown && selector.is_none() {
json_map.insert(
"title".into(),
Value::String(page.title().unwrap_or("").to_string()),
);
json_map.insert(
"text".into(),
Value::String(doc.query_text("body").unwrap_or_default()),
);
}
drop(guard);
browser.close().await.ok();
let mut data = Value::Object(json_map);
if let Some(mb) = max_bytes {
output::truncate_fields(&mut data, mb);
}
if json {
output::CliResponse::success_with_meta(data, None, start.elapsed().as_millis() as u64)
.print_json();
} else {
print_extract_human(&data);
}
0
}
fn print_extract_human(data: &Value) {
let obj = match data.as_object() {
Some(o) => o,
None => {
println!("{data}");
return;
}
};
if let Some(title) = obj.get("title").and_then(|v| v.as_str())
&& !title.is_empty()
{
println!("Title: {title}");
}
if obj.contains_key("title")
&& (obj.contains_key("text") || obj.contains_key("items") || obj.contains_key("links"))
{
println!();
}
if let Some(links) = obj.get("links").and_then(|v| v.as_array()) {
for link in links {
if let Some(s) = link.as_str() {
println!("{s}");
}
}
}
if let Some(items) = obj.get("items").and_then(|v| v.as_array()) {
for item in items {
if let Some(s) = item.as_str() {
println!("{s}");
} else {
let vals: Vec<&str> = item
.as_object()
.map(|o| o.values().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
println!("{}", vals.join("\t"));
}
}
}
if let Some(m) = obj.get("match") {
if let Some(s) = m.as_str() {
println!("{s}");
} else {
let vals: Vec<&str> = m
.as_object()
.map(|o| o.values().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
println!("{}", vals.join("\t"));
}
}
if let Some(text) = obj.get("text").and_then(|v| v.as_str())
&& !text.is_empty()
{
for line in text.lines() {
println!("{line}");
}
}
if let Some(md) = obj.get("markdown").and_then(|v| v.as_str())
&& !md.is_empty()
{
print!("{md}");
}
}
async fn run_script(script_path_or_yaml: &str, timeout: u64) -> i32 {
let script_config = if std::path::Path::new(script_path_or_yaml).exists() {
match std::fs::read_to_string(script_path_or_yaml) {
Ok(content) => match oxibrowser_core::script::parse_script(&content) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Error: parse error: {e}");
return 1;
}
},
Err(e) => {
eprintln!("Error: cannot read script: {e}");
return 1;
}
}
} else {
match oxibrowser_core::script::parse_script(script_path_or_yaml) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Error: parse error: {e}");
return 1;
}
}
};
let mut browser_config = oxibrowser_core::BrowserConfig::headless();
browser_config.enable_ssrf_filter = false;
let browser = match oxibrowser_core::Browser::new(browser_config).await {
Ok(b) => b,
Err(e) => {
eprintln!("Error: browser init failed: {e}");
return 1;
}
};
let tab = match browser.new_tab().await {
Ok(t) => t,
Err(e) => {
eprintln!("Error: tab creation failed: {e}");
return 1;
}
};
let mut runner = oxibrowser_core::script::ScriptRunner::new(&tab);
let script_result = match tokio::time::timeout(
Duration::from_secs(timeout),
runner.run_config(&script_config),
)
.await
{
Ok(Ok(r)) => r,
Ok(Err(e)) => {
browser.close().await.ok();
eprintln!("Error: {e}");
return 1;
}
Err(_) => {
browser.close().await.ok();
eprintln!("Error: timed out after {timeout}s");
return 3;
}
};
browser.close().await.ok();
let elapsed = script_result.duration_ms;
let data = serde_json::to_value(&script_result).unwrap_or_default();
output::CliResponse::success_with_meta(data, None, elapsed).print_json();
0
}
async fn run_serve(host: &str, port: u16, cookie_file: Option<&str>) -> i32 {
let addr: SocketAddr = match format!("{host}:{port}").parse() {
Ok(a) => a,
Err(e) => {
eprintln!("Error: invalid address: {e}");
return 2;
}
};
info!(addr = %addr, "starting CDP server");
let mut config = oxibrowser_core::BrowserConfig::headless();
if let Some(path) = cookie_file {
config.cookie_file = Some(std::path::PathBuf::from(path));
}
config.enable_ssrf_filter = false;
let browser = match oxibrowser_core::Browser::new(config).await {
Ok(b) => b,
Err(e) => {
eprintln!("Error: browser init failed: {e}");
return 1;
}
};
let browser = Arc::new(browser);
let server = Arc::new(oxibrowser_cdp::CdpServer::new(addr, browser.clone()));
let bound_addr = match server.start().await {
Ok(a) => a,
Err(e) => {
eprintln!("Error: server bind failed: {e}");
return 4;
}
};
info!(addr = %bound_addr, "CDP server ready");
println!("OxiBrowser CDP server listening on {bound_addr}");
println!(" DevTools: http://{bound_addr}/json/version");
println!(" WebSocket: ws://{bound_addr}/ws");
tokio::signal::ctrl_c().await.ok();
info!("shutting down");
server.shutdown();
browser.close().await.ok();
0
}
#[allow(clippy::too_many_arguments)]
async fn run_search(
query_parts: &[String],
source: &str,
engine: &str,
repo: Option<&str>,
token: Option<&str>,
json: bool,
max_results: u32,
timeout: u64,
) -> i32 {
let start = std::time::Instant::now();
let json = crate::output::should_output_json(json);
let query = query_parts.join(" ");
let query = query.trim();
if query.is_empty() {
return print_error("empty search query", "INPUT_VALIDATION", json);
}
let result = search::dispatch(
query,
source,
engine,
repo,
token,
max_results as usize,
timeout,
)
.await;
match result {
Ok(output) => {
let elapsed = start.elapsed().as_millis() as u64;
if json {
let data = serde_json::to_value(&output).unwrap_or_default();
let resp = output::CliResponse::success_with_search_meta(
data,
elapsed,
&output.source,
&output.engine,
);
resp.print_json()
} else {
search::format_human(&output);
0
}
}
Err(e) => print_error(&e.to_string(), "SEARCH_ERROR", json),
}
}
fn run_describe(command: Option<&str>, compact: bool) -> i32 {
let response = match command {
Some(cmd) => describe::describe_command(cmd),
None => describe::describe_all(compact),
};
response.print_json()
}
fn regex_strip_link(s: &str) -> String {
let bytes = s.as_bytes();
let mut result = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' {
if let Some(close) = bytes[i..].iter().position(|&b| b == b']') {
let close_idx = i + close;
if close_idx + 1 < bytes.len() && bytes[close_idx + 1] == b'(' {
if let Some(paren) = bytes[close_idx + 2..].iter().position(|&b| b == b')') {
let text = &s[i + 1..close_idx];
result.push_str(text);
i = close_idx + 2 + paren + 1;
continue;
}
}
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn validate_fetch_inputs(
url: &str,
click: Option<&str>,
fill: Option<&str>,
wait: Option<&str>,
extract: Option<&str>,
eval: Option<&str>,
) -> Option<output::CliResponse> {
if let Err(e) = validate::validate_url(url) {
return Some(output::CliResponse::from_validation(e));
}
if let Some(sel) = click
&& let Err(e) = validate::validate_selector(sel)
{
return Some(output::CliResponse::from_validation(e));
}
if let Some(spec) = fill
&& !spec.contains(':')
{
return Some(output::CliResponse::error(
"--fill must be in the format selector:value",
"INPUT_VALIDATION",
));
}
if let Some(sel) = wait
&& let Err(e) = validate::validate_selector(sel)
{
return Some(output::CliResponse::from_validation(e));
}
if let Some(sel) = extract
&& let Err(e) = validate::validate_selector(sel)
{
return Some(output::CliResponse::from_validation(e));
}
if let Some(expr) = eval
&& let Err(e) = validate::validate_expression(expr)
{
return Some(output::CliResponse::from_validation(e));
}
None
}