ci-bill 0.1.1

Rank GitHub Actions cost per workflow from the billing usage CSV and point to likely causes
Documentation
use std::process::Command;

fn money_micros(text: &str) -> i64 {
    let negative = text.starts_with('-');
    let text = text.trim_start_matches('-');
    let (whole, frac) = text.split_once('.').expect("decimal");
    assert_eq!(frac.len(), 6, "{text}");
    let value = whole.parse::<i64>().unwrap() * 1_000_000 + frac.parse::<i64>().unwrap();
    if negative {
        -value
    } else {
        value
    }
}

fn assert_money(value: &serde_json::Value) {
    let text = value
        .as_str()
        .unwrap_or_else(|| panic!("money string: {value}"));
    let digits = text.trim_start_matches('-');
    let (whole, frac) = digits.split_once('.').unwrap_or_else(|| panic!("{text}"));
    assert!(
        !whole.is_empty() && whole.chars().all(|c| c.is_ascii_digit()),
        "{text}"
    );
    assert_eq!(frac.len(), 6, "{text}");
    assert!(frac.chars().all(|c| c.is_ascii_digit()), "{text}");
}

fn report(args: &[&str]) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_ci-bill"))
        .current_dir(env!("CARGO_MANIFEST_DIR"))
        .args(args)
        .output()
        .expect("run ci-bill")
}

#[test]
fn sample_report_matches_snapshot() {
    let output = report(&[
        "report",
        "--usage",
        "samples/usage-sample.csv",
        "--workflows",
        "samples/.github/workflows",
    ]);
    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "stderr:\n{stderr}\nstdout:\n{stdout}"
    );
    assert!(stderr.is_empty(), "{stderr}");
    assert!(stdout.contains("2026-09-01 .. 2026-09-30"), "{stdout}");
    assert!(stdout.contains("example-org/sample-app"), "{stdout}");
    assert!(stdout.contains("Other products: 0.00\n"), "{stdout}");
    let expected = include_str!("snapshots/report.txt");
    assert_eq!(stdout, expected);
    assert!(!stdout.contains("Sample"), "{stdout}");
    assert!(!stdout.starts_with('{'), "{stdout}");
}

