use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{error::ErrorKind, Parser, Subcommand};
use ci_bill::{
api, fetch, findings, fix_diff, matrix_cost, md,
money::{DecimalError, Micros},
overlap, prices, rank, report, rerun, savings, usage, workflow,
};
fn main() -> ExitCode {
match Cli::try_parse() {
Ok(cli) => match cli.command {
Command::Report {
usage,
workflows,
runs,
top,
json,
budget,
fix_diff,
md,
} => report(
&usage,
workflows.as_deref(),
runs.as_deref(),
top,
json,
budget,
fix_diff,
md,
),
Command::Prices {
json,
runner_map,
label,
sku,
usage,
repository,
workflow,
} => show_prices(
json,
runner_map.as_deref(),
label.as_deref(),
sku.as_deref(),
usage.as_deref(),
repository.as_deref(),
workflow.as_deref(),
),
Command::Fetch {
repo,
out,
since,
until,
} => match fetch::fetch(&repo, &out, since, until) {
Ok(text) => {
print!("{text}");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("ci-bill: {err}");
ExitCode::from(err.code())
}
},
},
Err(err) => usage_exit(err),
}
}
fn usage_exit(err: clap::Error) -> ExitCode {
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
err.exit();
}
let _ = err.print();
ExitCode::from(3)
}
#[allow(clippy::too_many_arguments)]
fn report(
usage_path: &Path,
workflows: Option<&Path>,
runs: Option<&Path>,
top: Option<usize>,
json: bool,
budget: Option<Micros>,
fix_diff: bool,
md: bool,
) -> ExitCode {
let rows = match usage::read_usage(usage_path) {
Ok(rows) => rows,
Err(err) => {
eprintln!("ci-bill: {}: {err}", usage_path.display());
return ExitCode::from(2);
}
};
let ranking = match rank::rank(&rows, None) {
Ok(ranking) => ranking,
Err(err) => {
eprintln!("ci-bill: {}: {err}", usage_path.display());
return ExitCode::from(2);
}
};
let scanned = match workflows {
Some(dir) => match workflow::scan_dir(dir) {
Ok(scanned) => scanned,
Err(err) => {
eprintln!("ci-bill: {}: {err}", err.path.display());
return ExitCode::from(2);
}
},
None => Vec::new(),
};
let yaml = if workflows.is_some() {
let result = match runs {
Some(cache) => findings::findings_with_cache(&scanned, Some(cache)),
None => findings::findings(&scanned),
};
match result {
Ok(items) => Some(items),
Err(err) => {
eprintln!("ci-bill: {}: {err}", err.path.display());
return ExitCode::from(2);
}
}
} else {
None
};
let found = findings::combine(yaml, findings::invoice_findings(usage_path, &rows));
let api = match runs {
Some(dir) => match api::read_dir(dir) {
Ok(data) => Some(data),
Err(err) => {
eprintln!("ci-bill: {}: {err}", err.path.display());
return ExitCode::from(2);
}
},
None => None,
};
let ranked = ranking.workflows.as_slice();
let mut found = findings::append(
found,
api.as_ref()
.map(|data| rerun::findings(data, ranked, &scanned))
.unwrap_or_default(),
);
let measured = api
.as_ref()
.map(|data| matrix_cost::findings(data, ranked, &scanned))
.unwrap_or_default();
if let Some(items) = found.as_mut() {
matrix_cost::supersede(items, &measured);
}
let found = findings::append(found, measured);
let mut found = findings::append(
found,
api.as_ref()
.map(|data| overlap::findings(data, ranked, &scanned))
.unwrap_or_default(),
);
if let Some(items) = found.as_mut() {
let public_repo = api.as_ref().is_some_and(|data| data.public);
savings::apply(items, ranked, &scanned, public_repo);
}
let text = if md {
md::render(
ranked,
&ranking.bill,
found.as_deref(),
api.as_ref(),
&scanned,
workflows,
)
} else if fix_diff {
found
.as_deref()
.and_then(|items| fix_diff::render(items, workflows))
.unwrap_or_default()
} else if json {
match api.as_ref() {
Some(data) => report::render_json_with_runs(
ranked,
&ranking.bill,
found.as_deref(),
budget,
data,
top,
&scanned,
),
None => report::render_json(
ranked,
&ranking.bill,
found.as_deref(),
budget,
top,
&scanned,
),
}
} else {
report::render_text(
ranked,
&ranking.bill,
found.as_deref(),
api.as_ref(),
top,
&scanned,
)
};
print!("{text}");
let total_net = ranking.bill.total_net;
if let Some(amount) = budget.filter(|amount| total_net > *amount) {
let line = report::budget_exceeded_line(total_net, amount);
if json {
eprintln!("{line}");
} else {
println!("{line}");
}
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
const BUDGET_ERROR: &str = "expected a finite USD amount greater than or equal to zero";
fn parse_budget(raw: &str) -> Result<Micros, String> {
let text = raw.trim();
if text.is_empty() || text.starts_with('-') {
return Err(BUDGET_ERROR.to_string());
}
match Micros::parse(text) {
Ok(amount) => Ok(amount),
Err(DecimalError::NotANumber | DecimalError::TooManyDecimals) => {
Err(BUDGET_ERROR.to_string())
}
}
}
fn show_prices(
json: bool,
runner_map: Option<&Path>,
label: Option<&str>,
sku: Option<&str>,
usage: Option<&Path>,
repository: Option<&str>,
workflow: Option<&str>,
) -> ExitCode {
match prices::render(json, runner_map, label, sku, usage, repository, workflow) {
Ok(text) => {
print!("{text}");
ExitCode::SUCCESS
}
Err(err) => {
let code = err.exit_code();
eprintln!("ci-bill: {err}");
ExitCode::from(code)
}
}
}
#[derive(Debug, Parser)]
#[command(name = "ci-bill", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Report {
#[arg(long, value_name = "CSV")]
usage: PathBuf,
#[arg(long, value_name = "DIR")]
workflows: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
runs: Option<PathBuf>,
#[arg(long, value_name = "N")]
top: Option<usize>,
#[arg(long)]
json: bool,
#[arg(
long,
value_name = "AMOUNT",
value_parser = parse_budget,
allow_hyphen_values = true
)]
budget: Option<Micros>,
#[arg(long)]
fix_diff: bool,
#[arg(long)]
md: bool,
},
Prices {
#[arg(long)]
json: bool,
#[arg(long, value_name = "CSV")]
runner_map: Option<PathBuf>,
#[arg(long, value_name = "LABEL")]
label: Option<String>,
#[arg(long, value_name = "SKU")]
sku: Option<String>,
#[arg(long, value_name = "CSV")]
usage: Option<PathBuf>,
#[arg(long, value_name = "OWNER/REPO")]
repository: Option<String>,
#[arg(long, value_name = "PATH")]
workflow: Option<String>,
},
Fetch {
#[arg(long, value_name = "OWNER/REPO", value_parser = fetch::parse_repo_arg)]
repo: String,
#[arg(long, value_name = "DIR")]
out: PathBuf,
#[arg(long, value_name = "DATE", value_parser = fetch::parse_utc_date)]
since: fetch::UtcDate,
#[arg(long, value_name = "DATE", value_parser = fetch::parse_utc_date)]
until: Option<fetch::UtcDate>,
},
}