use super::{csv_field, DailyCostRow};
use chrono::{Datelike, Duration, NaiveDate};
pub const HEADER: &[&str] = &[
"BilledCost",
"BillingAccountId",
"BillingAccountName",
"BillingCurrency",
"BillingPeriodEnd",
"BillingPeriodStart",
"ChargeCategory",
"ChargeClass",
"ChargeDescription",
"ChargePeriodEnd",
"ChargePeriodStart",
"ContractedCost",
"EffectiveCost",
"InvoiceIssuerName",
"ListCost",
"PricingQuantity",
"PricingUnit",
"ProviderName",
"PublisherName",
"ServiceCategory",
"ServiceName",
"CommitmentDiscountCategory",
"CommitmentDiscountId",
"CommitmentDiscountName",
"CommitmentDiscountStatus",
"CommitmentDiscountType",
"ConsumedQuantity",
"ConsumedUnit",
"ContractedUnitPrice",
"InvoiceIssuer",
"ListUnitPrice",
"PricingCategory",
"ChargeType",
"Provider",
"Publisher",
"RegionId",
"RegionName",
"ResourceID",
"ResourceName",
"ResourceType",
"SkuId",
"SkuPriceId",
"SubAccountId",
"SubAccountName",
"Tags",
"x_project",
"x_agent_role",
"x_model",
"x_tool",
"x_tokens_saved",
];
const PROVIDER: &str = "LeanCTX";
const SERVICE_CATEGORY: &str = "AI and Machine Learning";
const SERVICE_NAME: &str = "LeanCTX Context Engine";
fn billing_period(date: &NaiveDate) -> (String, String) {
let start = date.with_day(1).expect("day 1 always valid");
let end = if start.month() == 12 {
NaiveDate::from_ymd_opt(start.year() + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1)
}
.expect("first of next month always valid");
(iso(&start), iso(&end))
}
fn iso(d: &NaiveDate) -> String {
format!("{}T00:00:00Z", d.format("%Y-%m-%d"))
}
fn push_row(out: &mut String, fields: &[String]) {
let line = fields
.iter()
.map(|f| csv_field(f))
.collect::<Vec<_>>()
.join(",");
out.push_str(&line);
out.push('\n');
}
pub fn to_csv(rows: &[DailyCostRow]) -> String {
let mut out = String::new();
push_row(
&mut out,
&HEADER
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
);
for row in rows {
let Ok(date) = NaiveDate::parse_from_str(&row.date, "%Y-%m-%d") else {
continue;
};
let charge_start = iso(&date);
let charge_end = iso(&(date + Duration::days(1)));
let (bill_start, bill_end) = billing_period(&date);
let resource_id = format!("leanctx/{}/{}/{}", row.project, row.agent_role, row.model);
let tags = serde_json::json!({
"project": row.project,
"agent_role": row.agent_role,
"model": row.model,
"tool": row.tool,
})
.to_string();
let mut emit = |category: &str, cost: f64, qty: u64, desc: String| {
push_row(
&mut out,
&[
format!("{cost:.6}"), row.project.clone(), format!("LeanCTX project {}", row.project), "USD".into(), bill_end.clone(), bill_start.clone(), category.into(), String::new(), desc, charge_end.clone(), charge_start.clone(), format!("{cost:.6}"), format!("{cost:.6}"), PROVIDER.into(), format!("{cost:.6}"), format!("{qty}.0"), "tokens".into(), PROVIDER.into(), PROVIDER.into(), SERVICE_CATEGORY.into(), SERVICE_NAME.into(), String::new(), String::new(), String::new(), String::new(), String::new(), format!("{qty}.0"), "tokens".into(), String::new(), PROVIDER.into(), String::new(), "Standard".into(), category.into(), PROVIDER.into(), PROVIDER.into(), String::new(), String::new(), resource_id.clone(), resource_id.clone(), "context-engine".into(), row.model.clone(), format!("{}-input", row.model), row.agent_role.clone(), row.agent_role.clone(), tags.clone(), row.project.clone(),
row.agent_role.clone(),
row.model.clone(),
row.tool.clone(),
row.tokens_saved.to_string(),
],
);
};
emit(
"Usage",
row.cost_usd,
row.tokens_actual,
format!("LLM context tokens via {} ({})", row.tool, row.model),
);
if row.tokens_saved > 0 {
emit(
"Credit",
-row.savings_usd,
row.tokens_saved,
format!(
"LeanCTX verified savings (hash-chained ledger) via {} ({})",
row.tool, row.model
),
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> DailyCostRow {
DailyCostRow {
date: "2026-06-01".into(),
project: "proj_a".into(),
agent_role: "coder".into(),
model: "claude".into(),
tool: "ctx_read".into(),
tokens_actual: 400,
tokens_saved: 1600,
cost_usd: 0.001,
savings_usd: 0.004,
}
}
#[test]
fn emits_all_mandatory_columns() {
let csv = to_csv(&[row()]);
let header = csv.lines().next().unwrap();
assert_eq!(header.split(',').count(), HEADER.len());
for col in [
"BilledCost",
"ChargeCategory",
"ChargePeriodStart",
"ServiceName",
"PricingUnit",
] {
assert!(header.contains(col), "missing {col}");
}
}
#[test]
fn usage_and_credit_rows_with_negative_savings() {
let csv = to_csv(&[row()]);
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3, "header + usage + credit");
assert!(lines[1].contains("Usage"));
assert!(lines[2].contains("Credit"));
assert!(
lines[2].starts_with("-0.004"),
"credit is negative: {}",
lines[2]
);
}
#[test]
fn billing_period_handles_december() {
let d = NaiveDate::from_ymd_opt(2026, 12, 15).unwrap();
let (start, end) = billing_period(&d);
assert_eq!(start, "2026-12-01T00:00:00Z");
assert_eq!(end, "2027-01-01T00:00:00Z");
}
#[test]
fn charge_period_is_one_day() {
let csv = to_csv(&[row()]);
let usage = csv.lines().nth(1).unwrap();
assert!(usage.contains("2026-06-01T00:00:00Z"));
assert!(usage.contains("2026-06-02T00:00:00Z"));
}
#[test]
fn no_credit_row_when_nothing_saved() {
let mut r = row();
r.tokens_saved = 0;
let csv = to_csv(&[r]);
assert_eq!(csv.lines().count(), 2, "header + usage only");
}
}