starbase_utils 0.14.0

General fs, io, serde, net, etc, utilities.
Documentation
use starbase_sandbox::create_empty_sandbox;
use starbase_utils::net::{self, NetError};
use std::collections::HashMap;
use std::future::Future;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;

fn run_async<T>(future: impl Future<Output = T>) -> T {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(future)
}

fn headers_with_custom_header() -> HashMap<String, String> {
    let mut headers = HashMap::new();
    headers.insert(
        "X-Proto-Test-Header".to_string(),
        "proto-starbase-headers-test".to_string(),
    );
    headers
}

fn read_request(mut stream: &std::net::TcpStream) -> String {
    let mut request = Vec::new();
    let mut buffer = [0; 512];

    loop {
        let read = stream.read(&mut buffer).unwrap();

        if read == 0 {
            break;
        }

        request.extend_from_slice(&buffer[..read]);

        if request.windows(4).any(|window| window == b"\r\n\r\n") {
            break;
        }
    }

    String::from_utf8(request).unwrap()
}

fn serve_http_once<F>(handler: F) -> String
where
    F: FnOnce(std::net::TcpStream) + Send + 'static,
{
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let url = format!("http://{}", listener.local_addr().unwrap());

    thread::spawn(move || {
        let (stream, _) = listener.accept().unwrap();
        handler(stream);
    });

    url
}

fn serve_response_once(status: &'static str, body: &'static [u8]) -> String {
    serve_http_once(move |mut stream| {
        read_request(&stream);

        write!(
            stream,
            "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
            body.len()
        )
        .unwrap();
        stream.write_all(body).unwrap();
    })
}

mod offline {
    use super::*;

    #[test]
    fn checks_custom_hosts() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let host = listener.local_addr().unwrap().to_string();

        thread::spawn(move || {
            let _ = listener.accept();
        });

        assert!(!net::is_offline_with_options(net::OfflineOptions {
            check_default_hosts: false,
            check_default_ips: false,
            custom_hosts: vec![host],
            timeout: 1000,
            ..Default::default()
        }));
    }

    #[test]
    fn times_out_custom_hosts() {
        assert!(net::is_offline_with_options(net::OfflineOptions {
            check_default_hosts: false,
            check_default_ips: false,
            custom_hosts: vec!["example.invalid:80".into()],
            timeout: 1,
            ..Default::default()
        }));
    }
}

mod download {
    use super::*;

    #[test]
    fn errors_invalid_url() {
        let sandbox = create_empty_sandbox();
        let error = run_async(net::download_from_url(
            "raw.githubusercontent.com/moonrepo/starbase/master/README.md",
            sandbox.path().join("README.md"),
        ))
        .unwrap_err();

        assert!(matches!(error, NetError::UrlParseFailed { .. }));
    }

    #[test]
    fn errors_not_found() {
        let sandbox = create_empty_sandbox();
        let url = serve_response_once("404 Not Found", b"nope");
        let error = run_async(net::download_from_url(
            format!("{url}/UNKNOWN_FILE.md"),
            sandbox.path().join("README.md"),
        ))
        .unwrap_err();

        assert!(matches!(error, NetError::UrlNotFound { .. }));
    }

    #[test]
    fn downloads_a_file() {
        let sandbox = create_empty_sandbox();
        let dest_file = sandbox.path().join("README.md");
        let url = serve_response_once("200 OK", b"# starbase\n");

        assert!(!dest_file.exists());

        run_async(net::download_from_url(
            format!("{url}/README.md"),
            &dest_file,
        ))
        .unwrap();

        assert_eq!(std::fs::read_to_string(&dest_file).unwrap(), "# starbase\n");
    }

    #[test]
    fn downloads_with_options_headers() {
        let sandbox = create_empty_sandbox();
        let dest_file = sandbox.path().join("headers.json");
        let (sender, receiver) = mpsc::channel();
        let url = serve_http_once(move |mut stream| {
            let request = read_request(&stream);
            sender.send(request).unwrap();

            stream
                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}")
                .unwrap();
        });

        run_async(net::download_from_url_with_options(
            format!("{url}/headers"),
            &dest_file,
            net::DownloadOptions {
                downloader: Some(Box::new(
                    net::DefaultDownloader::new_with_headers(headers_with_custom_header()).unwrap(),
                )),
                ..Default::default()
            },
        ))
        .unwrap();

        let request = receiver.recv_timeout(Duration::from_secs(1)).unwrap();
        assert!(request.contains("x-proto-test-header: proto-starbase-headers-test"));
        assert_eq!(std::fs::read_to_string(&dest_file).unwrap(), "{}");
    }

    #[test]
    fn reports_progress_while_streaming() {
        let sandbox = create_empty_sandbox();
        let dest_file = sandbox.path().join("stream.txt");
        let progress = Arc::new(Mutex::new(Vec::new()));
        let progress_for_callback = Arc::clone(&progress);
        let url = serve_http_once(move |mut stream| {
            read_request(&stream);

            stream
                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\n")
                .unwrap();
            stream.write_all(b"hello ").unwrap();
            stream.flush().unwrap();
            thread::sleep(Duration::from_millis(10));
            stream.write_all(b"world").unwrap();
        });

        run_async(net::download_from_url_with_options(
            format!("{url}/stream.txt"),
            &dest_file,
            net::DownloadOptions {
                on_chunk: Some(Arc::new(move |current, total| {
                    progress_for_callback.lock().unwrap().push((current, total));
                })),
                ..Default::default()
            },
        ))
        .unwrap();

        assert_eq!(std::fs::read_to_string(&dest_file).unwrap(), "hello world");

        let progress = progress.lock().unwrap();
        assert_eq!(progress.first(), Some(&(0, 11)));
        assert_eq!(progress.last(), Some(&(11, 11)));
    }

    #[test]
    fn removes_partial_file_on_stream_error() {
        let sandbox = create_empty_sandbox();
        let dest_file = sandbox.path().join("partial.txt");
        let url = serve_http_once(move |mut stream| {
            read_request(&stream);

            stream
                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\n")
                .unwrap();
            stream.write_all(b"partial").unwrap();
        });

        let error = run_async(net::download_from_url(
            format!("{url}/partial.txt"),
            &dest_file,
        ))
        .unwrap_err();

        assert!(matches!(error, NetError::Http { .. }));
        assert!(!dest_file.exists());
    }
}