shell-download 0.22.1

Zero-dependency Rust library for downloading a remote URL to a file, string or bytes using commonly-available shell tools.
Documentation
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use dtor::dtor;

static SERVER_CHILD: LazyLock<Mutex<Option<Child>>> = LazyLock::new(|| Mutex::new(None));

static SERVER: LazyLock<Option<Server>> = LazyLock::new(|| start_server());

#[derive(Debug)]
struct Server {
    _root: PathBuf,
    base_url: String,
}

#[dtor(unsafe)]
fn shutdown_server() {
    if let Ok(mut guard) = SERVER_CHILD.lock() {
        if let Some(mut child) = guard.take() {
            let _ = child.kill();
            let _ = child.wait();
        }
    }
}

#[test]
fn localhost_python_curl() {
    hit_localhost(shell_download::Downloader::Curl);
}

#[test]
fn localhost_python_wget() {
    hit_localhost(shell_download::Downloader::Wget);
}

#[test]
fn localhost_python_powershell() {
    hit_localhost(shell_download::Downloader::PowerShell);
}

#[test]
fn localhost_python_tunnel() {
    hit_localhost(shell_download::Downloader::Tunnel);
}

fn hit_localhost(driver: shell_download::Downloader) {
    let Some(server) = SERVER.as_ref() else {
        return; // skip: python3 missing or server couldn't start
    };

    let mut out = std::env::temp_dir();
    out.push(unique_name(&format!("shell-download-localhost-{driver:?}")));

    let url = format!("{}/test-file.html", server.base_url.trim_end_matches('/'));
    let handle = shell_download::RequestBuilder::new(url)
        .quiet(shell_download::Quiet::Never)
        .preferred_downloader(driver)
        .start(&out);

    let handle = match handle {
        Ok(h) => h,
        Err(shell_download::StartError::NoDriverFound) => {
            if is_ci() {
                panic!("failed to start downloader in CI");
            }
            return;
        }
        Err(err) => panic!("failed to start: {err:?}"),
    };

    let resp = handle.join().expect("download failed");

    assert!(
        resp.status_code >= 200 && resp.status_code < 400,
        "unexpected status code: {}",
        resp.status_code
    );

    let body = std::fs::read_to_string(&out).expect("read output file");
    assert!(
        body.contains("Hello from python"),
        "body did not contain expected marker; got prefix: {:?}",
        body.chars().take(200).collect::<String>()
    );

    let _ = std::fs::remove_file(&out);
}

fn is_ci() -> bool {
    matches!(std::env::var("CI"), Ok(v) if !v.trim().is_empty() && v != "0" && v.to_lowercase() != "false")
}

fn start_server() -> Option<Server> {
    // Write deterministic content
    let root = make_temp_dir("shell-download-localhost-root")?;
    std::fs::write(root.join("test-file.html"), "Hello from python\n").ok()?;

    let mut child = Command::new("python3")
        .arg("-m")
        .arg("http.server")
        .arg("12121")
        .arg("--bind")
        .arg("127.0.0.1")
        .arg("--directory")
        .arg(&root)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .ok()?;

    if !wait_for_port("127.0.0.1", 12121, Duration::from_secs(3)) {
        let _ = child.kill();
        let _ = child.wait();
        return None;
    }

    if let Ok(mut guard) = SERVER_CHILD.lock() {
        *guard = Some(child);
    } else {
        let _ = child.kill();
        let _ = child.wait();
        return None;
    }

    Some(Server {
        _root: root,
        base_url: "http://127.0.0.1:12121".into(),
    })
}

fn wait_for_port(host: &str, port: u16, timeout: Duration) -> bool {
    let start = std::time::Instant::now();
    while start.elapsed() < timeout {
        if std::net::TcpStream::connect((host, port)).is_ok() {
            return true;
        }
        std::thread::sleep(Duration::from_millis(25));
    }
    false
}

fn make_temp_dir(prefix: &str) -> Option<PathBuf> {
    let mut p = std::env::temp_dir();
    p.push(unique_name(prefix));
    std::fs::create_dir_all(&p).ok()?;
    Some(p)
}

fn unique_name(prefix: &str) -> PathBuf {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis();
    PathBuf::from(format!("{prefix}-{}-{}", std::process::id(), now))
}

#[allow(dead_code)]
fn _touch(path: &Path) {
    let _ = std::fs::write(path, "");
}