@archeagle087/costcrew 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +12 -5
- package/package.json +8 -5
- package/.github/workflows/ci.yml +0 -21
- package/.github/workflows/npm-release.yml +0 -107
- package/.github/workflows/prices-monthly.yml +0 -58
- package/.github/workflows/release.yml +0 -45
- package/Cargo.lock +0 -980
- package/Cargo.toml +0 -22
- package/assets/advise.png +0 -0
- package/assets/board.png +0 -0
- package/assets/catalog.png +0 -0
- package/assets/demo.png +0 -0
- package/assets/help.png +0 -0
- package/assets/mascot.png +0 -0
- package/assets/prices.png +0 -0
- package/assets/scan.png +0 -0
- package/assets/spend.png +0 -0
- package/assets/ui.png +0 -0
- package/data/prices.json +0 -1
- package/dist/costcrew-linux-x86_64.tar.gz +0 -0
- package/dist/costcrew-macos-aarch64.tar.gz +0 -0
- package/dist/costcrew-macos-x86_64.tar.gz +0 -0
- package/dist/costcrew-windows-x86_64.zip +0 -0
- package/install.ps1 +0 -38
- package/install.sh +0 -56
- package/packaging/npm/bin.js +0 -43
- package/packaging/npm/package.json +0 -20
- package/packaging/winget/0.4.0/Arch087146.costcrew.installer.yaml +0 -15
- package/packaging/winget/0.4.0/Arch087146.costcrew.locale.en-US.yaml +0 -28
- package/packaging/winget/0.4.0/Arch087146.costcrew.yaml +0 -5
- package/packaging/winget/README.md +0 -31
- package/scripts/render_screen.py +0 -221
- package/src/advise.rs +0 -154
- package/src/args.rs +0 -239
- package/src/board.rs +0 -227
- package/src/catalog.rs +0 -136
- package/src/demo.rs +0 -79
- package/src/fetch.rs +0 -155
- package/src/main.rs +0 -371
- package/src/price.rs +0 -218
- package/src/prices.rs +0 -182
- package/src/report.rs +0 -194
- package/src/scan.rs +0 -593
- package/src/spend.rs +0 -452
- package/src/ui.rs +0 -367
- package/work/costcrew-darwin-arm64/costcrew +0 -0
- package/work/costcrew-darwin-arm64/package.json +0 -9
- package/work/costcrew-darwin-x64/costcrew +0 -0
- package/work/costcrew-darwin-x64/package.json +0 -9
- package/work/costcrew-linux-x64/costcrew +0 -0
- package/work/costcrew-linux-x64/package.json +0 -9
- package/work/costcrew-win32-x64/costcrew.exe +0 -0
- package/work/costcrew-win32-x64/package.json +0 -9
package/src/spend.rs
DELETED
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
//! `costcrew spend` — YOUR AI spending, as recorded by you.
|
|
2
|
-
//!
|
|
3
|
-
//! The tool never guesses here: you record what you actually paid
|
|
4
|
-
//! (subscription renewals, API credits, GPU rentals) and costcrew turns the
|
|
5
|
-
//! ledger into monthly totals and per-service breakdowns. It deliberately
|
|
6
|
-
//! complements the estimates from `scan` and the provider-truth from
|
|
7
|
-
//! `openrouter`: this is the number that leaves your bank account.
|
|
8
|
-
//!
|
|
9
|
-
//! Ledger: %LOCALAPPDATA%\costcrew\spend.json. No network. Nothing leaves disk.
|
|
10
|
-
|
|
11
|
-
use crate::fetch::cache_dir;
|
|
12
|
-
use crate::report::{BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW};
|
|
13
|
-
use serde_json::Value;
|
|
14
|
-
use std::time::{Duration, SystemTime};
|
|
15
|
-
|
|
16
|
-
#[derive(Debug)]
|
|
17
|
-
pub struct Entry {
|
|
18
|
-
/// YYYY-MM-DD the spend happened (defaults to today)
|
|
19
|
-
pub date: String,
|
|
20
|
-
pub amount: f64,
|
|
21
|
-
pub service: String,
|
|
22
|
-
pub model: String,
|
|
23
|
-
pub note: String,
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------- ledger IO ----------
|
|
27
|
-
|
|
28
|
-
fn ledger_path() -> std::path::PathBuf {
|
|
29
|
-
cache_dir().join("spend.json")
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
pub fn all_entries() -> Vec<Entry> {
|
|
33
|
-
load()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
fn load() -> Vec<Entry> {
|
|
37
|
-
let Ok(text) = std::fs::read_to_string(ledger_path()) else {
|
|
38
|
-
return Vec::new();
|
|
39
|
-
};
|
|
40
|
-
let Ok(v) = serde_json::from_str::<Value>(&text) else {
|
|
41
|
-
return Vec::new();
|
|
42
|
-
};
|
|
43
|
-
v.get("entries")
|
|
44
|
-
.and_then(Value::as_array)
|
|
45
|
-
.map(|list| {
|
|
46
|
-
list.iter()
|
|
47
|
-
.filter_map(|e| {
|
|
48
|
-
Some(Entry {
|
|
49
|
-
date: e.get("date").and_then(Value::as_str)?.to_string(),
|
|
50
|
-
amount: e.get("amount").and_then(Value::as_f64)?,
|
|
51
|
-
service: e
|
|
52
|
-
.get("service")
|
|
53
|
-
.and_then(Value::as_str)
|
|
54
|
-
.unwrap_or("unspecified")
|
|
55
|
-
.to_string(),
|
|
56
|
-
model: e
|
|
57
|
-
.get("model")
|
|
58
|
-
.and_then(Value::as_str)
|
|
59
|
-
.unwrap_or("")
|
|
60
|
-
.to_string(),
|
|
61
|
-
note: e
|
|
62
|
-
.get("note")
|
|
63
|
-
.and_then(Value::as_str)
|
|
64
|
-
.unwrap_or("")
|
|
65
|
-
.to_string(),
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
.collect()
|
|
69
|
-
})
|
|
70
|
-
.unwrap_or_default()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
fn save(entries: &[Entry]) -> Result<(), String> {
|
|
74
|
-
let doc = serde_json::json!({
|
|
75
|
-
"version": 1,
|
|
76
|
-
"entries": entries.iter().map(|e| serde_json::json!({
|
|
77
|
-
"date": e.date, "amount": e.amount,
|
|
78
|
-
"service": e.service, "model": e.model, "note": e.note,
|
|
79
|
-
})).collect::<Vec<_>>(),
|
|
80
|
-
});
|
|
81
|
-
let dir = cache_dir();
|
|
82
|
-
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
83
|
-
std::fs::write(ledger_path(), doc.to_string()).map_err(|e| e.to_string())
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ---------- public commands ----------
|
|
87
|
-
|
|
88
|
-
/// `costcrew spend add <amount>` — record one payment.
|
|
89
|
-
pub fn add(args: &crate::args::Args) {
|
|
90
|
-
let amount = match args.pos.first().map(|a| parse_amount(a)) {
|
|
91
|
-
Some(Ok(a)) => a,
|
|
92
|
-
Some(Err(e)) => {
|
|
93
|
-
eprintln!("error: {e}");
|
|
94
|
-
std::process::exit(2);
|
|
95
|
-
}
|
|
96
|
-
None => {
|
|
97
|
-
eprintln!("error: spend add needs an amount, e.g. costcrew spend add 19.99");
|
|
98
|
-
eprintln!(" flags: --service \"Claude Pro\" --model opus --note \"monthly\"");
|
|
99
|
-
std::process::exit(2);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
let date = args.at.clone().unwrap_or_else(today);
|
|
103
|
-
match push_entry(Entry {
|
|
104
|
-
date,
|
|
105
|
-
amount,
|
|
106
|
-
service: args.service.clone().unwrap_or_else(|| "unspecified".into()),
|
|
107
|
-
model: args.model.clone().unwrap_or_default(),
|
|
108
|
-
note: args.note.clone().unwrap_or_default(),
|
|
109
|
-
}) {
|
|
110
|
-
Ok(n) => println!(
|
|
111
|
-
" {GREEN}✔{RESET} recorded ${amount:.2} — {DIM}{} entries in the ledger{RESET}",
|
|
112
|
-
n
|
|
113
|
-
),
|
|
114
|
-
Err(e) => {
|
|
115
|
-
eprintln!("error: can't write ledger: {e}");
|
|
116
|
-
std::process::exit(1);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/// shared writer for the flag path and the interactive path
|
|
122
|
-
pub fn push_entry(e: Entry) -> Result<usize, String> {
|
|
123
|
-
let mut entries = load();
|
|
124
|
-
entries.push(e);
|
|
125
|
-
save(&entries)?;
|
|
126
|
-
Ok(entries.len())
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/// menu-driven version of `spend add` — plain prompts, no flags
|
|
130
|
-
pub fn interactive_add() {
|
|
131
|
-
println!();
|
|
132
|
-
println!(
|
|
133
|
-
" {BOLD}record a payment{RESET} {DIM}· goes to {}{RESET}",
|
|
134
|
-
ledger_path().display()
|
|
135
|
-
);
|
|
136
|
-
println!();
|
|
137
|
-
let amount = match parse_amount(&prompt_of("amount $:")) {
|
|
138
|
-
Ok(a) => a,
|
|
139
|
-
Err(e) => {
|
|
140
|
-
println!(" {RED}✘ {e}{RESET}");
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
};
|
|
144
|
-
let service = {
|
|
145
|
-
let s = prompt_of("service (e.g. Claude Pro):");
|
|
146
|
-
if s.is_empty() {
|
|
147
|
-
"unspecified".into()
|
|
148
|
-
} else {
|
|
149
|
-
s
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
let model = prompt_of("model (enter to skip):");
|
|
153
|
-
let note = prompt_of("note (enter to skip):");
|
|
154
|
-
match push_entry(Entry {
|
|
155
|
-
date: today(),
|
|
156
|
-
amount,
|
|
157
|
-
service,
|
|
158
|
-
model,
|
|
159
|
-
note,
|
|
160
|
-
}) {
|
|
161
|
-
Ok(_) => println!(" {GREEN}✔{RESET} recorded ${amount:.2}"),
|
|
162
|
-
Err(e) => println!(" {RED}✘ can't write ledger: {e}{RESET}"),
|
|
163
|
-
}
|
|
164
|
-
println!();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
fn prompt_of(label: &str) -> String {
|
|
168
|
-
use std::io::Write;
|
|
169
|
-
print!(" {CYAN}{label}{RESET} ");
|
|
170
|
-
std::io::stdout().flush().ok();
|
|
171
|
-
let mut s = String::new();
|
|
172
|
-
std::io::stdin().read_line(&mut s).ok();
|
|
173
|
-
s.trim().to_string()
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/// `costcrew spend` — the summary view.
|
|
177
|
-
pub fn print_spend(json: bool, lookback_days: u32) {
|
|
178
|
-
let entries = load();
|
|
179
|
-
let today = today();
|
|
180
|
-
|
|
181
|
-
if json {
|
|
182
|
-
use serde_json::json;
|
|
183
|
-
let month_tot = total_in_month(&entries, &month_of(&today));
|
|
184
|
-
let rolling = total_last_n_days(&entries, &today, lookback_days);
|
|
185
|
-
let all: f64 = entries.iter().map(|e| e.amount).sum();
|
|
186
|
-
let arr: Vec<_> = entries
|
|
187
|
-
.iter()
|
|
188
|
-
.map(|e| {
|
|
189
|
-
json!({
|
|
190
|
-
"date": e.date, "amount": e.amount, "service": e.service,
|
|
191
|
-
"model": e.model, "note": e.note,
|
|
192
|
-
})
|
|
193
|
-
})
|
|
194
|
-
.collect();
|
|
195
|
-
println!(
|
|
196
|
-
"{}",
|
|
197
|
-
json!({
|
|
198
|
-
"this_month_usd": (month_tot * 100.0).round() / 100.0,
|
|
199
|
-
"last_n_days_usd": ((rolls_into(month_tot, rolling)) * 100.0).round() / 100.0,
|
|
200
|
-
"all_time_usd": (all * 100.0).round() / 100.0,
|
|
201
|
-
"entries": arr,
|
|
202
|
-
})
|
|
203
|
-
);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
println!();
|
|
208
|
-
println!(
|
|
209
|
-
" {BOLD}costcrew spend{RESET} {DIM}· your AI cash outlay (only what you recorded){RESET}"
|
|
210
|
-
);
|
|
211
|
-
println!();
|
|
212
|
-
|
|
213
|
-
if entries.is_empty() {
|
|
214
|
-
println!(" {YELLOW}nothing recorded yet.{RESET}");
|
|
215
|
-
println!();
|
|
216
|
-
println!(" {DIM}examples:{RESET}");
|
|
217
|
-
println!(" {CYAN}costcrew spend add 20 --service \"Claude Pro\"{RESET}");
|
|
218
|
-
println!(" {CYAN}costcrew spend add 25 --service \"OpenRouter\" --note \"credit top-up\"{RESET}");
|
|
219
|
-
println!(" {CYAN}costcrew spend add 1.84 --service \"Vast.ai\" --model llama-70b --note \"2h @ $0.92/h\"{RESET}");
|
|
220
|
-
println!();
|
|
221
|
-
println!(
|
|
222
|
-
" {DIM}this ledger is what actually leaves your bank — `{CYAN}scan{DIM}` estimates\n per-token usage, `{CYAN}openrouter{DIM}` reports key spend, this records the real bills.{RESET}"
|
|
223
|
-
);
|
|
224
|
-
println!();
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
let this_month = total_in_month(&entries, &month_of(&today));
|
|
229
|
-
let rolling = total_last_n_days(&entries, &today, lookback_days);
|
|
230
|
-
let all_time: f64 = entries.iter().map(|e| e.amount).sum();
|
|
231
|
-
|
|
232
|
-
println!(
|
|
233
|
-
" {DIM}last {}d{RESET} {BOLD}${:>8.2}{RESET} {DIM}this month{RESET} {BOLD}${:>8.2}{RESET} {DIM}all-time{RESET} {BOLD}${:>8.2}{RESET}",
|
|
234
|
-
lookback_days, rolling.min(this_month.max(rolling)), this_month, all_time
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
// by service — last N days
|
|
238
|
-
let mut by_service: Vec<(String, f64)> = Vec::new();
|
|
239
|
-
for e in &entries {
|
|
240
|
-
if !in_last_n_days(&e.date, &today, lookback_days) {
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
match by_service.iter_mut().find(|(s, _)| *s == e.service) {
|
|
244
|
-
Some((_, v)) => *v += e.amount,
|
|
245
|
-
None => by_service.push((e.service.clone(), e.amount)),
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
by_service.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
249
|
-
if !by_service.is_empty() {
|
|
250
|
-
println!();
|
|
251
|
-
println!(" {DIM}by service · last {}d{RESET}", lookback_days);
|
|
252
|
-
let max = by_service.iter().fold(0.0f64, |m, (_, v)| m.max(*v));
|
|
253
|
-
for (s, v) in &by_service {
|
|
254
|
-
let bar = bar(*v, max);
|
|
255
|
-
let col = value_color(*v, max);
|
|
256
|
-
println!(" {:<18} {col}${:>7.2}{RESET} {bar}", truncate(s, 18), v);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// by month, rolling 6
|
|
261
|
-
let mut by_month: Vec<(String, f64)> = Vec::new();
|
|
262
|
-
for e in &entries {
|
|
263
|
-
let m = month_of(&e.date);
|
|
264
|
-
match by_month.iter_mut().find(|(s, _)| *s == m) {
|
|
265
|
-
Some((_, v)) => *v += e.amount,
|
|
266
|
-
None => by_month.push((m, e.amount)),
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
by_month.sort_by(|a, b| a.0.cmp(&b.0));
|
|
270
|
-
let by_month = &by_month[by_month.len().saturating_sub(6)..];
|
|
271
|
-
if !by_month.is_empty() {
|
|
272
|
-
println!();
|
|
273
|
-
println!(" {DIM}by month{RESET}");
|
|
274
|
-
let max = by_month.iter().fold(0.0f64, |m, (_, v)| m.max(*v));
|
|
275
|
-
for (m, v) in by_month {
|
|
276
|
-
let bar = bar(*v, max);
|
|
277
|
-
let mad = if *m == month_of(&today) { BOLD } else { "" };
|
|
278
|
-
println!(" {mad}{m}{RESET} {RED}${:>7.2}{RESET} {bar}", v);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
println!();
|
|
283
|
-
println!(
|
|
284
|
-
" {DIM}estimate of the same window per-token: {RESET}{CYAN}costcrew scan{RESET}{DIM} · provider truth: {RESET}{CYAN}costcrew openrouter{RESET}{DIM}{RESET}"
|
|
285
|
-
);
|
|
286
|
-
println!();
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
fn rolls_into(_a: f64, b: f64) -> f64 {
|
|
290
|
-
b
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
fn value_color(v: f64, max: f64) -> &'static str {
|
|
294
|
-
let frac = if max > 0.0 { v / max } else { 0.0 };
|
|
295
|
-
if frac >= 0.66 {
|
|
296
|
-
RED
|
|
297
|
-
} else if frac >= 0.33 {
|
|
298
|
-
YELLOW
|
|
299
|
-
} else {
|
|
300
|
-
GREEN
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
fn bar(v: f64, max: f64) -> String {
|
|
305
|
-
if max <= 0.0 {
|
|
306
|
-
return String::new();
|
|
307
|
-
}
|
|
308
|
-
let n = ((v / max) * 40.0).round() as usize;
|
|
309
|
-
format!("{:<40}", "█".repeat(n))
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
fn truncate(s: &str, n: usize) -> String {
|
|
313
|
-
if s.chars().count() <= n {
|
|
314
|
-
s.to_string()
|
|
315
|
-
} else {
|
|
316
|
-
s.chars().take(n - 1).collect::<String>() + "…"
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/// "$1,234.56" / "19.99" / "0" → f64
|
|
321
|
-
pub fn parse_amount(s: &str) -> Result<f64, String> {
|
|
322
|
-
let cleaned: String = s
|
|
323
|
-
.chars()
|
|
324
|
-
.filter(|c| !matches!(c, '$' | ',' | ' '))
|
|
325
|
-
.collect();
|
|
326
|
-
cleaned
|
|
327
|
-
.parse::<f64>()
|
|
328
|
-
.map_err(|_| format!("not a dollar amount: {s}"))
|
|
329
|
-
.and_then(|v| {
|
|
330
|
-
if v.is_finite() && v >= 0.0 {
|
|
331
|
-
Ok(v)
|
|
332
|
-
} else {
|
|
333
|
-
Err(format!("amount must be ≥ 0, got {s}"))
|
|
334
|
-
}
|
|
335
|
-
})
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/// weekday-aware date helpers without chrono: days-from-civil (Hinnant)
|
|
339
|
-
fn days_from_civil(date: &str) -> i64 {
|
|
340
|
-
let parts: Vec<_> = date
|
|
341
|
-
.split('-')
|
|
342
|
-
.filter_map(|p| p.parse::<i64>().ok())
|
|
343
|
-
.collect();
|
|
344
|
-
if parts.len() != 3 {
|
|
345
|
-
return i64::MIN;
|
|
346
|
-
}
|
|
347
|
-
let (y, m, d) = (parts[0], parts[1], parts[2]);
|
|
348
|
-
let y = if m <= 2 { y - 1 } else { y };
|
|
349
|
-
let era = y.div_euclid(400);
|
|
350
|
-
let yoe = y - era * 400;
|
|
351
|
-
let mp = (m - 3).rem_euclid(12);
|
|
352
|
-
let doy = (153 * mp + 2) / 5 + d - 1;
|
|
353
|
-
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
354
|
-
era * 146_097 + doe
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
pub fn today() -> String {
|
|
358
|
-
let ms = SystemTime::now()
|
|
359
|
-
.duration_since(SystemTime::UNIX_EPOCH)
|
|
360
|
-
.map(|d: Duration| d.as_millis() as u64)
|
|
361
|
-
.unwrap_or(0);
|
|
362
|
-
let z = (ms / 86_400_000) as i64 + 719_468;
|
|
363
|
-
let era = z.div_euclid(146_097);
|
|
364
|
-
let doe = z.rem_euclid(146_097);
|
|
365
|
-
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
366
|
-
let mut y = yoe + era * 400;
|
|
367
|
-
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
368
|
-
let mp = (5 * doy + 2) / 153;
|
|
369
|
-
let d = doy - (153 * mp + 2) / 5 + 1;
|
|
370
|
-
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
371
|
-
if m <= 2 {
|
|
372
|
-
y += 1;
|
|
373
|
-
}
|
|
374
|
-
format!("{y:04}-{m:02}-{d:02}")
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
fn month_of(date: &str) -> String {
|
|
378
|
-
date.chars().take(7).collect()
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
fn in_last_n_days(date: &str, today: &str, n: u32) -> bool {
|
|
382
|
-
let (d, t) = (days_from_civil(date), days_from_civil(today));
|
|
383
|
-
if d < i64::MAX / 2 {
|
|
384
|
-
t >= d && (t - d) < n as i64
|
|
385
|
-
} else {
|
|
386
|
-
false
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
fn total_in_month(entries: &[Entry], month: &str) -> f64 {
|
|
391
|
-
entries
|
|
392
|
-
.iter()
|
|
393
|
-
.filter(|e| month_of(&e.date) == month)
|
|
394
|
-
.map(|e| e.amount)
|
|
395
|
-
.sum()
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
fn total_last_n_days(entries: &[Entry], today: &str, n: u32) -> f64 {
|
|
399
|
-
entries
|
|
400
|
-
.iter()
|
|
401
|
-
.filter(|e| in_last_n_days(&e.date, today, n))
|
|
402
|
-
.map(|e| e.amount)
|
|
403
|
-
.sum()
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
#[cfg(test)]
|
|
407
|
-
mod tests {
|
|
408
|
-
use super::*;
|
|
409
|
-
|
|
410
|
-
#[test]
|
|
411
|
-
fn amounts_decode() {
|
|
412
|
-
assert_eq!(parse_amount("19.99").unwrap(), 19.99);
|
|
413
|
-
assert_eq!(parse_amount("$1,234.50").unwrap(), 1234.50);
|
|
414
|
-
assert_eq!(parse_amount("0").unwrap(), 0.0);
|
|
415
|
-
assert!(parse_amount("abc").is_err());
|
|
416
|
-
assert!(parse_amount("-5").is_err());
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
#[test]
|
|
420
|
-
fn civil_days_are_monotonic() {
|
|
421
|
-
// sanity: crossing a month/year boundary keeps ordering
|
|
422
|
-
let a = days_from_civil("2026-11-09");
|
|
423
|
-
let b = days_from_civil("2026-11-10");
|
|
424
|
-
let c = days_from_civil("2027-01-01");
|
|
425
|
-
assert!(a < b && b < c);
|
|
426
|
-
// ...and is consistent with days_from_civil convention on a known pair:
|
|
427
|
-
// 2024-02-29 must exist after 2024-02-28 (leap day)
|
|
428
|
-
assert!(days_from_civil("2024-02-28") < days_from_civil("2024-02-29"));
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
#[test]
|
|
432
|
-
fn month_windows() {
|
|
433
|
-
let e = |d: &str, a: f64| Entry {
|
|
434
|
-
date: d.into(),
|
|
435
|
-
amount: a,
|
|
436
|
-
service: "test".into(),
|
|
437
|
-
model: String::new(),
|
|
438
|
-
note: String::new(),
|
|
439
|
-
};
|
|
440
|
-
let today = "2026-11-10";
|
|
441
|
-
let entries = vec![
|
|
442
|
-
e("2026-11-01", 20.0),
|
|
443
|
-
e("2026-10-15", 100.0),
|
|
444
|
-
e("2026-11-09", 5.5),
|
|
445
|
-
];
|
|
446
|
-
assert_eq!(total_in_month(&entries, "2026-11"), 25.5);
|
|
447
|
-
assert_eq!(total_in_month(&entries, "2026-10"), 100.0);
|
|
448
|
-
assert_eq!(total_last_n_days(&entries, today, 30), 125.5); // Oct15 = 26d back, inside
|
|
449
|
-
// 25-day window: back to Oct 16 → October entry excluded
|
|
450
|
-
assert_eq!(total_last_n_days(&entries, today, 25), 25.5);
|
|
451
|
-
}
|
|
452
|
-
}
|