use std::borrow::Cow;
use serde_json::{Map, Value};
use super::ledger::{KINDS, LEDGER_NONE, LEDGER_OTHER, LEDGER_PROXY, entry_lines, ledger_kind};
use super::wire::{LedgerEntry, LedgerFilters, ProjectColumn};
use super::{ProError, ProResult, ProjectScope};
pub(crate) const LEDGER_EXPORT_FIELDS: [&str; 21] = [
"ts",
"kind",
"tool",
"mechanism",
"model_id",
"baseline_tokens",
"actual_tokens",
"saved_tokens",
"bounce_adjustment",
"saved_usd",
"unit_price_per_m_usd",
"lines_added",
"lines_removed",
"measurement_method",
"evidence_class",
"attribution_group",
"repo_hash",
"project",
"version",
"prev_hash",
"entry_hash",
];
pub(crate) const MAX_EXPORT_BYTES: usize = 32 << 20;
pub(crate) fn mime_of(format: &str) -> &'static str {
if format == "csv" {
"text/csv; charset=utf-8"
} else {
"application/json"
}
}
pub(crate) fn file_name(day: chrono::NaiveDate, format: &str) -> String {
format!("leanctx-ledger-{day}.{format}")
}
pub(crate) fn csv_safe(value: &str) -> Cow<'_, str> {
if value.starts_with(['=', '+', '-', '@', '\t', '\r']) {
Cow::Owned(format!("'{value}"))
} else {
Cow::Borrowed(value)
}
}
fn csv_cell(value: &Value) -> String {
let text: Cow<'_, str> = match value {
Value::Null => Cow::Borrowed(""),
Value::String(text) => csv_safe(text),
Value::Bool(flag) => Cow::Borrowed(if *flag { "True" } else { "False" }),
Value::Number(number) => Cow::Owned(number.to_string()),
other => {
let rendered = other.to_string();
Cow::Owned(csv_safe(&rendered).into_owned())
}
};
if text.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", text.replace('"', "\"\""))
} else {
text.into_owned()
}
}
fn project_of(column: &ProjectColumn, entry: &LedgerEntry) -> String {
match column {
ProjectColumn::Fixed { name } => name.clone(),
ProjectColumn::Labels { labels } => {
if entry.tool.as_deref() == Some("proxy_cache") {
return LEDGER_PROXY.into();
}
let repo = entry.repo_hash.as_deref().unwrap_or("");
labels.iter().find(|l| l.hash == repo).map_or_else(
|| {
if repo.is_empty() {
LEDGER_NONE
} else {
LEDGER_OTHER
}
.to_string()
},
|l| l.name.clone(),
)
}
}
}
fn record(raw: &Map<String, Value>, entry: &LedgerEntry, project: &str) -> Map<String, Value> {
let (added, removed) = entry_lines(entry);
LEDGER_EXPORT_FIELDS
.iter()
.map(|field| {
let value = match *field {
"kind" => Value::from(KINDS[ledger_kind(entry) as usize]),
"lines_added" => Value::from(added),
"lines_removed" => Value::from(removed),
"project" => Value::from(project),
other => raw.get(other).cloned().unwrap_or_else(|| "".into()),
};
((*field).to_string(), value)
})
.collect()
}
pub(crate) fn build(
scope: &ProjectScope,
filters: &LedgerFilters,
format: &str,
column: &ProjectColumn,
) -> ProResult<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
let csv = format == "csv";
if csv {
out.extend_from_slice(LEDGER_EXPORT_FIELDS.join(",").as_bytes());
out.push(b'\n');
} else {
out.push(b'[');
}
let mut count = 0usize;
super::ledger::export_entries(scope, filters, |raw, entry| {
let values = record(raw, entry, &project_of(column, entry));
if csv {
let line: Vec<String> = LEDGER_EXPORT_FIELDS
.iter()
.map(|f| csv_cell(values.get(*f).unwrap_or(&Value::Null)))
.collect();
out.extend_from_slice(line.join(",").as_bytes());
out.push(b'\n');
} else {
if count > 0 {
out.push(b',');
}
out.push(b'\n');
let encoded = serde_json::to_vec(&Value::Object(values))
.map_err(|_| ProError::internal("Export failed"))?;
out.extend_from_slice(&encoded);
}
count += 1;
if out.len() > MAX_EXPORT_BYTES {
return Err(ProError::new(
413,
"The selection is too large to export; narrow the filters",
));
}
Ok(())
})?;
if !csv {
out.extend_from_slice(b"\n]\n");
}
Ok(out)
}