#[test]
fn sample_json_matches_snapshot_and_fields() {
    let output = report(&[
        "report",
        "--usage",
        "samples/usage-sample.csv",
        "--workflows",
        "samples/.github/workflows",
        "--json",
    ]);
    let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "stderr:\n{stderr}\nstdout:\n{stdout}"
    );
    assert!(stderr.is_empty(), "{stderr}");
    assert!(!stdout.contains("USD (from net_amount)"), "{stdout}");
    assert!(!stdout.contains("Sample"), "{stdout}");
    let expected = include_str!("snapshots/report.json");
    assert_eq!(stdout, expected);

    assert!(!stdout.contains("0000000001"), "{stdout}");
    assert!(!stdout.contains("9999999"), "{stdout}");
    let value: serde_json::Value =
        serde_json::from_str(stdout.trim_end()).expect("one json object");
    assert_eq!(value["schema_version"], 2);
    assert_eq!(value["currency"], "USD");
    assert_eq!(value["money_unit"], "USD");
    assert_eq!(value["total_net"], "284.208000");
    assert_eq!(value["other_products_net"], "0.000000");
    assert!(value["total_minutes"].is_number(), "{value}");
    assert_eq!(value["total_minutes"].as_i64(), Some(12208));
    assert_eq!(value["period"]["from"], "2026-09-01");
    assert_eq!(value["period"]["to"], "2026-09-30");
    assert_eq!(
        value["repositories"],
        serde_json::json!(["example-org/sample-app"])
    );

    let workflows = value["workflows"].as_array().expect("workflows");
    assert_eq!(workflows.len(), 5);
    let paths: Vec<_> = workflows
        .iter()
        .map(|item| item["path"].as_str().expect("path"))
        .collect();
    assert_eq!(
        paths,
        vec![
            ".github/workflows/lint.yml",
            ".github/workflows/ci.yml",
            ".github/workflows/release.yml",
            ".github/workflows/docs.yml",
            "(no workflow)",
        ]
    );

    let total_net = money_micros(value["total_net"].as_str().unwrap());
    let mut net_sum = 0;
    for workflow in workflows {
        assert_money(workflow.get("net").unwrap());
        assert_money(workflow.get("gross").unwrap());
        assert_money(workflow.get("discount").unwrap());
        assert!(workflow["minutes"].is_number());
        assert_money(workflow.get("share").unwrap());
        assert_eq!(workflow["repository"], "example-org/sample-app");
        net_sum += money_micros(workflow["net"].as_str().unwrap());
        for sku in workflow["skus"].as_array().expect("skus") {
            assert!(sku["sku"].is_string());
            assert_money(sku.get("net").unwrap());
            assert!(sku["minutes"].is_number());
            assert!(sku.get("gross").is_none());
        }
        for finding in workflow["findings"].as_array().expect("findings") {
            assert!(finding["id"].is_string());
            assert!(finding["file"].is_string());
            assert!(finding["line"].is_number());
            assert!(finding["message"].is_string());
            assert_money(finding.get("savings").expect("savings"));
            assert!(finding["method"].is_string());
        }
    }
    assert_eq!(net_sum, total_net);

    let lint = &workflows[0];
    assert_eq!(lint["net"], "230.144000");
    assert_eq!(lint["minutes"].as_i64(), Some(3712));
    assert_eq!(lint["skus"][0]["sku"], "actions_macos");
    assert_eq!(lint["skus"].as_array().unwrap().len(), 1);
    let lint_findings = lint["findings"].as_array().unwrap();
    assert_eq!(lint_findings.len(), 3);
    assert_eq!(lint_findings[0]["id"], "F1");
    assert_eq!(lint_findings[0]["line"], 8);
    assert_eq!(lint_findings[0]["message"], "lint on macOS");
    assert_eq!(
        lint_findings[0]["file"],
        "samples/.github/workflows/lint.yml"
    );
    assert_eq!(lint_findings[0]["savings"], "207.872000");
    assert_eq!(lint_findings[0]["method"], "linux_rate");
    assert_eq!(lint_findings[1]["id"], "F2");
    assert_eq!(lint_findings[1]["line"], 7);
    assert_eq!(lint_findings[1]["savings"], "22.320000");
    assert_eq!(lint_findings[1]["method"], "stuck_run");
    assert_eq!(lint_findings[2]["id"], "F3");
    assert_eq!(lint_findings[2]["line"], 2);
    assert_eq!(
        lint_findings[2]["message"],
        "pull_request without concurrency cancel-in-progress"
    );
    assert_eq!(lint_findings[2]["savings"], "0.000000");
    assert_eq!(lint_findings[2]["method"], "overlap");
    assert_eq!(
        lint["steps"],
        serde_json::json!([
            {"line": 10, "name": "actions/checkout@v4"},
            {"line": 11, "name": "cargo fmt --check"},
            {"line": 12, "name": "cargo clippy -- -D warnings"}
        ])
    );
    for step in lint["steps"].as_array().unwrap() {
        assert_eq!(step.as_object().unwrap().len(), 2, "{step}");
        assert!(step["line"].is_number());
        assert!(step["name"].is_string());
    }

    assert!(workflows[1]["findings"].as_array().unwrap().is_empty());
    assert_eq!(
        workflows[1]["steps"],
        serde_json::json!([
            {"line": 12, "name": "actions/checkout@v4"},
            {"line": 13, "name": "cargo test"}
        ])
    );
    assert_eq!(workflows[1]["skus"][0]["sku"], "actions_linux");
    assert_eq!(workflows[1]["minutes"].as_i64(), Some(7372));
    assert_eq!(workflows[1]["net"], "44.232000");

    assert!(workflows[2]["findings"].as_array().unwrap().is_empty());
    assert_eq!(
        workflows[2]["steps"],
        serde_json::json!([
            {"line": 10, "name": "actions/checkout@v4"},
            {"line": 11, "name": "cargo build --release"}
        ])
    );
    assert_eq!(workflows[2]["skus"][0]["sku"], "actions_windows");
    assert_eq!(workflows[2]["minutes"].as_i64(), Some(772));
    assert_eq!(workflows[2]["net"], "7.720000");

    let docs = &workflows[3];
    assert_eq!(docs["skus"][0]["sku"], "actions_linux");
    assert_eq!(docs["minutes"].as_i64(), Some(352));
    assert_eq!(docs["net"], "2.112000");
    let docs_findings = docs["findings"].as_array().unwrap();
    assert_eq!(docs_findings.len(), 1);
    assert_eq!(docs_findings[0]["id"], "F2");
    assert_eq!(docs_findings[0]["line"], 4);
    assert_eq!(
        docs_findings[0]["file"],
        "samples/.github/workflows/docs.yml"
    );
    assert_eq!(docs_findings[0]["savings"], "2.160000");
    assert_eq!(docs_findings[0]["method"], "stuck_run");
    assert_eq!(
        docs_findings[0]["message"],
        "default timeout is 360 minutes"
    );
    assert_eq!(
        docs["steps"],
        serde_json::json!([
            {"line": 7, "name": "actions/checkout@v4"},
            {"line": 8, "name": "cargo doc --no-deps"}
        ])
    );

    let storage = &workflows[4];
    assert_eq!(storage["path"], "(no workflow)");
    assert_eq!(storage["net"], "0.000000");
    assert_eq!(storage["minutes"].as_i64(), Some(0));
    assert_eq!(storage["gross"], "0.005040");
    assert_eq!(storage["skus"][0]["sku"], "actions_storage");
    assert!(storage["findings"].as_array().unwrap().is_empty());
    assert_eq!(storage["steps"], serde_json::json!([]));

    assert!(value["unattached_findings"].as_array().unwrap().is_empty());
    assert!(value.get("budget").is_none(), "{value}");
    assert!(value.get("budget_exceeded").is_none(), "{value}");
}