lean_ctx/core/finops_export/
mod.rs1pub mod aliases;
17pub mod cbf;
18pub mod focus;
19pub mod vantage;
20
21use std::collections::BTreeMap;
22
23#[derive(Debug, Clone, PartialEq)]
25pub struct DailyCostRow {
26 pub date: String,
28 pub project: String,
31 pub agent_role: String,
33 pub model: String,
34 pub tool: String,
35 pub tokens_actual: u64,
37 pub tokens_saved: u64,
39 pub cost_usd: f64,
41 pub savings_usd: f64,
43}
44
45#[derive(Debug, Clone, Default)]
48pub struct DateRange {
49 pub from: Option<String>,
50 pub to: Option<String>,
51}
52
53impl DateRange {
54 fn contains(&self, date: &str) -> bool {
55 if let Some(f) = &self.from
56 && date < f.as_str()
57 {
58 return false;
59 }
60 if let Some(t) = &self.to
61 && date > t.as_str()
62 {
63 return false;
64 }
65 true
66 }
67}
68
69pub fn aggregate(range: &DateRange) -> Vec<DailyCostRow> {
72 let Some(path) = crate::core::savings_ledger::store::default_path() else {
73 return Vec::new();
74 };
75 aggregate_events(&crate::core::savings_ledger::store::load(&path), range)
76}
77
78fn aggregate_events(
79 events: &[crate::core::savings_ledger::SavingsEvent],
80 range: &DateRange,
81) -> Vec<DailyCostRow> {
82 let mut map: BTreeMap<(String, String, String, String, String), DailyCostRow> = BTreeMap::new();
83
84 for ev in events {
85 let Some(date) = ev.ts.get(..10) else {
86 continue;
87 };
88 if date.len() != 10 || !range.contains(date) {
89 continue;
90 }
91 let key = (
92 date.to_string(),
93 ev.repo_hash.clone(),
94 ev.agent_id.clone(),
95 ev.model_id.clone(),
96 ev.tool.clone(),
97 );
98 let row = map.entry(key).or_insert_with(|| DailyCostRow {
99 date: date.to_string(),
100 project: ev.repo_hash.clone(),
101 agent_role: ev.agent_id.clone(),
102 model: ev.model_id.clone(),
103 tool: ev.tool.clone(),
104 tokens_actual: 0,
105 tokens_saved: 0,
106 cost_usd: 0.0,
107 savings_usd: 0.0,
108 });
109 let net_saved = ev.saved_tokens.saturating_sub(ev.bounce_adjustment);
110 row.tokens_actual += ev.actual_tokens;
111 row.tokens_saved += net_saved;
112 row.cost_usd += ev.actual_tokens as f64 / 1_000_000.0 * ev.unit_price_per_m_usd;
113 row.savings_usd += ev.saved_usd;
114 }
115
116 map.into_values().collect()
117}
118
119pub(crate) fn csv_field(v: &str) -> String {
121 if v.contains([',', '"', '\n']) {
122 format!("\"{}\"", v.replace('"', "\"\""))
123 } else {
124 v.to_string()
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::core::savings_ledger::SavingsEvent;
132
133 pub(crate) fn event(
134 ts: &str,
135 repo: &str,
136 model: &str,
137 actual: u64,
138 saved: u64,
139 ) -> SavingsEvent {
140 SavingsEvent {
141 ts: ts.into(),
142 tool: "ctx_read".into(),
143 model_id: model.into(),
144 tokenizer: "o200k_base".into(),
145 baseline_tokens: actual + saved,
146 actual_tokens: actual,
147 saved_tokens: saved,
148 bounce_adjustment: 0,
149 unit_price_per_m_usd: 2.5,
150 saved_usd: saved as f64 / 1_000_000.0 * 2.5,
151 repo_hash: repo.into(),
152 agent_id: "coder".into(),
153 prev_hash: String::new(),
154 entry_hash: String::new(),
155 }
156 }
157
158 #[test]
159 fn aggregates_per_day_and_dimensions() {
160 let events = vec![
161 event("2026-06-01T08:00:00+00:00", "proj_a", "claude", 300, 700),
162 event("2026-06-01T09:00:00+00:00", "proj_a", "claude", 100, 900),
163 event("2026-06-02T08:00:00+00:00", "proj_a", "claude", 50, 50),
164 event("2026-06-01T08:00:00+00:00", "proj_b", "gpt", 10, 90),
165 ];
166 let rows = aggregate_events(&events, &DateRange::default());
167 assert_eq!(rows.len(), 3, "day×project×model groups");
168
169 let a1 = rows
170 .iter()
171 .find(|r| r.project == "proj_a" && r.date == "2026-06-01")
172 .unwrap();
173 assert_eq!(a1.tokens_actual, 400);
174 assert_eq!(a1.tokens_saved, 1600);
175 assert!((a1.cost_usd - 400.0 / 1e6 * 2.5).abs() < 1e-12);
176 assert!((a1.savings_usd - 1600.0 / 1e6 * 2.5).abs() < 1e-12);
177 }
178
179 #[test]
180 fn date_range_filters_inclusively() {
181 let events = vec![
182 event("2026-06-01T08:00:00+00:00", "p", "m", 1, 1),
183 event("2026-06-02T08:00:00+00:00", "p", "m", 1, 1),
184 event("2026-06-03T08:00:00+00:00", "p", "m", 1, 1),
185 ];
186 let range = DateRange {
187 from: Some("2026-06-02".into()),
188 to: Some("2026-06-02".into()),
189 };
190 let rows = aggregate_events(&events, &range);
191 assert_eq!(rows.len(), 1);
192 assert_eq!(rows[0].date, "2026-06-02");
193 }
194
195 #[test]
196 fn bounce_adjustment_reduces_savings() {
197 let mut ev = event("2026-06-01T08:00:00+00:00", "p", "m", 100, 1000);
198 ev.bounce_adjustment = 400;
199 let rows = aggregate_events(&[ev], &DateRange::default());
200 assert_eq!(rows[0].tokens_saved, 600);
201 }
202
203 #[test]
204 fn csv_field_quotes_specials() {
205 assert_eq!(csv_field("plain"), "plain");
206 assert_eq!(csv_field("a,b"), "\"a,b\"");
207 assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
208 }
209}