autumn-cli 0.2.0

CLI tool for the Autumn web framework
use std::fs::File;
use std::io::Write;
use std::time::Duration;

use reqwest::blocking::Client;
use serde_json::{Value, json};

pub fn run(url: &str, output: &str) {
    if let Err(e) = run_inner(url, output) {
        eprintln!("{e}");
        std::process::exit(1);
    }
}

pub fn run_inner(url: &str, output: &str) -> Result<(), String> {
    let base_url = url.trim_end_matches('/');
    println!("Exporting diagnostics from {base_url}");

    let client = Client::builder()
        .timeout(Duration::from_secs(5))
        .no_proxy()
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {e}"))?;

    let health = fetch_endpoint(&client, base_url, "/actuator/health");
    let metrics = fetch_endpoint(&client, base_url, "/actuator/metrics");
    let tasks = fetch_endpoint(&client, base_url, "/actuator/tasks");
    let loggers = fetch_endpoint(&client, base_url, "/actuator/loggers");

    // If any endpoint returns an error, fail the export
    for (name, val) in [
        ("health", &health),
        ("metrics", &metrics),
        ("tasks", &tasks),
        ("loggers", &loggers),
    ] {
        if let Some(err) = val.get("error") {
            return Err(format!(
                "Failed to fetch {} from {base_url}: {}",
                name,
                err.as_str().unwrap_or("Unknown error")
            ));
        }
    }

    // Use std::time for a simple timestamp as chrono is not available
    let timestamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();

    let snapshot = json!({
        "timestamp": timestamp,
        "url": base_url,
        "health": health,
        "metrics": metrics,
        "tasks": tasks,
        "loggers": loggers,
    });

    match File::create(output) {
        Ok(mut file) => {
            let json_str = serde_json::to_string_pretty(&snapshot).unwrap();
            if let Err(e) = file.write_all(json_str.as_bytes()) {
                return Err(format!("Failed to write to file '{output}': {e}"));
            }
            println!("Successfully exported diagnostics to {output}");
            Ok(())
        }
        Err(e) => Err(format!("Failed to create file '{output}': {e}")),
    }
}

fn fetch_endpoint(client: &Client, base_url: &str, path: &str) -> Value {
    let full_url = format!("{base_url}{path}");
    match client.get(&full_url).send() {
        Ok(response) => {
            if response.status().is_success() || response.status().as_u16() == 503 {
                response
                    .json::<Value>()
                    .unwrap_or_else(|e| json!({ "error": format!("Failed to parse JSON: {}", e) }))
            } else {
                json!({ "error": format!("HTTP {}", response.status()) })
            }
        }
        Err(e) => {
            json!({ "error": format!("Request failed: {}", e) })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{BufRead, BufReader, Write};
    use std::net::TcpListener;
    use std::thread;

    fn spawn_mock_server(status_line: &str, body: &str, num_requests: usize) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        let url = format!("http://127.0.0.1:{port}");

        let status_line = status_line.to_string();
        let body = body.to_string();

        thread::spawn(move || {
            for _ in 0..num_requests {
                if let Ok((mut stream, _)) = listener.accept() {
                    let mut reader = BufReader::new(&mut stream);
                    let mut req_line = String::new();
                    if reader.read_line(&mut req_line).is_err() || req_line.is_empty() {
                        continue;
                    }

                    loop {
                        let mut header_line = String::new();
                        if reader.read_line(&mut header_line).is_err()
                            || header_line == "\r\n"
                            || header_line.trim().is_empty()
                        {
                            break;
                        }
                    }

                    let response = if body.is_empty() {
                        format!("{status_line}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
                    } else {
                        format!(
                            "{status_line}\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
                            body.len()
                        )
                    };
                    let _ = stream.write_all(response.as_bytes());
                }
            }
        });

        url
    }

    #[test]
    fn test_fetch_endpoint_success() {
        let url = spawn_mock_server("HTTP/1.1 200 OK", r#"{"status": "ok"}"#, 1);

        let client = Client::new();
        let val = fetch_endpoint(&client, &url, "/test");
        assert_eq!(val["status"], "ok");
    }

    #[test]
    fn test_fetch_endpoint_failure() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        drop(listener); // Close so connection fails

        let client = Client::builder()
            .timeout(Duration::from_millis(100))
            .build()
            .unwrap();
        let val = fetch_endpoint(&client, &format!("http://127.0.0.1:{port}"), "/test");
        assert!(val.get("error").is_some());
    }

    #[test]
    fn test_fetch_endpoint_404() {
        let url = spawn_mock_server("HTTP/1.1 404 NOT FOUND", "", 1);

        let client = Client::new();
        let val = fetch_endpoint(&client, &url, "/test");
        assert!(val["error"].as_str().unwrap_or("").contains("HTTP 404"));
    }

    #[test]
    fn test_run_inner_success() {
        let url = spawn_mock_server("HTTP/1.1 200 OK", r#"{"status": "ok"}"#, 4);

        let output_file = tempfile::NamedTempFile::new().unwrap();
        let output_path = output_file.path().to_str().unwrap();

        let result = run_inner(&url, output_path);
        assert!(result.is_ok());

        let contents = std::fs::read_to_string(output_path).unwrap();
        let json: Value = serde_json::from_str(&contents).unwrap();

        assert_eq!(json["url"], url);
        assert_eq!(json["health"]["status"], "ok");
        assert_eq!(json["metrics"]["status"], "ok");
        assert_eq!(json["tasks"]["status"], "ok");
        assert_eq!(json["loggers"]["status"], "ok");
    }

    #[test]
    fn test_fetch_endpoint_invalid_json() {
        let url = spawn_mock_server("HTTP/1.1 200 OK", r#"{"status": "ok", "#, 1); // Invalid JSON

        let client = Client::new();
        let val = fetch_endpoint(&client, &url, "/test");
        assert!(
            val["error"]
                .as_str()
                .unwrap()
                .contains("Failed to parse JSON")
        );
    }

    #[test]
    fn test_run_inner_file_creation_error() {
        let url = spawn_mock_server("HTTP/1.1 200 OK", r#"{"status": "ok"}"#, 4);

        // Use an invalid path that cannot be created
        let result = run_inner(&url, "/invalid/path/that/does/not/exist/diag.json");
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to create file"));
    }
}