ci-bill 0.1.0

Rank GitHub Actions cost per workflow from the billing usage CSV and point to likely causes
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;

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")
}

fn stdout(output: &std::process::Output) -> String {
    String::from_utf8(output.stdout.clone()).expect("utf-8 stdout")
}

fn stderr(output: &std::process::Output) -> String {
    String::from_utf8(output.stderr.clone()).expect("utf-8 stderr")
}

fn assert_exit(output: &std::process::Output, code: i32) {
    assert_eq!(
        output.status.code(),
        Some(code),
        "stdout:\n{}\nstderr:\n{}",
        stdout(output),
        stderr(output)
    );
}

fn assert_no_limitation(text: &str) {
    let lower = text.to_ascii_lowercase();
    assert!(!lower.contains("not supported"), "{text}");
    assert!(!lower.contains("unsupported"), "{text}");
    assert!(!lower.contains("skipped"), "{text}");
    assert!(!lower.contains("not implemented"), "{text}");
}

fn scratch(name: &str) -> PathBuf {
    let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
        .join("remote-workflow")
        .join(name);
    let _ = std::fs::remove_dir_all(&path);
    std::fs::create_dir_all(&path).unwrap();
    path
}

fn write(path: &Path, text: &str) {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).unwrap();
    }
    std::fs::write(path, text).unwrap();
}

const USAGE: &str = "\
date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path
2026-09-01,actions,actions_linux,10,minutes,0.006000,0.060000,0,0.060000,ci,example-org,example-org/app,.github/workflows/caller.yml
2026-09-02,actions,actions_linux,10,minutes,0.010000,0.100000,0,0.100000,ci,example-org,example-org/app,.github/workflows/other.yml
";

const CALLER: &str = "\
name: Caller
on: push
jobs:
  call-lint:
    uses: acme/tools/.github/workflows/lint.yml@v1
    with:
      extra: \"1\"
    secrets: inherit
";

const CALLEE: &str = "\
name: Lint
on: workflow_call
jobs:
  lint:
    runs-on: macos-latest
    timeout-minutes: 10
    steps:
      - run: cargo fmt --check
";

fn layout(name: &str) -> (PathBuf, PathBuf, PathBuf) {
    let root = scratch(name);
    let workflows = root.join("workflows");
    let runs = root.join("runs");
    std::fs::create_dir_all(&workflows).unwrap();
    std::fs::create_dir_all(&runs).unwrap();
    write(&root.join("usage.csv"), USAGE);
    write(&workflows.join("caller.yml"), CALLER);
    (root.join("usage.csv"), workflows, runs)
}

#[test]
fn cli_remote_without_cache_shows_the_uses_line() {
    let (usage, workflows, runs) = layout("without-cache");
    let output = report(&[
        "report",
        "--usage",
        usage.to_str().unwrap(),
        "--workflows",
        workflows.to_str().unwrap(),
        "--runs",
        runs.to_str().unwrap(),
    ]);
    assert_exit(&output, 0);
    assert!(stderr(&output).is_empty(), "{}", stderr(&output));
    let text = stdout(&output);
    assert_no_limitation(&text);
    assert!(
        text.contains("example-org/app .github/workflows/caller.yml"),
        "{text}"
    );
    assert!(text.contains("0.06"), "{text}");
    assert!(text.contains("0.10"), "{text}");
    assert!(
        text.contains("F10 ") && text.contains("caller.yml:5:"),
        "{text}"
    );
    assert!(
        text.contains("job call-lint uses acme/tools/.github/workflows/lint.yml@v1"),
        "{text}"
    );
    assert!(!text.contains("Workflows without usage"), "{text}");
    let caller = text
        .find("example-org/app .github/workflows/caller.yml")
        .unwrap();
    let other = text
        .find("example-org/app .github/workflows/other.yml")
        .unwrap();
    assert!(other < caller, "{text}");
    assert!(!text[other..caller].contains("F10 "), "{text}");
    assert!(text[caller..].contains("F10 "), "{text}");
}

