distronomicon 0.2.0

GitHub release updater for Linux binaries
Documentation
use std::{fs, os::unix};

use assert_cmd::cargo::cargo_bin_cmd;
use camino::Utf8PathBuf;
use camino_tempfile::Utf8TempDir;
use jiff::Timestamp;
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{method, path},
};

fn create_state_file(state_dir: &Utf8PathBuf, app: &str, tag: &str, etag: &str) {
    let state_path = state_dir.join(app).join("state.json");
    fs::create_dir_all(state_path.parent().unwrap()).unwrap();

    let now = Timestamp::now();
    let state = serde_json::json!({
        "latest_tag": tag,
        "etag": etag,
        "last_modified": now.to_string(),
        "installed_at": now.to_string(),
    });

    fs::write(state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
}

fn create_installed_version(install_root: &Utf8PathBuf, app: &str, tag: &str) {
    let releases_dir = install_root.join(app).join("releases").join(tag);
    let bin_dir = install_root.join(app).join("bin");

    fs::create_dir_all(&releases_dir).unwrap();
    fs::create_dir_all(&bin_dir).unwrap();

    let binary_path = releases_dir.join(app);
    fs::write(&binary_path, "fake binary").unwrap();

    let symlink_path = bin_dir.join(app);
    unix::fs::symlink(format!("../releases/{tag}/{app}"), symlink_path).unwrap();
}

#[tokio::test]
async fn check_with_304_not_modified() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(
            ResponseTemplate::new(304)
                .insert_header("etag", "\"abc123\"")
                .insert_header("last-modified", "Mon, 27 Oct 2025 12:00:00 GMT"),
        )
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    create_state_file(&state_dir, "myapp", "v1.0.0", "\"abc123\"");
    create_installed_version(&install_root, "myapp", "v1.0.0");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    insta::assert_snapshot!(stdout);
}

#[tokio::test]
async fn check_with_update_available() {
    let mock_server = MockServer::start().await;

    let release_json = serde_json::json!({
        "tag_name": "v1.1.0",
        "prerelease": false,
        "draft": false,
        "assets": [{
            "name": "myapp.tar.gz",
            "url": "https://api.github.com/repos/owner/repo/releases/assets/1",
            "browser_download_url": "https://github.com/owner/repo/releases/download/v1.1.0/myapp.tar.gz",
            "size": 1024
        }]
    });

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(&release_json)
                .insert_header("etag", "\"def456\"")
                .insert_header("last-modified", "Tue, 28 Oct 2025 12:00:00 GMT"),
        )
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    create_state_file(&state_dir, "myapp", "v1.0.0", "\"abc123\"");
    create_installed_version(&install_root, "myapp", "v1.0.0");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    insta::assert_snapshot!(stdout);
}

#[tokio::test]
async fn check_no_current_version() {
    let mock_server = MockServer::start().await;

    let release_json = serde_json::json!({
        "tag_name": "v1.0.0",
        "prerelease": false,
        "draft": false,
        "assets": [{
            "name": "myapp.tar.gz",
            "url": "https://api.github.com/repos/owner/repo/releases/assets/1",
            "browser_download_url": "https://github.com/owner/repo/releases/download/v1.0.0/myapp.tar.gz",
            "size": 1024
        }]
    });

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(&release_json)
                .insert_header("etag", "\"abc123\"")
                .insert_header("last-modified", "Mon, 27 Oct 2025 12:00:00 GMT"),
        )
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    insta::assert_snapshot!(stdout);
}

#[tokio::test]
async fn check_network_error_exits_1() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(ResponseTemplate::new(500))
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(1));
}

#[tokio::test]
async fn check_first_run_no_state() {
    let mock_server = MockServer::start().await;

    let release_json = serde_json::json!({
        "tag_name": "v1.0.0",
        "prerelease": false,
        "draft": false,
        "assets": [{
            "name": "myapp.tar.gz",
            "url": "https://api.github.com/repos/owner/repo/releases/assets/1",
            "browser_download_url": "https://github.com/owner/repo/releases/download/v1.0.0/myapp.tar.gz",
            "size": 1024
        }]
    });

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(&release_json)
                .insert_header("etag", "\"abc123\"")
                .insert_header("last-modified", "Mon, 27 Oct 2025 12:00:00 GMT"),
        )
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    insta::assert_snapshot!(stdout);

    let state_path = state_dir.join("myapp").join("state.json");
    assert!(!state_path.exists());
}

#[tokio::test]
async fn state_validators_updated_on_304() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/repos/owner/repo/releases/latest"))
        .respond_with(
            ResponseTemplate::new(304)
                .insert_header("etag", "\"new-etag\"")
                .insert_header("last-modified", "Wed, 29 Oct 2025 12:00:00 GMT"),
        )
        .mount(&mock_server)
        .await;

    let temp_dir = Utf8TempDir::new().unwrap();
    let state_dir = temp_dir.path().join("state");
    let install_root = temp_dir.path().join("opt");

    create_state_file(&state_dir, "myapp", "v1.0.0", "\"old-etag\"");
    create_installed_version(&install_root, "myapp", "v1.0.0");

    let mut cmd = cargo_bin_cmd!("distronomicon");
    let output = cmd
        .arg("--app")
        .arg("myapp")
        .arg("--install-root")
        .arg(install_root.as_str())
        .arg("check")
        .arg("--repo")
        .arg("owner/repo")
        .arg("--state-directory")
        .arg(state_dir.as_str())
        .arg("--github-host")
        .arg(mock_server.uri())
        .output()
        .unwrap();

    assert_eq!(output.status.code(), Some(0));

    let stdout = String::from_utf8_lossy(&output.stdout);
    insta::assert_snapshot!(stdout);

    let state_path = state_dir.join("myapp").join("state.json");
    assert!(state_path.exists());

    let state_contents = fs::read_to_string(&state_path).unwrap();
    let state: serde_json::Value = serde_json::from_str(&state_contents).unwrap();

    assert_eq!(state["etag"].as_str(), Some("\"new-etag\""));
}