use crate::diag;
use crate::output;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::OnceLock;
use std::time::Duration;
const MAX_STDOUT_BYTES: usize = 50 * 1024 * 1024;
const TIMEOUT_SECS: u64 = 60;
const KILL_GRACE_SECS: u64 = 5;
const MIN_VERSION: &str = "1.0.3";
pub const XURL_INSTALL_HINT: &str = "Install xurl-rs: brew install brettdavies/tap/xurl-rs (or Go xurl: brew install xdevplatform/tap/xurl)";
static XURL_PATH: OnceLock<Result<PathBuf, String>> = OnceLock::new();
pub fn resolve_xurl_path() -> Result<&'static Path, Box<dyn std::error::Error + Send + Sync>> {
let result = XURL_PATH.get_or_init(|| {
if let Ok(path) = std::env::var("BIRD_XURL_PATH") {
let p = PathBuf::from(&path);
if !p.exists() {
return Err(format!("BIRD_XURL_PATH={} does not exist", path));
}
let p = p
.canonicalize()
.map_err(|e| format!("BIRD_XURL_PATH={} cannot be resolved: {}", path, e))?;
if !p.is_file() {
return Err(format!("BIRD_XURL_PATH={} is not a file", p.display()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = p
.metadata()
.map_err(|e| format!("BIRD_XURL_PATH={}: {}", path, e))?
.permissions()
.mode();
if mode & 0o111 == 0 {
return Err(format!("BIRD_XURL_PATH={} is not executable", path));
}
}
return Ok(p);
}
for name in &["xr", "xurl"] {
if let Ok(found) = which::which(name) {
let canonical = found.canonicalize().unwrap_or(found);
if verify_xurl_binary(&canonical) {
return Ok(canonical);
}
}
}
Err(format!("xurl not found. {}", XURL_INSTALL_HINT))
});
match result {
Ok(p) => Ok(p.as_path()),
Err(e) => Err(e.clone().into()),
}
}
fn verify_xurl_binary(path: &Path) -> bool {
let Ok(output) = Command::new(path)
.arg("version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
else {
return false;
};
let stdout = String::from_utf8_lossy(&output.stdout);
parse_version_string(stdout.trim()).is_some()
}
fn parse_version_string(s: &str) -> Option<semver::Version> {
let version_part = s
.strip_prefix("xurl ")
.or_else(|| s.strip_prefix("xr "))
.unwrap_or(s);
let clean = version_part.strip_prefix('v').unwrap_or(version_part);
semver::Version::parse(clean).ok()
}
pub fn check_xurl_version(
path: &Path,
quiet: bool,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let output = Command::new(path)
.arg("version")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("failed to run xurl version: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if let Some(current) = parse_version_string(trimmed) {
if let Ok(minimum) = semver::Version::parse(MIN_VERSION)
&& current < minimum
{
diag!(
quiet,
"[transport] warning: xurl {} is below minimum {}; consider upgrading",
current,
MIN_VERSION
);
}
Ok(current.to_string())
} else {
Ok(trimmed.to_string())
}
}
#[derive(Debug)]
pub enum XurlError {
NotFound(String),
Auth(String),
Api { status: u16, message: String },
Timeout,
Process(String),
}
impl std::fmt::Display for XurlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
XurlError::NotFound(msg) => write!(f, "{}", msg),
XurlError::Auth(msg) => write!(f, "auth error: {}", msg),
XurlError::Api { status, message } => write!(f, "API error {}: {}", status, message),
XurlError::Timeout => write!(f, "xurl timed out after {}s", TIMEOUT_SECS),
XurlError::Process(msg) => write!(f, "xurl process error: {}", msg),
}
}
}
impl std::error::Error for XurlError {}
pub fn xurl_call(
args: &[&str],
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let path = resolve_xurl_path()?;
let mut child = match Command::new(path)
.args(args)
.env("NO_COLOR", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Box::new(XurlError::NotFound(format!(
"xurl not found. {}",
XURL_INSTALL_HINT
))));
}
Err(e) => {
return Err(Box::new(XurlError::Process(format!(
"failed to spawn xurl: {}",
e
))));
}
};
let stdout_thread = child.stdout.take().map(|out| {
std::thread::spawn(move || {
let mut buf = Vec::new();
out.take(MAX_STDOUT_BYTES as u64).read_to_end(&mut buf).ok();
buf
})
});
let stderr_thread = child.stderr.take().map(|err| {
std::thread::spawn(move || {
let mut buf = Vec::new();
err.take(MAX_STDOUT_BYTES as u64).read_to_end(&mut buf).ok();
buf
})
});
let status = wait_with_timeout(&mut child, Duration::from_secs(TIMEOUT_SECS))?;
let stdout_buf = stdout_thread
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
let stdout_str = String::from_utf8_lossy(&stdout_buf);
let stderr_buf = stderr_thread
.map(|h| h.join().unwrap_or_default())
.unwrap_or_default();
let stderr_str = String::from_utf8_lossy(&stderr_buf);
let clean_stdout = output::strip_ansi_lines(&stdout_str);
if status.success() {
let json: serde_json::Value = serde_json::from_str(&clean_stdout).map_err(|e| {
XurlError::Process(format!(
"xurl returned invalid JSON: {} (stdout: {})",
e,
output::sanitize_for_stderr(&clean_stdout, 200)
))
})?;
Ok(json)
} else {
classify_error(&clean_stdout, &stderr_str)
}
}
pub fn xurl_passthrough(args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = resolve_xurl_path()?;
let status = Command::new(path)
.args(args)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Box::new(XurlError::NotFound(format!(
"xurl not found. {}",
XURL_INSTALL_HINT
))) as Box<dyn std::error::Error + Send + Sync>
} else {
Box::new(XurlError::Process(format!("failed to run xurl: {}", e)))
as Box<dyn std::error::Error + Send + Sync>
}
})?;
if status.success() {
Ok(())
} else {
Err(Box::new(XurlError::Process(format!(
"xurl exited with code {}",
status.code().unwrap_or(-1)
))))
}
}
fn classify_error(
stdout: &str,
stderr: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
let status = json.get("status").and_then(|s| s.as_u64()).unwrap_or(0) as u16;
let detail = json
.get("detail")
.and_then(|d| d.as_str())
.or_else(|| json.get("title").and_then(|t| t.as_str()))
.unwrap_or("unknown error")
.to_string();
return Err(match status {
401 | 403 => Box::new(XurlError::Auth(detail)),
_ if status > 0 => Box::new(XurlError::Api {
status,
message: detail,
}),
_ => Box::new(XurlError::Api {
status: 0,
message: detail,
}),
});
}
let msg = if stderr.is_empty() {
output::sanitize_for_stderr(stdout, 200)
} else {
output::sanitize_for_stderr(stderr, 200)
};
Err(Box::new(XurlError::Process(msg)))
}
fn wait_with_timeout(
child: &mut std::process::Child,
timeout: Duration,
) -> Result<std::process::ExitStatus, Box<dyn std::error::Error + Send + Sync>> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(50);
loop {
match child.try_wait()? {
Some(status) => return Ok(status),
None => {
if start.elapsed() >= timeout {
#[cfg(unix)]
{
unsafe {
libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
}
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let grace_start = std::time::Instant::now();
loop {
match child.try_wait()? {
Some(status) => return Ok(status),
None => {
if grace_start.elapsed() >= Duration::from_secs(KILL_GRACE_SECS) {
let _ = child.kill();
let _ = child.wait();
return Err(Box::new(XurlError::Timeout));
}
std::thread::sleep(poll_interval);
}
}
}
}
std::thread::sleep(poll_interval);
}
}
}
}
pub trait Transport {
fn request(
&self,
args: &[String],
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>;
}
pub struct XurlTransport;
impl Transport for XurlTransport {
fn request(
&self,
args: &[String],
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
xurl_call(&arg_refs)
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::VecDeque;
pub struct MockTransport {
pub responses:
RefCell<VecDeque<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>>>,
}
impl MockTransport {
pub fn new(
responses: Vec<Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>>>,
) -> Self {
Self {
responses: RefCell::new(VecDeque::from(responses)),
}
}
}
impl Transport for MockTransport {
fn request(
&self,
_args: &[String],
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
self.responses
.borrow_mut()
.pop_front()
.unwrap_or_else(|| Err("MockTransport: no more responses".into()))
}
}
#[test]
fn xurl_error_display_not_found() {
let e = XurlError::NotFound("xurl not found".into());
assert_eq!(e.to_string(), "xurl not found");
}
#[test]
fn xurl_error_display_auth() {
let e = XurlError::Auth("Unauthorized".into());
assert_eq!(e.to_string(), "auth error: Unauthorized");
}
#[test]
fn xurl_error_display_api() {
let e = XurlError::Api {
status: 429,
message: "Too Many Requests".into(),
};
assert_eq!(e.to_string(), "API error 429: Too Many Requests");
}
#[test]
fn xurl_error_display_timeout() {
let e = XurlError::Timeout;
assert!(e.to_string().contains("timed out"));
}
#[test]
fn classify_error_auth_401() {
let stdout = r#"{"title":"Unauthorized","status":401,"detail":"Unauthorized"}"#;
let result = classify_error(stdout, "");
let err = result.unwrap_err();
assert!(err.to_string().contains("auth error"));
}
#[test]
fn classify_error_auth_403() {
let stdout = r#"{"title":"Forbidden","status":403,"detail":"Forbidden"}"#;
let result = classify_error(stdout, "");
let err = result.unwrap_err();
assert!(err.to_string().contains("auth error"));
}
#[test]
fn classify_error_api_429() {
let stdout = r#"{"title":"Too Many Requests","status":429,"detail":"Rate limit exceeded"}"#;
let result = classify_error(stdout, "");
let err = result.unwrap_err();
assert!(err.to_string().contains("API error 429"));
}
#[test]
fn classify_error_no_json_uses_stderr() {
let result = classify_error("not json", "some error on stderr");
let err = result.unwrap_err();
assert!(err.to_string().contains("some error on stderr"));
}
#[test]
fn classify_error_no_json_no_stderr_uses_stdout() {
let result = classify_error("raw error output", "");
let err = result.unwrap_err();
assert!(err.to_string().contains("raw error output"));
}
#[test]
fn mock_transport_returns_responses_in_order() {
let mock = MockTransport::new(vec![
Ok(serde_json::json!({"data": "first"})),
Ok(serde_json::json!({"data": "second"})),
]);
let r1 = mock.request(&[]).unwrap();
assert_eq!(r1["data"], "first");
let r2 = mock.request(&[]).unwrap();
assert_eq!(r2["data"], "second");
}
#[test]
fn mock_transport_exhausted_returns_error() {
let mock = MockTransport::new(vec![]);
let result = mock.request(&[]);
assert!(result.is_err());
}
#[test]
fn version_comparison_multi_digit() {
assert!(
semver::Version::parse("1.0.9").unwrap() < semver::Version::parse("1.0.10").unwrap()
);
assert!(
(semver::Version::parse("1.0.10").unwrap() >= semver::Version::parse("1.0.3").unwrap())
);
}
#[test]
fn version_comparison_major() {
assert!(
(semver::Version::parse("2.0.0").unwrap() >= semver::Version::parse("1.0.3").unwrap())
);
}
#[test]
fn version_comparison_prerelease() {
assert!(
semver::Version::parse("1.0.3-beta").unwrap()
< semver::Version::parse("1.0.3").unwrap()
);
}
}