#[test]
fn cli_remote_cache_file_shows_callee_findings() {
    let (usage, workflows, runs) = layout("with-cache");
    write(
        &runs.join("acme/tools/.github/workflows/lint.yml@v1"),
        CALLEE,
    );
    let output = report(&[
        "report",
        "--usage",
        usage.to_str().unwrap(),
        "--workflows",
        workflows.to_str().unwrap(),
        "--runs",
        runs.to_str().unwrap(),
        "--json",
    ]);
    assert_exit(&output, 0);
    assert!(stderr(&output).is_empty(), "{}", stderr(&output));
    let text = stdout(&output);
    assert_no_limitation(&text);
    let json: serde_json::Value = serde_json::from_str(text.trim_end()).expect("json");
    assert_eq!(json["schema_version"], 2);
    let caller = json["workflows"]
        .as_array()
        .unwrap()
        .iter()
        .find(|item| item["path"] == ".github/workflows/caller.yml")
        .unwrap();
    assert_eq!(caller["net"], "0.060000");
    let findings = caller["findings"].as_array().unwrap();
    assert!(
        findings.iter().any(|item| {
            item["id"] == "F1"
                && item["line"] == 5
                && item["message"] == "lint on macOS"
                && item["file"]
                    .as_str()
                    .unwrap()
                    .ends_with("acme/tools/.github/workflows/lint.yml@v1")
        }),
        "{findings:?}"
    );
    assert!(json["unattached_findings"].as_array().unwrap().is_empty());
    let other = json["workflows"]
        .as_array()
        .unwrap()
        .iter()
        .find(|item| item["path"] == ".github/workflows/other.yml")
        .unwrap();
    assert_eq!(other["net"], "0.100000");
    assert!(other["findings"].as_array().unwrap().is_empty());
}

#[test]
fn cli_invalid_uses_is_a_finding() {
    let (usage, workflows, _) = layout("invalid");
    write(
        &workflows.join("caller.yml"),
        "name: Caller\non: push\njobs:\n  call-lint:\n    uses: \"\"\n  remote:\n    uses: acme/tools/.github/workflows/lint.yml@\n",
    );
    let output = report(&[
        "report",
        "--usage",
        usage.to_str().unwrap(),
        "--workflows",
        workflows.to_str().unwrap(),
    ]);
    assert_exit(&output, 0);
    let text = stdout(&output);
    assert_no_limitation(&text);
    assert!(text.contains("F11 "), "{text}");
    assert!(
        text.contains("invalid reusable workflow uses: job call-lint uses \"\""),
        "{text}"
    );
    assert!(
        text.contains("invalid reusable workflow uses: job remote uses \"acme/tools/.github/workflows/lint.yml@\""),
        "{text}"
    );
    assert!(text.contains("caller.yml:5:"), "{text}");
    assert!(text.contains("caller.yml:7:"), "{text}");
}

#[test]
fn cli_bad_cache_yaml_exits_2_without_the_body() {
    let (usage, workflows, runs) = layout("bad-yaml");
    write(
        &runs.join("acme/tools/.github/workflows/lint.yml@v1"),
        "jobs: [\nUNIQUE_SECRET_TOKEN\n",
    );
    let output = report(&[
        "report",
        "--usage",
        usage.to_str().unwrap(),
        "--workflows",
        workflows.to_str().unwrap(),
        "--runs",
        runs.to_str().unwrap(),
    ]);
    assert_exit(&output, 2);
    let err = stderr(&output);
    assert!(err.contains("invalid YAML"), "{err}");
    assert!(err.contains("line "), "{err}");
    assert!(!err.contains("UNIQUE_SECRET_TOKEN"), "{err}");
    assert!(!stdout(&output).contains("UNIQUE_SECRET_TOKEN"));
    assert_no_limitation(&err);
}

#[test]
fn cli_cache_coexists_with_saved_runs() {
    let (usage, workflows, runs) = layout("with-api");
    write(
        &runs.join("acme/tools/.github/workflows/lint.yml@feature/widget"),
        CALLEE,
    );
    write(
        &workflows.join("caller.yml"),
        "name: Caller\r\non: push\r\njobs:\r\n  call-lint:\r\n    secrets: inherit\r\n    uses: acme/tools/.github/workflows/lint.yml@feature/widget\r\n",
    );
    write(
        &runs.join("runs-1.json"),
        r#"{
  "workflow_runs": [{
    "id": 1001,
    "path": ".github/workflows/caller.yml",
    "run_attempt": 1,
    "run_number": 4,
    "event": "push",
    "conclusion": "success",
    "run_started_at": "2026-03-01T10:00:00Z"
  }]
}"#,
    );
    write(
        &runs.join("jobs-1001.json"),
        r#"{
  "jobs": [{
    "id": 501,
    "run_id": 1001,
    "run_attempt": 1,
    "name": "build",
    "labels": ["ubuntu-latest"],
    "started_at": "2026-03-01T10:00:05Z",
    "completed_at": "2026-03-01T10:02:05Z",
    "conclusion": "success",
    "runner_name": "runner"
  }]
}"#,
    );
    let output = report(&[
        "report",
        "--usage",
        usage.to_str().unwrap(),
        "--workflows",
        workflows.to_str().unwrap(),
        "--runs",
        runs.to_str().unwrap(),
    ]);
    assert_exit(&output, 0);
    assert!(stderr(&output).is_empty(), "{}", stderr(&output));
    let text = stdout(&output);
    assert_no_limitation(&text);
    assert!(text.contains("lint on macOS"), "{text}");
    assert!(text.contains("lint.yml@feature/widget"), "{text}");
    assert!(text.contains("build"), "{text}");
    assert!(text.contains("0.06"), "{text}");
}