resopt-cli 0.5.0

Plan, verify, and apply resource optimizations for Apple projects.
Documentation
use serde_json::json;
use std::{
    fs,
    io::{BufRead, BufReader, Read, Write},
    net::TcpStream,
    process::{Child, ChildStdout, Command, Stdio},
    time::Duration,
};

struct Server {
    child: Child,
    // Keep the pipe open until the child exits. Dropping a temporary reader
    // after the first line can interrupt the rest of the banner on Windows.
    stdout: BufReader<ChildStdout>,
}
impl Server {
    fn new(mut child: Child) -> Self {
        let stdout = BufReader::new(child.stdout.take().expect("piped server stdout"));
        Self { child, stdout }
    }
}
impl Drop for Server {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
        // Fields, including stdout, are dropped only after the child is reaped.
    }
}

fn request(address: &str, method: &str, route: &str, headers: &str, body: &str) -> String {
    let mut stream = TcpStream::connect(address).unwrap();
    stream
        .set_read_timeout(Some(Duration::from_secs(15)))
        .unwrap();
    write!(stream,"{method} {route} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}",body.len()).unwrap();
    let mut bytes = Vec::new();
    stream.read_to_end(&mut bytes).unwrap();
    let split = bytes.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
    let head = String::from_utf8(bytes[..split].to_vec()).unwrap();
    let mut body = bytes[split..].to_vec();
    if head
        .to_ascii_lowercase()
        .contains("transfer-encoding: chunked")
    {
        let mut decoded = Vec::new();
        let mut offset = 0;
        loop {
            let end = body[offset..]
                .windows(2)
                .position(|w| w == b"\r\n")
                .unwrap()
                + offset;
            let count = usize::from_str_radix(std::str::from_utf8(&body[offset..end]).unwrap(), 16)
                .unwrap();
            if count == 0 {
                break;
            }
            offset = end + 2;
            decoded.extend_from_slice(&body[offset..offset + count]);
            offset += count + 2;
        }
        body = decoded;
    }
    head + &String::from_utf8(body).unwrap()
}

