use crate::core;
pub(in crate::cli::dispatch) fn cmd_savings(rest: &[String]) {
let action = rest.first().map_or("summary", String::as_str);
match action {
"verify" => {
let v = core::savings_ledger::verify();
if v.valid {
println!(
"Savings ledger: OK — {} event(s), SHA-256 chain intact.",
v.total
);
} else {
println!(
"Savings ledger: TAMPERED — chain broke at entry {} (of {} verified).",
v.first_invalid_at.unwrap_or(0),
v.total
);
println!(
" If you did not edit the ledger, repair the chain with: lean-ctx savings rechain"
);
std::process::exit(1);
}
}
"rechain" => cmd_savings_rechain(),
"export" => {
let events = core::savings_ledger::all_events();
match serde_json::to_string_pretty(&events) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("Export failed: {e}");
std::process::exit(1);
}
}
}
"sign" => cmd_savings_sign(&rest[1..]),
"push" => cmd_savings_push(&rest[1..]),
"team" => cmd_savings_team(&rest[1..]),
"verify-batch" => cmd_savings_verify_batch(rest.get(1).map(String::as_str)),
"roi" => cmd_savings_roi(&rest[1..]),
"summary" | "" => print!("{}", format_savings_summary()),
_ => {
eprintln!(
"Usage: lean-ctx savings [summary|team|verify|rechain|export|sign|push|verify-batch|roi]"
);
std::process::exit(1);
}
}
}
fn cmd_savings_rechain() {
match core::savings_ledger::rechain() {
Ok(0) => println!("Savings ledger is empty — nothing to re-chain."),
Ok(n) => {
let v = core::savings_ledger::verify();
if v.valid {
println!(
"Savings ledger re-chained: {n} event(s) migrated to the v2 (float-free) hash. SHA-256 chain intact."
);
} else {
println!(
"Re-chained {n} event(s), but the chain still breaks at entry {} — this indicates real tampering, not the float bug.",
v.first_invalid_at.unwrap_or(0)
);
std::process::exit(1);
}
}
Err(e) => {
eprintln!("Re-chain failed: {e}");
std::process::exit(1);
}
}
}
fn cmd_savings_roi(args: &[String]) {
let agent_id = savings_agent_id();
let report = core::savings_ledger::roi_report(&agent_id);
if args.iter().any(|a| a == "--json") {
match serde_json::to_string_pretty(&report) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("ROI report serialization failed: {e}");
std::process::exit(1);
}
}
return;
}
println!("{}", report.headline());
println!();
println!(" Period: {}", report.period);
println!(" Events: {}", report.total_events);
println!(
" Saved tokens: {} gross / {} net",
report.saved_tokens, report.net_saved_tokens
);
println!(" Saved USD: ${:.4}", report.saved_usd);
println!(
" Avg / event: {:.1} tok, ${:.6}",
report.avg_saved_tokens_per_event, report.avg_saved_usd_per_event
);
println!(
" Chain: {}",
if report.chain_valid {
"valid (SHA-256 intact)"
} else {
"BROKEN — run `lean-ctx savings rechain`"
}
);
println!(
" Signature: {}",
if report.signed {
"present (Ed25519)"
} else {
"unsigned (machine identity unavailable)"
}
);
if !report.top_tools.is_empty() {
println!(" Top tools:");
for (tool, tokens) in report.top_tools.iter().take(5) {
println!(" {tool}: {tokens} tok");
}
}
}
pub(super) fn savings_agent_id() -> String {
core::savings_ledger::push::agent_id()
}
fn cmd_savings_sign(args: &[String]) {
use core::savings_ledger::{signed_batch, SignedSavingsBatchV1};
let out_override = args.iter().find_map(|a| {
a.strip_prefix("--out=")
.map(std::path::PathBuf::from)
.or_else(|| (a == "--out").then_some(std::path::PathBuf::new()))
});
let out_override = match out_override {
Some(p) if p.as_os_str().is_empty() => args
.iter()
.skip_while(|a| a.as_str() != "--out")
.nth(1)
.map(std::path::PathBuf::from),
other => other,
};
let agent_id = savings_agent_id();
let mut batch = SignedSavingsBatchV1::build_all(&agent_id);
if batch.totals.total_events == 0 {
eprintln!("Savings ledger is empty — nothing to sign yet. It fills as lean-ctx compresses your reads.");
std::process::exit(1);
}
if let Err(e) = batch.sign(&agent_id) {
eprintln!("Signing failed: {e}");
std::process::exit(1);
}
let out = match out_override {
Some(p) => Ok(p),
None => signed_batch::default_artifact_path(),
};
let path = out.and_then(|p| signed_batch::write_artifact(&batch, &p));
match path {
Ok(p) => {
use core::wrapped::format_tokens;
let pk = batch.signer_public_key.as_deref().unwrap_or("");
println!("Signed savings batch written to {}", p.display());
println!(
" Net saved: {} tokens (~${:.2}) over {} event(s)",
format_tokens(batch.totals.net_saved_tokens),
batch.totals.saved_usd,
batch.totals.total_events
);
println!(" Chain head: {}", batch.last_entry_hash);
println!(
" Chain: {}",
if batch.chain_valid {
"intact (SHA-256)"
} else {
"BROKEN — run `lean-ctx savings verify`"
}
);
println!(" Signer key: {pk}");
println!(
"\nVerify anywhere (no ledger needed): lean-ctx savings verify-batch {}",
p.display()
);
}
Err(e) => {
eprintln!("Could not write artifact: {e}");
std::process::exit(1);
}
}
}
fn resolve_team_url(args: &[String]) -> Option<String> {
args.iter()
.find_map(|a| a.strip_prefix("--team-url=").map(String::from))
.or_else(|| {
args.iter()
.position(|a| a == "--team-url")
.and_then(|i| args.get(i + 1).cloned())
})
.or_else(|| crate::core::config::Config::load().team_url.clone())
.filter(|s| !s.trim().is_empty())
}
fn resolve_team_token(args: &[String]) -> Option<String> {
args.iter()
.find_map(|a| a.strip_prefix("--team-token=").map(String::from))
.or_else(|| {
args.iter()
.position(|a| a == "--team-token")
.and_then(|i| args.get(i + 1).cloned())
})
.or_else(|| std::env::var("LEAN_CTX_TEAM_TOKEN").ok())
.or_else(|| crate::core::config::Config::load().team_token.clone())
.filter(|s| !s.trim().is_empty())
}
fn cmd_savings_push(args: &[String]) {
use core::savings_ledger::push::{ingest_endpoint, push_batch, PushError};
let Some(url) = resolve_team_url(args) else {
eprintln!("No team URL configured. Use --team-url or set team_url in config.toml");
std::process::exit(1);
};
let team_token = resolve_team_token(args);
match push_batch(&url, team_token.as_deref()) {
Ok(outcome) => {
use core::wrapped::format_tokens;
println!("\x1b[32m✓\x1b[0m Savings batch pushed to team server.");
println!(
" Net saved: {} tokens (~${:.2})",
format_tokens(outcome.net_saved_tokens),
outcome.saved_usd
);
println!(" Endpoint: {}", ingest_endpoint(&url));
}
Err(PushError::Unauthorized) => {
eprintln!(
"Team server denied the push (HTTP 401/403). Set a member token via \
--team-token, LEAN_CTX_TEAM_TOKEN, or team_token in config.toml."
);
std::process::exit(1);
}
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
}
fn cmd_savings_team(args: &[String]) {
let Some(url) = resolve_team_url(args) else {
eprintln!("No team URL configured. Use --team-url or set team_url in config.toml");
std::process::exit(1);
};
let endpoint = format!("{}/v1/savings/summary", url.trim_end_matches('/'));
let mut request = ureq::get(&endpoint);
if let Some(tok) = resolve_team_token(args) {
request = request.header("Authorization", &format!("Bearer {tok}"));
}
match request.call() {
Ok(resp) => {
let status = resp.status();
let text = resp.into_body().read_to_string().unwrap_or_default();
match status.as_u16() {
200 => print!("{}", format_team_savings(&text)),
401 | 403 => {
eprintln!(
"Team server denied access (HTTP {status}). The token needs the \
'audit' scope (owner/admin). Set it via --team-token, \
LEAN_CTX_TEAM_TOKEN, or team_token in config.toml."
);
std::process::exit(1);
}
code => {
eprintln!("Team server error (HTTP {code}): {text}");
std::process::exit(1);
}
}
}
Err(e) => {
eprintln!("Failed to reach team server at {endpoint}: {e}");
std::process::exit(1);
}
}
}
fn format_team_savings(body: &str) -> String {
use core::wrapped::format_tokens;
let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
return "Team savings: could not parse the server response.\n".to_string();
};
let net = v["totals"]["net_saved_tokens"].as_u64().unwrap_or(0);
let usd = v["totals"]["saved_usd"].as_f64().unwrap_or(0.0);
let members = v["member_count"].as_u64().unwrap_or(0);
let mut out = String::new();
out.push_str("\n \x1b[1mTeam savings\x1b[0m (opt-in roll-up)\n");
out.push_str(&format!(" Members reporting: {members}\n"));
out.push_str(&format!(
" Net saved: {} tokens (~${usd:.2})\n",
format_tokens(net)
));
if members == 0 {
out.push_str(
"\n No member has pushed a signed savings batch yet.\n \
One-off: lean-ctx savings push\n \
Automatic: lean-ctx config set team_url <url> \
&& lean-ctx config set team_token <member-token> \
&& lean-ctx config set team_auto_push true\n",
);
return out;
}
if let Some(rows) = v["by_member"].as_array().filter(|r| !r.is_empty()) {
out.push_str("\n Per member:\n");
for m in rows {
let id = m["agent_id"].as_str().unwrap_or("?");
let mnet = m["net_saved_tokens"].as_u64().unwrap_or(0);
let musd = m["saved_usd"].as_f64().unwrap_or(0.0);
out.push_str(&format!(
" {id:<30} {} tokens (~${musd:.2})\n",
format_tokens(mnet)
));
}
}
if let Some(rows) = v["by_model"].as_array().filter(|r| !r.is_empty()) {
out.push_str("\n Per model:\n");
for m in rows {
let model = m["model"].as_str().unwrap_or("?");
let t = m["saved_tokens"].as_u64().unwrap_or(0);
out.push_str(&format!(" {model:<30} {} tokens\n", format_tokens(t)));
}
}
out.push('\n');
out
}
fn cmd_savings_verify_batch(file: Option<&str>) {
use core::savings_ledger::signed_batch;
let Some(file) = file else {
eprintln!("Usage: lean-ctx savings verify-batch <file.json>");
std::process::exit(1);
};
let batch = match signed_batch::load_artifact(std::path::Path::new(file)) {
Ok(b) => b,
Err(e) => {
eprintln!("Cannot read artifact: {e}");
std::process::exit(1);
}
};
let res = batch.verify();
if res.signature_valid {
use core::wrapped::format_tokens;
println!("Signed savings batch: VALID");
println!(
" Signed by: {}",
res.signer_public_key.as_deref().unwrap_or("?")
);
println!(" Agent: {}", batch.agent_id);
println!(" Created: {}", batch.created_at);
println!(" lean-ctx: {}", batch.lean_ctx_version);
println!(
" Net saved: {} tokens (~${:.2}) over {} event(s)",
format_tokens(batch.totals.net_saved_tokens),
batch.totals.saved_usd,
batch.totals.total_events
);
println!(" Chain head: {}", batch.last_entry_hash);
if !batch.chain_valid {
println!(" NOTE: the ledger chain was already broken when this batch was signed.");
}
} else {
println!(
"Signed savings batch: INVALID — {}",
res.error.as_deref().unwrap_or("signature check failed")
);
std::process::exit(1);
}
}
#[allow(clippy::many_single_char_names)] fn format_savings_summary() -> String {
use core::wrapped::format_tokens;
let s = core::savings_ledger::summary();
if s.total_events == 0 {
return "Savings ledger is empty — it fills as lean-ctx compresses your reads.\n"
.to_string();
}
let v = core::savings_ledger::verify();
let energy_tokens = if s.bounce_events > 0 {
s.net_saved_tokens()
} else {
s.saved_tokens
};
let t = core::theme::load_theme(&core::config::Config::load().theme);
let rst = core::theme::rst();
let bold = core::theme::bold();
let dim = core::theme::dim();
let sc = t.success.fg();
let m = t.muted.fg();
let w = 56;
let ss = t.box_side_square();
let sl = |content: &str| -> String {
let padded = core::theme::pad_right(content, w);
format!(" {ss}{padded}{ss}")
};
let integrity_badge = if v.valid {
format!("{sc}✓ SHA-256 chain intact{rst}")
} else {
format!(
"{}✗ BROKEN — run `lean-ctx savings verify`{rst}",
t.danger.fg()
)
};
let mut out = Vec::new();
out.push(String::new());
out.push(format!(
" {}",
t.box_top_labeled(w, "VERIFIED SAVINGS LEDGER")
));
out.push(sl(&format!(
" {bold}Events{rst} {m}{}{rst}",
s.total_events
)));
out.push(sl(&format!(" {integrity_badge}")));
if s.bounce_events > 0 {
out.push(sl(&format!(
" {bold}Saved{rst} {sc}{}{rst} {dim}(gross){rst}",
format_tokens(s.saved_tokens)
)));
out.push(sl(&format!(
" {bold}Bounce{rst} {m}{}{rst} {dim}({} re-reads){rst}",
format_tokens(s.bounce_tokens),
s.bounce_events
)));
out.push(sl(&format!(
" {bold}Net saved{rst} {sc}{bold}{}{rst}",
format_tokens(s.net_saved_tokens())
)));
out.push(sl(&format!(
" {bold}USD{rst} {sc}{bold}${:.2}{rst} {dim}(net of bounce){rst}",
s.saved_usd
)));
} else {
out.push(sl(&format!(
" {bold}Saved{rst} {sc}{bold}{}{rst}",
format_tokens(s.saved_tokens)
)));
out.push(sl(&format!(
" {bold}USD{rst} {sc}{bold}${:.2}{rst}",
s.saved_usd
)));
}
{
let energy = core::energy::format_for_tokens(energy_tokens);
let charges = core::energy::phone_charges_hint(energy_tokens)
.map(|h| format!(" ({h})"))
.unwrap_or_default();
out.push(sl(&format!(
" {bold}Energy{rst} {sc}{energy}{rst}{dim}{charges}{rst}"
)));
}
out.push(format!(" {}", t.box_bottom_square(w)));
if !s.by_model.is_empty() {
out.push(String::new());
out.push(format!(" {}", t.box_top_labeled(w, "BY MODEL")));
for (model, tok, usd) in s.by_model.iter().take(5) {
out.push(sl(&format!(
" {m}{model:<22}{rst} {:>10} tok {sc}${usd:.2}{rst}",
format_tokens(*tok)
)));
}
out.push(format!(" {}", t.box_bottom_square(w)));
}
if s.by_day.len() >= 2 {
out.push(String::new());
out.push(format!(" {}", t.box_top_labeled(w, "RECENT DAYS")));
let recent: Vec<_> = s.by_day.iter().rev().take(7).collect();
for (day, tok, usd) in recent.into_iter().rev() {
out.push(sl(&format!(
" {m}{day}{rst} {:>10} tok {sc}${usd:.2}{rst}",
format_tokens(*tok)
)));
}
out.push(format!(" {}", t.box_bottom_square(w)));
}
out.push(String::new());
out.join("\n")
}