use std::process::Command;
fn run(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_ci-bill"))
.args(args)
.output()
.expect("run ci-bill")
}
fn stdout(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &std::process::Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
#[test]
fn help_shows_options() {
let top = run(&["--help"]);
assert!(
top.status.success(),
"ci-bill --help failed\n{}",
stderr(&top)
);
assert!(
stdout(&top).contains("report"),
"top-level help should name the report command:\n{}",
stdout(&top)
);
let output = run(&["report", "--help"]);
assert!(
output.status.success(),
"ci-bill report --help failed\n{}",
stderr(&output)
);
let text = stdout(&output);
assert!(text.contains("--usage"), "{text}");
assert!(text.contains("--workflows"), "{text}");
assert!(text.contains("--runs"), "{text}");
assert!(text.contains("--top"), "{text}");
assert!(text.contains("--json"), "{text}");
assert!(text.contains("--budget"), "{text}");
assert!(text.contains("--fix-diff"), "{text}");
assert!(text.contains("--md"), "{text}");
}
#[test]
fn unknown_option_exits_3() {
let output = run(&["--no-such-option"]);
assert_eq!(
output.status.code(),
Some(3),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let nested = run(&["report", "--usage", "usage.csv", "--no-such-option"]);
assert_eq!(
nested.status.code(),
Some(3),
"stdout:\n{}\nstderr:\n{}",
stdout(&nested),
stderr(&nested)
);
}
#[test]
fn unreadable_usage_exits_2() {
let missing = run(&["report", "--usage", "/no/such/ci-bill-usage.csv"]);
assert_eq!(
missing.status.code(),
Some(2),
"stdout:\n{}\nstderr:\n{}",
stdout(&missing),
stderr(&missing)
);
let missing_err = stderr(&missing);
assert!(missing_err.contains("line 0"), "{missing_err}");
assert!(missing_err.contains("cannot read file"), "{missing_err}");
let path = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("bad-number.csv");
std::fs::write(
&path,
"date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path\n2026-09-01,actions,actions_linux,NOT_A_NUMBER,minutes,0.006,1,0,1,ada,example-org,example-org/app,a.yml\n",
)
.unwrap();
let output = run(&["report", "--usage", path.to_str().unwrap()]);
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let err = stderr(&output);
assert!(err.contains("line 2"), "{err}");
assert!(err.contains("quantity is not a number"), "{err}");
assert!(!err.contains("NOT_A_NUMBER"), "{err}");
}
#[test]
fn top_flag_exits_0() {
let output = run(&[
"report",
"--usage",
"samples/usage-sample.csv",
"--top",
"2",
]);
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn missing_workflows_dir_exits_2() {
let output = run(&[
"report",
"--usage",
"samples/usage-sample.csv",
"--workflows",
"samples/no-such-workflows",
]);
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let err = stderr(&output);
assert!(err.contains("cannot read directory"), "{err}");
assert!(err.contains("samples/no-such-workflows"), "{err}");
}
#[test]
fn sample_usage_exits_0() {
let output = run(&["report", "--usage", "samples/usage-sample.csv"]);
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn invalid_workflow_yaml_exits_2_without_content() {
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("invalid-workflow-yaml");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let secret = "SECRET_TOKEN_XYZ";
std::fs::write(
dir.join("broken.yml"),
format!("name: Demo\njobs: [\n {secret}\n"),
)
.unwrap();
let output = run(&[
"report",
"--usage",
"samples/usage-sample.csv",
"--workflows",
dir.to_str().unwrap(),
]);
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let err = stderr(&output);
assert!(err.contains("broken.yml"), "{err}");
assert!(err.contains("line 4"), "{err}");
assert!(err.contains("invalid YAML"), "{err}");
assert!(!err.contains(secret), "{err}");
assert!(!stdout(&output).contains(secret), "{}", stdout(&output));
}
#[test]
fn amount_overflow_exits_2_for_text_json_and_md() {
let path = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("overflow.csv");
std::fs::write(
&path,
"date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path\n\
2026-09-01,actions,actions_linux,1,minutes,0,0,0,9000000000000,ada,example-org,example-org/app,a.yml\n\
2026-09-02,actions,actions_linux,1,minutes,0,0,0,9000000000000,ada,example-org,example-org/app,b.yml\n",
)
.unwrap();
for extra_args in [[].as_slice(), ["--json"].as_slice(), ["--md"].as_slice()] {
let mut args = vec!["report", "--usage", path.to_str().unwrap()];
args.extend_from_slice(extra_args);
let output = run(&args);
assert_eq!(
output.status.code(),
Some(2),
"args {args:?}\nstdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let err = stderr(&output);
assert!(!err.contains("line 0"), "{args:?}: {err}");
assert!(
err.contains("net_amount total is out of range (sum of all rows)"),
"{args:?}: {err}"
);
assert!(stdout(&output).is_empty(), "{args:?}: {}", stdout(&output));
}
}
#[test]
fn omitted_workflows_net_overflow_does_not_panic() {
let path = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("omitted-overflow.csv");
std::fs::write(
&path,
"date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path\n\
2026-09-01,actions,actions_linux,1,count,9000000000000,9000000000000,0,9000000000000,ada,example-org,example-org/app,a.yml\n\
2026-09-02,actions,actions_linux,1,count,8000000000000,0,8000000000000,-8000000000000,ada,example-org,example-org/app,b.yml\n\
2026-09-03,actions,actions_linux,1,count,8000000000000,0,8000000000000,-8000000000000,ada,example-org,example-org/app,c.yml\n",
)
.unwrap();
for extra_args in [[].as_slice(), ["--json"].as_slice(), ["--md"].as_slice()] {
let mut args = vec!["report", "--usage", path.to_str().unwrap(), "--top", "1"];
args.extend_from_slice(extra_args);
let output = run(&args);
assert_eq!(
output.status.code(),
Some(0),
"args {args:?}\nstdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(stderr(&output).is_empty(), "{args:?}: {}", stderr(&output));
if extra_args == ["--json"] {
let value: serde_json::Value =
serde_json::from_str(&stdout(&output)).expect("report json");
assert!(value["outside_top"]["net"].is_string());
}
}
}
#[test]
fn missing_usage_exits_3() {
let output = run(&["report"]);
assert_eq!(
output.status.code(),
Some(3),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let with_optional = run(&["report", "--workflows", "workflows", "--top", "5"]);
assert_eq!(
with_optional.status.code(),
Some(3),
"stdout:\n{}\nstderr:\n{}",
stdout(&with_optional),
stderr(&with_optional)
);
}