#[test]
fn loopback_api_requires_session_and_origin_and_applies_only_selected_candidate() {
    let base = tempfile::tempdir().unwrap();
    let root = base.path().join("project");
    let asset = root.join("Assets.xcassets/Test.imageset");
    fs::create_dir_all(&asset).unwrap();
    fs::write(asset.join("Contents.json"),br#"{"images":[{"filename":"image.png","idiom":"universal"}],"info":{"version":1,"author":"xcode"}}"#).unwrap();
    let mut original = vec![];
    {
        let mut encoder = png::Encoder::new(&mut original, 64, 64);
        encoder.set_color(png::ColorType::Rgba);
        encoder.set_compression(png::Compression::NoCompression);
        encoder
            .write_header()
            .unwrap()
            .write_image_data(&[64, 128, 96, 255].repeat(4096))
            .unwrap();
    }
    fs::write(asset.join("image.png"), &original).unwrap();
    let plan_dir = base.path().join("plan");
    let plan = resopt::create_plan(
        &root,
        &plan_dir,
        resopt::Policy {
            min_input_bytes: 0,
            min_savings_bytes: 1,
            min_savings_percent: 0.0,
            ..Default::default()
        },
    )
    .unwrap();
    let candidate = &plan.candidates[0];
    let report_dir = base.path().join("report");
    fs::create_dir_all(report_dir.join("candidates")).unwrap();
    let optimized = fs::read(
        plan_dir
            .join("candidates")
            .join(format!("{}.png", candidate.optimized_sha256)),
    )
    .unwrap();
    fs::write(report_dir.join("candidates/0.png"), &optimized).unwrap();
    let report = json!({"schema_version":1,"root":plan.root,"backend":"test","options":{},"inventory":{"schema_version":2,"root":plan.root,"catalogs":1,"assets":[],"skipped_source_or_tooling_files":0,"excluded_directories":[],"diagnostics":[]},"resources":[{"resource":{"path":candidate.path,"bytes":candidate.original_bytes,"kind":"image","format":"png","extension":"png","extension_mismatch":false,"origin":"catalog_rendition","conversion_exclusion":null},"sha256":candidate.original_sha256,"image":null,"status":"candidates_available","issues":[],"candidates":[{"format":"png","quality":null,"lossy":false,"bytes":candidate.optimized_bytes,"savings_bytes":candidate.original_bytes-candidate.optimized_bytes,"valid":true,"rejection":null,"difference":null,"artifact":"candidates/0.png","preview":null}],"smallest_candidate":0,"original_preview":null,"original_artifact":null}],"status_counts":{"candidates_available":1},"potential_source_bytes_saved":candidate.original_bytes-candidate.optimized_bytes});
    fs::write(
        report_dir.join("analysis.json"),
        serde_json::to_vec(&report).unwrap(),
    )
    .unwrap();
    let mut server = Server::new(
        Command::new(env!("CARGO_BIN_EXE_resopt"))
            .args(["serve", report_dir.to_str().unwrap(), "--port", "0"])
            .stdout(Stdio::piped())
            .spawn()
            .unwrap(),
    );
    let mut line = String::new();
    server.stdout.read_line(&mut line).unwrap();
    let mut project_line = String::new();
    server.stdout.read_line(&mut project_line).unwrap();
    assert!(project_line.starts_with("Project: "), "{project_line}");
    let mut ready_line = String::new();
    server.stdout.read_line(&mut ready_line).unwrap();
    assert!(ready_line.starts_with("Stop with Ctrl-C."), "{ready_line}");
    let origin = line
        .trim()
        .strip_prefix("Review server: ")
        .unwrap()
        .trim_end_matches('/');
    let address = origin.strip_prefix("http://").unwrap();
    let page = request(address, "GET", "/", "", "");
    assert!(page.starts_with("HTTP/1.1 200"));
    let data = page
        .split("id=\"report-data\">")
        .nth(1)
        .unwrap()
        .split("</script>")
        .next()
        .unwrap();
    let data: serde_json::Value = serde_json::from_str(data).unwrap();
    let token = data["sessionToken"].as_str().unwrap();
    let headers = format!(
        "Content-Type: application/json\r\nOrigin: {origin}\r\nX-Resopt-Token: {token}\r\n"
    );
    let body = r#"{"resource":0,"candidate":0,"approve_lossy":false}"#;
    assert!(
        request(
            address,
            "POST",
            "/api/apply",
            "Content-Type: application/json\r\n",
            body
        )
        .starts_with("HTTP/1.1 403")
    );
    assert!(
        request(
            address,
            "POST",
            "/api/apply",
            &headers.replace(origin, "https://untrusted.example"),
            body
        )
        .starts_with("HTTP/1.1 403")
    );
    assert!(request(address, "GET", "/api/state", "", "").starts_with("HTTP/1.1 403"));
    assert!(
        request(address, "GET", "/operations/0/transaction.json", "", "")
            .starts_with("HTTP/1.1 404")
    );
    assert!(request(address, "GET", "/../Cargo.toml", "", "").starts_with("HTTP/1.1 404"));
    assert_eq!(fs::read(asset.join("image.png")).unwrap(), original);
    assert!(
        request(
            address,
            "POST",
            "/api/preview",
            "Content-Type: application/json\r\n",
            body
        )
        .starts_with("HTTP/1.1 403")
    );
    let preview = request(address, "POST", "/api/preview", &headers, body);
    assert!(preview.starts_with("HTTP/1.1 200"), "{preview}");
    let preview: serde_json::Value =
        serde_json::from_str(preview.split("\r\n\r\n").nth(1).unwrap()).unwrap();
    assert_eq!(fs::read(asset.join("image.png")).unwrap(), original);
    let reviewed = json!({"resource":0,"candidate":0,"approve_lossy":false,"plan_token":preview["plan_token"]}).to_string();
    let result = request(address, "POST", "/api/apply", &headers, &reviewed);
    assert!(result.starts_with("HTTP/1.1 200"), "{result}");
    assert_eq!(fs::read(asset.join("image.png")).unwrap(), optimized);
    let result = request(
        address,
        "POST",
        "/api/restore",
        &headers,
        r#"{"resource":0}"#,
    );
    assert!(result.starts_with("HTTP/1.1 200"), "{result}");
    assert_eq!(fs::read(asset.join("image.png")).unwrap(), original);
}

#[test]
fn web_analyzes_local_project_without_uploads_and_serves_review() {
    let base = tempfile::tempdir().unwrap();
    let root = base.path().join("project");
    fs::create_dir(&root).unwrap();
    fs::create_dir(root.join(".git")).unwrap();
    fs::write(root.join(".gitignore"), "ignored.png\n").unwrap();
    let mut original = Vec::new();
    {
        let mut encoder = png::Encoder::new(&mut original, 64, 64);
        encoder.set_color(png::ColorType::Rgba);
        encoder.set_compression(png::Compression::NoCompression);
        encoder
            .write_header()
            .unwrap()
            .write_image_data(&[40, 80, 120, 128].repeat(64 * 64))
            .unwrap();
    }
    fs::write(root.join("image.png"), &original).unwrap();
    fs::write(root.join("ignored.png"), &original).unwrap();
    let out = base.path().join("analysis");
    let mut server = Server::new(
        Command::new(env!("CARGO_BIN_EXE_resopt"))
            .args([
                "web",
                root.to_str().unwrap(),
                "--out",
                out.to_str().unwrap(),
                "--no-open",
                "--qualities",
                "85",
            ])
            .stdout(Stdio::piped())
            .spawn()
            .unwrap(),
    );
    let mut line = String::new();
    server.stdout.read_line(&mut line).unwrap();
    let origin = line
        .trim()
        .strip_prefix("Local web: ")
        .unwrap()
        .trim_end_matches('/')
        .to_string();
    let address = origin.strip_prefix("http://").unwrap();
    assert!(address.starts_with("127.0.0.1:"));
    let deadline = std::time::Instant::now() + Duration::from_secs(45);
    loop {
        let progress = request(address, "GET", "/api/progress", "", "");
        assert!(progress.starts_with("HTTP/1.1 200"), "{progress}");
        let progress: serde_json::Value =
            serde_json::from_str(progress.split("\r\n\r\n").nth(1).unwrap()).unwrap();
        assert!(progress["error"].is_null(), "{progress}");
        if progress["done"] == true {
            break;
        }
        assert!(std::time::Instant::now() < deadline, "analysis timed out");
        std::thread::sleep(Duration::from_millis(50));
    }
    let page = request(address, "GET", "/", "", "");
    assert!(page.contains("report-data"));
    let data = page
        .split("id=\"report-data\">")
        .nth(1)
        .unwrap()
        .split("</script>")
        .next()
        .unwrap();
    let data: serde_json::Value = serde_json::from_str(data).unwrap();
    let resources = data["resources"].as_array().unwrap();
    assert_eq!(resources.len(), 1);
    assert_eq!(resources[0]["resource"]["path"], "image.png");
    let candidates = resources[0]["candidates"].as_array().unwrap();
    assert!(
        candidates
            .iter()
            .any(|c| c["format"] == "png" && c["artifact"].is_string())
    );
    #[cfg(not(target_os = "macos"))]
    assert!(candidates.iter().all(|c| c["format"] == "png"));
    assert_eq!(fs::read(root.join("image.png")).unwrap(), original);
    let upload = request(
        address,
        "POST",
        "/api/convert",
        "Content-Type: application/octet-stream\r\n",
        "file bytes",
    );
    assert!(!upload.starts_with("HTTP/1.1 200"));
    assert!(request(address, "GET", "/../project/image.png", "", "").starts_with("HTTP/1.1 404"));
    let report = out.join("analysis.json");
    drop(server);
    assert!(report.is_file());
}

#[test]
fn web_rejects_existing_or_in_project_output_before_startup() {
    let root = tempfile::tempdir().unwrap();
    for out in [root.path().to_path_buf(), root.path().join("analysis")] {
        let result = Command::new(env!("CARGO_BIN_EXE_resopt"))
            .args([
                "web",
                root.path().to_str().unwrap(),
                "--out",
                out.to_str().unwrap(),
                "--no-open",
            ])
            .output()
            .unwrap();
        assert!(!result.status.success());
        assert!(result.stdout.is_empty());
    }
}