@hasna/terminal 2.3.0 → 2.3.1

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.
Files changed (202) hide show
  1. package/dist/cli.js +64 -16
  2. package/package.json +1 -1
  3. package/src/ai.ts +8 -0
  4. package/src/cli.tsx +57 -18
  5. package/src/output-processor.ts +6 -1
  6. package/src/output-store.ts +58 -12
  7. package/src/tool-profiles.ts +139 -0
  8. package/temp/rtk/.claude/agents/code-reviewer.md +0 -221
  9. package/temp/rtk/.claude/agents/debugger.md +0 -519
  10. package/temp/rtk/.claude/agents/rtk-testing-specialist.md +0 -461
  11. package/temp/rtk/.claude/agents/rust-rtk.md +0 -511
  12. package/temp/rtk/.claude/agents/technical-writer.md +0 -355
  13. package/temp/rtk/.claude/commands/diagnose.md +0 -352
  14. package/temp/rtk/.claude/commands/test-routing.md +0 -362
  15. package/temp/rtk/.claude/hooks/bash/pre-commit-format.sh +0 -16
  16. package/temp/rtk/.claude/hooks/rtk-rewrite.sh +0 -70
  17. package/temp/rtk/.claude/hooks/rtk-suggest.sh +0 -152
  18. package/temp/rtk/.claude/rules/cli-testing.md +0 -526
  19. package/temp/rtk/.claude/skills/issue-triage/SKILL.md +0 -348
  20. package/temp/rtk/.claude/skills/issue-triage/templates/issue-comment.md +0 -134
  21. package/temp/rtk/.claude/skills/performance.md +0 -435
  22. package/temp/rtk/.claude/skills/pr-triage/SKILL.md +0 -315
  23. package/temp/rtk/.claude/skills/pr-triage/templates/review-comment.md +0 -71
  24. package/temp/rtk/.claude/skills/repo-recap.md +0 -206
  25. package/temp/rtk/.claude/skills/rtk-tdd/SKILL.md +0 -78
  26. package/temp/rtk/.claude/skills/rtk-tdd/references/testing-patterns.md +0 -124
  27. package/temp/rtk/.claude/skills/security-guardian.md +0 -503
  28. package/temp/rtk/.claude/skills/ship.md +0 -404
  29. package/temp/rtk/.github/workflows/benchmark.yml +0 -34
  30. package/temp/rtk/.github/workflows/dco-check.yaml +0 -12
  31. package/temp/rtk/.github/workflows/release-please.yml +0 -51
  32. package/temp/rtk/.github/workflows/release.yml +0 -343
  33. package/temp/rtk/.github/workflows/security-check.yml +0 -135
  34. package/temp/rtk/.github/workflows/validate-docs.yml +0 -78
  35. package/temp/rtk/.release-please-manifest.json +0 -3
  36. package/temp/rtk/ARCHITECTURE.md +0 -1491
  37. package/temp/rtk/CHANGELOG.md +0 -640
  38. package/temp/rtk/CLAUDE.md +0 -605
  39. package/temp/rtk/CONTRIBUTING.md +0 -199
  40. package/temp/rtk/Cargo.lock +0 -1668
  41. package/temp/rtk/Cargo.toml +0 -64
  42. package/temp/rtk/Formula/rtk.rb +0 -43
  43. package/temp/rtk/INSTALL.md +0 -390
  44. package/temp/rtk/LICENSE +0 -21
  45. package/temp/rtk/README.md +0 -386
  46. package/temp/rtk/README_es.md +0 -159
  47. package/temp/rtk/README_fr.md +0 -197
  48. package/temp/rtk/README_ja.md +0 -159
  49. package/temp/rtk/README_ko.md +0 -159
  50. package/temp/rtk/README_zh.md +0 -167
  51. package/temp/rtk/ROADMAP.md +0 -15
  52. package/temp/rtk/SECURITY.md +0 -217
  53. package/temp/rtk/TEST_EXEC_TIME.md +0 -102
  54. package/temp/rtk/build.rs +0 -57
  55. package/temp/rtk/docs/AUDIT_GUIDE.md +0 -432
  56. package/temp/rtk/docs/FEATURES.md +0 -1410
  57. package/temp/rtk/docs/TROUBLESHOOTING.md +0 -309
  58. package/temp/rtk/docs/filter-workflow.md +0 -102
  59. package/temp/rtk/docs/images/gain-dashboard.jpg +0 -0
  60. package/temp/rtk/docs/tracking.md +0 -583
  61. package/temp/rtk/hooks/opencode-rtk.ts +0 -39
  62. package/temp/rtk/hooks/rtk-awareness.md +0 -29
  63. package/temp/rtk/hooks/rtk-rewrite.sh +0 -61
  64. package/temp/rtk/hooks/test-rtk-rewrite.sh +0 -442
  65. package/temp/rtk/install.sh +0 -124
  66. package/temp/rtk/release-please-config.json +0 -10
  67. package/temp/rtk/scripts/benchmark.sh +0 -592
  68. package/temp/rtk/scripts/check-installation.sh +0 -162
  69. package/temp/rtk/scripts/install-local.sh +0 -37
  70. package/temp/rtk/scripts/rtk-economics.sh +0 -137
  71. package/temp/rtk/scripts/test-all.sh +0 -561
  72. package/temp/rtk/scripts/test-aristote.sh +0 -227
  73. package/temp/rtk/scripts/test-tracking.sh +0 -79
  74. package/temp/rtk/scripts/update-readme-metrics.sh +0 -32
  75. package/temp/rtk/scripts/validate-docs.sh +0 -73
  76. package/temp/rtk/src/aws_cmd.rs +0 -880
  77. package/temp/rtk/src/binlog.rs +0 -1645
  78. package/temp/rtk/src/cargo_cmd.rs +0 -1727
  79. package/temp/rtk/src/cc_economics.rs +0 -1157
  80. package/temp/rtk/src/ccusage.rs +0 -340
  81. package/temp/rtk/src/config.rs +0 -187
  82. package/temp/rtk/src/container.rs +0 -855
  83. package/temp/rtk/src/curl_cmd.rs +0 -134
  84. package/temp/rtk/src/deps.rs +0 -268
  85. package/temp/rtk/src/diff_cmd.rs +0 -367
  86. package/temp/rtk/src/discover/mod.rs +0 -274
  87. package/temp/rtk/src/discover/provider.rs +0 -388
  88. package/temp/rtk/src/discover/registry.rs +0 -2022
  89. package/temp/rtk/src/discover/report.rs +0 -202
  90. package/temp/rtk/src/discover/rules.rs +0 -667
  91. package/temp/rtk/src/display_helpers.rs +0 -402
  92. package/temp/rtk/src/dotnet_cmd.rs +0 -1771
  93. package/temp/rtk/src/dotnet_format_report.rs +0 -133
  94. package/temp/rtk/src/dotnet_trx.rs +0 -593
  95. package/temp/rtk/src/env_cmd.rs +0 -204
  96. package/temp/rtk/src/filter.rs +0 -462
  97. package/temp/rtk/src/filters/README.md +0 -52
  98. package/temp/rtk/src/filters/ansible-playbook.toml +0 -34
  99. package/temp/rtk/src/filters/basedpyright.toml +0 -47
  100. package/temp/rtk/src/filters/biome.toml +0 -45
  101. package/temp/rtk/src/filters/brew-install.toml +0 -37
  102. package/temp/rtk/src/filters/composer-install.toml +0 -40
  103. package/temp/rtk/src/filters/df.toml +0 -16
  104. package/temp/rtk/src/filters/dotnet-build.toml +0 -64
  105. package/temp/rtk/src/filters/du.toml +0 -16
  106. package/temp/rtk/src/filters/fail2ban-client.toml +0 -15
  107. package/temp/rtk/src/filters/gcc.toml +0 -49
  108. package/temp/rtk/src/filters/gcloud.toml +0 -22
  109. package/temp/rtk/src/filters/hadolint.toml +0 -24
  110. package/temp/rtk/src/filters/helm.toml +0 -29
  111. package/temp/rtk/src/filters/iptables.toml +0 -27
  112. package/temp/rtk/src/filters/jj.toml +0 -28
  113. package/temp/rtk/src/filters/jq.toml +0 -24
  114. package/temp/rtk/src/filters/make.toml +0 -41
  115. package/temp/rtk/src/filters/markdownlint.toml +0 -24
  116. package/temp/rtk/src/filters/mix-compile.toml +0 -27
  117. package/temp/rtk/src/filters/mix-format.toml +0 -15
  118. package/temp/rtk/src/filters/mvn-build.toml +0 -44
  119. package/temp/rtk/src/filters/oxlint.toml +0 -43
  120. package/temp/rtk/src/filters/ping.toml +0 -63
  121. package/temp/rtk/src/filters/pio-run.toml +0 -40
  122. package/temp/rtk/src/filters/poetry-install.toml +0 -50
  123. package/temp/rtk/src/filters/pre-commit.toml +0 -35
  124. package/temp/rtk/src/filters/ps.toml +0 -16
  125. package/temp/rtk/src/filters/quarto-render.toml +0 -41
  126. package/temp/rtk/src/filters/rsync.toml +0 -48
  127. package/temp/rtk/src/filters/shellcheck.toml +0 -27
  128. package/temp/rtk/src/filters/shopify-theme.toml +0 -29
  129. package/temp/rtk/src/filters/skopeo.toml +0 -45
  130. package/temp/rtk/src/filters/sops.toml +0 -16
  131. package/temp/rtk/src/filters/ssh.toml +0 -44
  132. package/temp/rtk/src/filters/stat.toml +0 -34
  133. package/temp/rtk/src/filters/swift-build.toml +0 -41
  134. package/temp/rtk/src/filters/systemctl-status.toml +0 -33
  135. package/temp/rtk/src/filters/terraform-plan.toml +0 -35
  136. package/temp/rtk/src/filters/tofu-fmt.toml +0 -16
  137. package/temp/rtk/src/filters/tofu-init.toml +0 -38
  138. package/temp/rtk/src/filters/tofu-plan.toml +0 -35
  139. package/temp/rtk/src/filters/tofu-validate.toml +0 -17
  140. package/temp/rtk/src/filters/trunk-build.toml +0 -39
  141. package/temp/rtk/src/filters/ty.toml +0 -50
  142. package/temp/rtk/src/filters/uv-sync.toml +0 -37
  143. package/temp/rtk/src/filters/xcodebuild.toml +0 -99
  144. package/temp/rtk/src/filters/yamllint.toml +0 -25
  145. package/temp/rtk/src/find_cmd.rs +0 -598
  146. package/temp/rtk/src/format_cmd.rs +0 -386
  147. package/temp/rtk/src/gain.rs +0 -723
  148. package/temp/rtk/src/gh_cmd.rs +0 -1651
  149. package/temp/rtk/src/git.rs +0 -2012
  150. package/temp/rtk/src/go_cmd.rs +0 -592
  151. package/temp/rtk/src/golangci_cmd.rs +0 -254
  152. package/temp/rtk/src/grep_cmd.rs +0 -288
  153. package/temp/rtk/src/gt_cmd.rs +0 -810
  154. package/temp/rtk/src/hook_audit_cmd.rs +0 -283
  155. package/temp/rtk/src/hook_check.rs +0 -171
  156. package/temp/rtk/src/init.rs +0 -1859
  157. package/temp/rtk/src/integrity.rs +0 -537
  158. package/temp/rtk/src/json_cmd.rs +0 -231
  159. package/temp/rtk/src/learn/detector.rs +0 -628
  160. package/temp/rtk/src/learn/mod.rs +0 -119
  161. package/temp/rtk/src/learn/report.rs +0 -184
  162. package/temp/rtk/src/lint_cmd.rs +0 -694
  163. package/temp/rtk/src/local_llm.rs +0 -316
  164. package/temp/rtk/src/log_cmd.rs +0 -248
  165. package/temp/rtk/src/ls.rs +0 -324
  166. package/temp/rtk/src/main.rs +0 -2482
  167. package/temp/rtk/src/mypy_cmd.rs +0 -389
  168. package/temp/rtk/src/next_cmd.rs +0 -241
  169. package/temp/rtk/src/npm_cmd.rs +0 -236
  170. package/temp/rtk/src/parser/README.md +0 -267
  171. package/temp/rtk/src/parser/error.rs +0 -46
  172. package/temp/rtk/src/parser/formatter.rs +0 -336
  173. package/temp/rtk/src/parser/mod.rs +0 -311
  174. package/temp/rtk/src/parser/types.rs +0 -119
  175. package/temp/rtk/src/pip_cmd.rs +0 -302
  176. package/temp/rtk/src/playwright_cmd.rs +0 -479
  177. package/temp/rtk/src/pnpm_cmd.rs +0 -573
  178. package/temp/rtk/src/prettier_cmd.rs +0 -221
  179. package/temp/rtk/src/prisma_cmd.rs +0 -482
  180. package/temp/rtk/src/psql_cmd.rs +0 -382
  181. package/temp/rtk/src/pytest_cmd.rs +0 -384
  182. package/temp/rtk/src/read.rs +0 -217
  183. package/temp/rtk/src/rewrite_cmd.rs +0 -50
  184. package/temp/rtk/src/ruff_cmd.rs +0 -402
  185. package/temp/rtk/src/runner.rs +0 -271
  186. package/temp/rtk/src/summary.rs +0 -297
  187. package/temp/rtk/src/tee.rs +0 -405
  188. package/temp/rtk/src/telemetry.rs +0 -248
  189. package/temp/rtk/src/toml_filter.rs +0 -1655
  190. package/temp/rtk/src/tracking.rs +0 -1416
  191. package/temp/rtk/src/tree.rs +0 -209
  192. package/temp/rtk/src/tsc_cmd.rs +0 -259
  193. package/temp/rtk/src/utils.rs +0 -432
  194. package/temp/rtk/src/verify_cmd.rs +0 -47
  195. package/temp/rtk/src/vitest_cmd.rs +0 -385
  196. package/temp/rtk/src/wc_cmd.rs +0 -401
  197. package/temp/rtk/src/wget_cmd.rs +0 -260
  198. package/temp/rtk/tests/fixtures/dotnet/build_failed.txt +0 -11
  199. package/temp/rtk/tests/fixtures/dotnet/format_changes.json +0 -31
  200. package/temp/rtk/tests/fixtures/dotnet/format_empty.json +0 -1
  201. package/temp/rtk/tests/fixtures/dotnet/format_success.json +0 -12
  202. package/temp/rtk/tests/fixtures/dotnet/test_failed.txt +0 -18
@@ -1,1157 +0,0 @@
1
- //! Claude Code Economics: Spending vs Savings Analysis
2
- //!
3
- //! Combines ccusage (tokens spent) with rtk tracking (tokens saved) to provide
4
- //! dual-metric economic impact reporting with blended and active cost-per-token.
5
-
6
- use anyhow::{Context, Result};
7
- use chrono::NaiveDate;
8
- use serde::Serialize;
9
- use std::collections::HashMap;
10
-
11
- use crate::ccusage::{self, CcusagePeriod, Granularity};
12
- use crate::tracking::{DayStats, MonthStats, Tracker, WeekStats};
13
- use crate::utils::{format_cpt, format_tokens, format_usd};
14
-
15
- // ── Constants ──
16
-
17
- const BILLION: f64 = 1e9;
18
-
19
- // API pricing ratios (verified Feb 2026, consistent across Claude models <=200K context)
20
- // Source: https://docs.anthropic.com/en/docs/about-claude/models
21
- const WEIGHT_OUTPUT: f64 = 5.0; // Output = 5x input
22
- const WEIGHT_CACHE_CREATE: f64 = 1.25; // Cache write = 1.25x input
23
- const WEIGHT_CACHE_READ: f64 = 0.1; // Cache read = 0.1x input
24
-
25
- // ── Types ──
26
-
27
- #[derive(Debug, Serialize)]
28
- pub struct PeriodEconomics {
29
- pub label: String,
30
- // ccusage metrics (Option for graceful degradation)
31
- pub cc_cost: Option<f64>,
32
- pub cc_total_tokens: Option<u64>,
33
- pub cc_active_tokens: Option<u64>, // input + output only (excluding cache)
34
- // Per-type token breakdown
35
- pub cc_input_tokens: Option<u64>,
36
- pub cc_output_tokens: Option<u64>,
37
- pub cc_cache_create_tokens: Option<u64>,
38
- pub cc_cache_read_tokens: Option<u64>,
39
- // rtk metrics
40
- pub rtk_commands: Option<usize>,
41
- pub rtk_saved_tokens: Option<usize>,
42
- pub rtk_savings_pct: Option<f64>,
43
- // Primary metric (weighted input CPT)
44
- pub weighted_input_cpt: Option<f64>, // Derived input CPT using API ratios
45
- pub savings_weighted: Option<f64>, // saved * weighted_input_cpt (PRIMARY)
46
- // Legacy metrics (verbose mode only)
47
- pub blended_cpt: Option<f64>, // cost / total_tokens (diluted by cache)
48
- pub active_cpt: Option<f64>, // cost / active_tokens (OVERESTIMATES)
49
- pub savings_blended: Option<f64>, // saved * blended_cpt (UNDERESTIMATES)
50
- pub savings_active: Option<f64>, // saved * active_cpt (OVERESTIMATES)
51
- }
52
-
53
- impl PeriodEconomics {
54
- fn new(label: &str) -> Self {
55
- Self {
56
- label: label.to_string(),
57
- cc_cost: None,
58
- cc_total_tokens: None,
59
- cc_active_tokens: None,
60
- cc_input_tokens: None,
61
- cc_output_tokens: None,
62
- cc_cache_create_tokens: None,
63
- cc_cache_read_tokens: None,
64
- rtk_commands: None,
65
- rtk_saved_tokens: None,
66
- rtk_savings_pct: None,
67
- weighted_input_cpt: None,
68
- savings_weighted: None,
69
- blended_cpt: None,
70
- active_cpt: None,
71
- savings_blended: None,
72
- savings_active: None,
73
- }
74
- }
75
-
76
- fn set_ccusage(&mut self, metrics: &ccusage::CcusageMetrics) {
77
- self.cc_cost = Some(metrics.total_cost);
78
- self.cc_total_tokens = Some(metrics.total_tokens);
79
-
80
- // Store per-type tokens
81
- self.cc_input_tokens = Some(metrics.input_tokens);
82
- self.cc_output_tokens = Some(metrics.output_tokens);
83
- self.cc_cache_create_tokens = Some(metrics.cache_creation_tokens);
84
- self.cc_cache_read_tokens = Some(metrics.cache_read_tokens);
85
-
86
- // Active tokens (legacy)
87
- let active = metrics.input_tokens + metrics.output_tokens;
88
- self.cc_active_tokens = Some(active);
89
- }
90
-
91
- fn set_rtk_from_day(&mut self, stats: &DayStats) {
92
- self.rtk_commands = Some(stats.commands);
93
- self.rtk_saved_tokens = Some(stats.saved_tokens);
94
- self.rtk_savings_pct = Some(stats.savings_pct);
95
- }
96
-
97
- fn set_rtk_from_week(&mut self, stats: &WeekStats) {
98
- self.rtk_commands = Some(stats.commands);
99
- self.rtk_saved_tokens = Some(stats.saved_tokens);
100
- self.rtk_savings_pct = Some(stats.savings_pct);
101
- }
102
-
103
- fn set_rtk_from_month(&mut self, stats: &MonthStats) {
104
- self.rtk_commands = Some(stats.commands);
105
- self.rtk_saved_tokens = Some(stats.saved_tokens);
106
- self.rtk_savings_pct = Some(if stats.input_tokens + stats.output_tokens > 0 {
107
- stats.saved_tokens as f64
108
- / (stats.saved_tokens + stats.input_tokens + stats.output_tokens) as f64
109
- * 100.0
110
- } else {
111
- 0.0
112
- });
113
- }
114
-
115
- fn compute_weighted_metrics(&mut self) {
116
- // Weighted input CPT derivation using API price ratios
117
- if let (Some(cost), Some(saved)) = (self.cc_cost, self.rtk_saved_tokens) {
118
- if let (Some(input), Some(output), Some(cache_create), Some(cache_read)) = (
119
- self.cc_input_tokens,
120
- self.cc_output_tokens,
121
- self.cc_cache_create_tokens,
122
- self.cc_cache_read_tokens,
123
- ) {
124
- // Weighted units = input + 5*output + 1.25*cache_create + 0.1*cache_read
125
- let weighted_units = input as f64
126
- + WEIGHT_OUTPUT * output as f64
127
- + WEIGHT_CACHE_CREATE * cache_create as f64
128
- + WEIGHT_CACHE_READ * cache_read as f64;
129
-
130
- if weighted_units > 0.0 {
131
- let input_cpt = cost / weighted_units;
132
- let savings = saved as f64 * input_cpt;
133
-
134
- self.weighted_input_cpt = Some(input_cpt);
135
- self.savings_weighted = Some(savings);
136
- }
137
- }
138
- }
139
- }
140
-
141
- fn compute_dual_metrics(&mut self) {
142
- if let (Some(cost), Some(saved)) = (self.cc_cost, self.rtk_saved_tokens) {
143
- // Blended CPT (cost / total_tokens including cache)
144
- if let Some(total) = self.cc_total_tokens {
145
- if total > 0 {
146
- self.blended_cpt = Some(cost / total as f64);
147
- self.savings_blended = Some(saved as f64 * (cost / total as f64));
148
- }
149
- }
150
-
151
- // Active CPT (cost / active_tokens = input+output only)
152
- if let Some(active) = self.cc_active_tokens {
153
- if active > 0 {
154
- self.active_cpt = Some(cost / active as f64);
155
- self.savings_active = Some(saved as f64 * (cost / active as f64));
156
- }
157
- }
158
- }
159
- }
160
- }
161
-
162
- #[derive(Debug, Serialize)]
163
- struct Totals {
164
- cc_cost: f64,
165
- cc_total_tokens: u64,
166
- cc_active_tokens: u64,
167
- cc_input_tokens: u64,
168
- cc_output_tokens: u64,
169
- cc_cache_create_tokens: u64,
170
- cc_cache_read_tokens: u64,
171
- rtk_commands: usize,
172
- rtk_saved_tokens: usize,
173
- rtk_avg_savings_pct: f64,
174
- weighted_input_cpt: Option<f64>,
175
- savings_weighted: Option<f64>,
176
- blended_cpt: Option<f64>,
177
- active_cpt: Option<f64>,
178
- savings_blended: Option<f64>,
179
- savings_active: Option<f64>,
180
- }
181
-
182
- // ── Public API ──
183
-
184
- pub fn run(
185
- daily: bool,
186
- weekly: bool,
187
- monthly: bool,
188
- all: bool,
189
- format: &str,
190
- verbose: u8,
191
- ) -> Result<()> {
192
- let tracker = Tracker::new().context("Failed to initialize tracking database")?;
193
-
194
- match format {
195
- "json" => export_json(&tracker, daily, weekly, monthly, all),
196
- "csv" => export_csv(&tracker, daily, weekly, monthly, all),
197
- _ => display_text(&tracker, daily, weekly, monthly, all, verbose),
198
- }
199
- }
200
-
201
- // ── Merge Logic ──
202
-
203
- fn merge_daily(cc: Option<Vec<CcusagePeriod>>, rtk: Vec<DayStats>) -> Vec<PeriodEconomics> {
204
- let mut map: HashMap<String, PeriodEconomics> = HashMap::new();
205
-
206
- // Insert ccusage data
207
- if let Some(cc_data) = cc {
208
- for entry in cc_data {
209
- let crate::ccusage::CcusagePeriod { key, metrics } = entry;
210
- map.entry(key)
211
- .or_insert_with_key(|k| PeriodEconomics::new(k))
212
- .set_ccusage(&metrics);
213
- }
214
- }
215
-
216
- // Merge rtk data
217
- for entry in rtk {
218
- map.entry(entry.date.clone())
219
- .or_insert_with_key(|k| PeriodEconomics::new(k))
220
- .set_rtk_from_day(&entry);
221
- }
222
-
223
- // Compute dual metrics and sort
224
- let mut result: Vec<_> = map.into_values().collect();
225
- for period in &mut result {
226
- period.compute_weighted_metrics();
227
- period.compute_dual_metrics();
228
- }
229
- result.sort_by(|a, b| a.label.cmp(&b.label));
230
- result
231
- }
232
-
233
- fn merge_weekly(cc: Option<Vec<CcusagePeriod>>, rtk: Vec<WeekStats>) -> Vec<PeriodEconomics> {
234
- let mut map: HashMap<String, PeriodEconomics> = HashMap::new();
235
-
236
- // Insert ccusage data (key = ISO Monday "2026-01-20")
237
- if let Some(cc_data) = cc {
238
- for entry in cc_data {
239
- let crate::ccusage::CcusagePeriod { key, metrics } = entry;
240
- map.entry(key)
241
- .or_insert_with_key(|k| PeriodEconomics::new(k))
242
- .set_ccusage(&metrics);
243
- }
244
- }
245
-
246
- // Merge rtk data (week_start = legacy Saturday "2026-01-18")
247
- // Convert Saturday to Monday for alignment
248
- for entry in rtk {
249
- let monday_key = match convert_saturday_to_monday(&entry.week_start) {
250
- Some(m) => m,
251
- None => {
252
- eprintln!("⚠️ Invalid week_start format: {}", entry.week_start);
253
- continue;
254
- }
255
- };
256
-
257
- map.entry(monday_key)
258
- .or_insert_with_key(|key| PeriodEconomics::new(key))
259
- .set_rtk_from_week(&entry);
260
- }
261
-
262
- let mut result: Vec<_> = map.into_values().collect();
263
- for period in &mut result {
264
- period.compute_weighted_metrics();
265
- period.compute_dual_metrics();
266
- }
267
- result.sort_by(|a, b| a.label.cmp(&b.label));
268
- result
269
- }
270
-
271
- fn merge_monthly(cc: Option<Vec<CcusagePeriod>>, rtk: Vec<MonthStats>) -> Vec<PeriodEconomics> {
272
- let mut map: HashMap<String, PeriodEconomics> = HashMap::new();
273
-
274
- // Insert ccusage data
275
- if let Some(cc_data) = cc {
276
- for entry in cc_data {
277
- let crate::ccusage::CcusagePeriod { key, metrics } = entry;
278
- map.entry(key)
279
- .or_insert_with_key(|k| PeriodEconomics::new(k))
280
- .set_ccusage(&metrics);
281
- }
282
- }
283
-
284
- // Merge rtk data
285
- for entry in rtk {
286
- map.entry(entry.month.clone())
287
- .or_insert_with_key(|k| PeriodEconomics::new(k))
288
- .set_rtk_from_month(&entry);
289
- }
290
-
291
- let mut result: Vec<_> = map.into_values().collect();
292
- for period in &mut result {
293
- period.compute_weighted_metrics();
294
- period.compute_dual_metrics();
295
- }
296
- result.sort_by(|a, b| a.label.cmp(&b.label));
297
- result
298
- }
299
-
300
- // ── Helpers ──
301
-
302
- /// Convert Saturday week_start (legacy rtk) to ISO Monday
303
- /// Example: "2026-01-18" (Sat) -> "2026-01-20" (Mon)
304
- fn convert_saturday_to_monday(saturday: &str) -> Option<String> {
305
- let sat_date = NaiveDate::parse_from_str(saturday, "%Y-%m-%d").ok()?;
306
-
307
- // rtk uses Saturday as week start, ISO uses Monday
308
- // Saturday + 2 days = Monday
309
- let monday = sat_date + chrono::TimeDelta::try_days(2)?;
310
-
311
- Some(monday.format("%Y-%m-%d").to_string())
312
- }
313
-
314
- fn compute_totals(periods: &[PeriodEconomics]) -> Totals {
315
- let mut totals = Totals {
316
- cc_cost: 0.0,
317
- cc_total_tokens: 0,
318
- cc_active_tokens: 0,
319
- cc_input_tokens: 0,
320
- cc_output_tokens: 0,
321
- cc_cache_create_tokens: 0,
322
- cc_cache_read_tokens: 0,
323
- rtk_commands: 0,
324
- rtk_saved_tokens: 0,
325
- rtk_avg_savings_pct: 0.0,
326
- weighted_input_cpt: None,
327
- savings_weighted: None,
328
- blended_cpt: None,
329
- active_cpt: None,
330
- savings_blended: None,
331
- savings_active: None,
332
- };
333
-
334
- let mut pct_sum = 0.0;
335
- let mut pct_count = 0;
336
-
337
- for p in periods {
338
- if let Some(cost) = p.cc_cost {
339
- totals.cc_cost += cost;
340
- }
341
- if let Some(total) = p.cc_total_tokens {
342
- totals.cc_total_tokens += total;
343
- }
344
- if let Some(active) = p.cc_active_tokens {
345
- totals.cc_active_tokens += active;
346
- }
347
- if let Some(input) = p.cc_input_tokens {
348
- totals.cc_input_tokens += input;
349
- }
350
- if let Some(output) = p.cc_output_tokens {
351
- totals.cc_output_tokens += output;
352
- }
353
- if let Some(cache_create) = p.cc_cache_create_tokens {
354
- totals.cc_cache_create_tokens += cache_create;
355
- }
356
- if let Some(cache_read) = p.cc_cache_read_tokens {
357
- totals.cc_cache_read_tokens += cache_read;
358
- }
359
- if let Some(cmds) = p.rtk_commands {
360
- totals.rtk_commands += cmds;
361
- }
362
- if let Some(saved) = p.rtk_saved_tokens {
363
- totals.rtk_saved_tokens += saved;
364
- }
365
- if let Some(pct) = p.rtk_savings_pct {
366
- pct_sum += pct;
367
- pct_count += 1;
368
- }
369
- }
370
-
371
- if pct_count > 0 {
372
- totals.rtk_avg_savings_pct = pct_sum / pct_count as f64;
373
- }
374
-
375
- // Compute global weighted metrics
376
- let weighted_units = totals.cc_input_tokens as f64
377
- + WEIGHT_OUTPUT * totals.cc_output_tokens as f64
378
- + WEIGHT_CACHE_CREATE * totals.cc_cache_create_tokens as f64
379
- + WEIGHT_CACHE_READ * totals.cc_cache_read_tokens as f64;
380
-
381
- if weighted_units > 0.0 {
382
- let input_cpt = totals.cc_cost / weighted_units;
383
- totals.weighted_input_cpt = Some(input_cpt);
384
- totals.savings_weighted = Some(totals.rtk_saved_tokens as f64 * input_cpt);
385
- }
386
-
387
- // Compute global dual metrics (legacy)
388
- if totals.cc_total_tokens > 0 {
389
- totals.blended_cpt = Some(totals.cc_cost / totals.cc_total_tokens as f64);
390
- totals.savings_blended = Some(totals.rtk_saved_tokens as f64 * totals.blended_cpt.unwrap());
391
- }
392
- if totals.cc_active_tokens > 0 {
393
- totals.active_cpt = Some(totals.cc_cost / totals.cc_active_tokens as f64);
394
- totals.savings_active = Some(totals.rtk_saved_tokens as f64 * totals.active_cpt.unwrap());
395
- }
396
-
397
- totals
398
- }
399
-
400
- // ── Display ──
401
-
402
- fn display_text(
403
- tracker: &Tracker,
404
- daily: bool,
405
- weekly: bool,
406
- monthly: bool,
407
- all: bool,
408
- verbose: u8,
409
- ) -> Result<()> {
410
- // Default: summary view
411
- if !daily && !weekly && !monthly && !all {
412
- display_summary(tracker, verbose)?;
413
- return Ok(());
414
- }
415
-
416
- if all || daily {
417
- display_daily(tracker, verbose)?;
418
- }
419
- if all || weekly {
420
- display_weekly(tracker, verbose)?;
421
- }
422
- if all || monthly {
423
- display_monthly(tracker, verbose)?;
424
- }
425
-
426
- Ok(())
427
- }
428
-
429
- fn display_summary(tracker: &Tracker, verbose: u8) -> Result<()> {
430
- let cc_monthly =
431
- ccusage::fetch(Granularity::Monthly).context("Failed to fetch ccusage monthly data")?;
432
- let rtk_monthly = tracker
433
- .get_by_month()
434
- .context("Failed to load monthly token savings from database")?;
435
- let periods = merge_monthly(cc_monthly, rtk_monthly);
436
-
437
- if periods.is_empty() {
438
- println!("No data available. Run some rtk commands to start tracking.");
439
- return Ok(());
440
- }
441
-
442
- let totals = compute_totals(&periods);
443
-
444
- println!("💰 Claude Code Economics");
445
- println!("════════════════════════════════════════════════════");
446
- println!();
447
-
448
- println!(
449
- " Spent (ccusage): {}",
450
- format_usd(totals.cc_cost)
451
- );
452
- println!(" Token breakdown:");
453
- println!(
454
- " Input: {}",
455
- format_tokens(totals.cc_input_tokens as usize)
456
- );
457
- println!(
458
- " Output: {}",
459
- format_tokens(totals.cc_output_tokens as usize)
460
- );
461
- println!(
462
- " Cache writes: {}",
463
- format_tokens(totals.cc_cache_create_tokens as usize)
464
- );
465
- println!(
466
- " Cache reads: {}",
467
- format_tokens(totals.cc_cache_read_tokens as usize)
468
- );
469
- println!();
470
-
471
- println!(" RTK commands: {}", totals.rtk_commands);
472
- println!(
473
- " Tokens saved: {}",
474
- format_tokens(totals.rtk_saved_tokens)
475
- );
476
- println!();
477
-
478
- println!(" Estimated Savings:");
479
- println!(" ┌─────────────────────────────────────────────────┐");
480
-
481
- if let Some(weighted_savings) = totals.savings_weighted {
482
- let weighted_pct = if totals.cc_cost > 0.0 {
483
- (weighted_savings / totals.cc_cost) * 100.0
484
- } else {
485
- 0.0
486
- };
487
- println!(
488
- " │ Input token pricing: {} ({:.1}%) │",
489
- format_usd(weighted_savings).trim_end(),
490
- weighted_pct
491
- );
492
- if let Some(input_cpt) = totals.weighted_input_cpt {
493
- println!(
494
- " │ Derived input CPT: {} │",
495
- format_cpt(input_cpt)
496
- );
497
- }
498
- } else {
499
- println!(" │ Input token pricing: — │");
500
- }
501
-
502
- println!(" └─────────────────────────────────────────────────┘");
503
- println!();
504
-
505
- println!(" How it works:");
506
- println!(" RTK compresses CLI outputs before they enter Claude's context.");
507
- println!(" Savings derived using API price ratios (out=5x, cache_w=1.25x, cache_r=0.1x).");
508
- println!();
509
-
510
- // Verbose mode: legacy metrics
511
- if verbose > 0 {
512
- println!(" Legacy metrics (reference only):");
513
- if let Some(active_savings) = totals.savings_active {
514
- let active_pct = if totals.cc_cost > 0.0 {
515
- (active_savings / totals.cc_cost) * 100.0
516
- } else {
517
- 0.0
518
- };
519
- println!(
520
- " Active (OVERESTIMATES): {} ({:.1}%)",
521
- format_usd(active_savings),
522
- active_pct
523
- );
524
- }
525
- if let Some(blended_savings) = totals.savings_blended {
526
- let blended_pct = if totals.cc_cost > 0.0 {
527
- (blended_savings / totals.cc_cost) * 100.0
528
- } else {
529
- 0.0
530
- };
531
- println!(
532
- " Blended (UNDERESTIMATES): {} ({:.2}%)",
533
- format_usd(blended_savings),
534
- blended_pct
535
- );
536
- }
537
- println!(" Note: Saved tokens estimated via chars/4 heuristic, not exact tokenizer.");
538
- println!();
539
- }
540
-
541
- Ok(())
542
- }
543
-
544
- fn display_daily(tracker: &Tracker, verbose: u8) -> Result<()> {
545
- let cc_daily =
546
- ccusage::fetch(Granularity::Daily).context("Failed to fetch ccusage daily data")?;
547
- let rtk_daily = tracker
548
- .get_all_days()
549
- .context("Failed to load daily token savings from database")?;
550
- let periods = merge_daily(cc_daily, rtk_daily);
551
-
552
- println!("📅 Daily Economics");
553
- println!("════════════════════════════════════════════════════");
554
- print_period_table(&periods, verbose);
555
- Ok(())
556
- }
557
-
558
- fn display_weekly(tracker: &Tracker, verbose: u8) -> Result<()> {
559
- let cc_weekly =
560
- ccusage::fetch(Granularity::Weekly).context("Failed to fetch ccusage weekly data")?;
561
- let rtk_weekly = tracker
562
- .get_by_week()
563
- .context("Failed to load weekly token savings from database")?;
564
- let periods = merge_weekly(cc_weekly, rtk_weekly);
565
-
566
- println!("📅 Weekly Economics");
567
- println!("════════════════════════════════════════════════════");
568
- print_period_table(&periods, verbose);
569
- Ok(())
570
- }
571
-
572
- fn display_monthly(tracker: &Tracker, verbose: u8) -> Result<()> {
573
- let cc_monthly =
574
- ccusage::fetch(Granularity::Monthly).context("Failed to fetch ccusage monthly data")?;
575
- let rtk_monthly = tracker
576
- .get_by_month()
577
- .context("Failed to load monthly token savings from database")?;
578
- let periods = merge_monthly(cc_monthly, rtk_monthly);
579
-
580
- println!("📅 Monthly Economics");
581
- println!("════════════════════════════════════════════════════");
582
- print_period_table(&periods, verbose);
583
- Ok(())
584
- }
585
-
586
- fn print_period_table(periods: &[PeriodEconomics], verbose: u8) {
587
- println!();
588
-
589
- if verbose > 0 {
590
- // Verbose: include legacy metrics
591
- println!(
592
- "{:<12} {:>10} {:>10} {:>10} {:>10} {:>12} {:>12}",
593
- "Period", "Spent", "Saved", "Savings", "Active$", "Blended$", "RTK Cmds"
594
- );
595
- println!(
596
- "{:-<12} {:-<10} {:-<10} {:-<10} {:-<10} {:-<12} {:-<12}",
597
- "", "", "", "", "", "", ""
598
- );
599
-
600
- for p in periods {
601
- let spent = p.cc_cost.map(format_usd).unwrap_or_else(|| "—".to_string());
602
- let saved = p
603
- .rtk_saved_tokens
604
- .map(format_tokens)
605
- .unwrap_or_else(|| "—".to_string());
606
- let weighted = p
607
- .savings_weighted
608
- .map(format_usd)
609
- .unwrap_or_else(|| "—".to_string());
610
- let active = p
611
- .savings_active
612
- .map(format_usd)
613
- .unwrap_or_else(|| "—".to_string());
614
- let blended = p
615
- .savings_blended
616
- .map(format_usd)
617
- .unwrap_or_else(|| "—".to_string());
618
- let cmds = p
619
- .rtk_commands
620
- .map(|c| c.to_string())
621
- .unwrap_or_else(|| "—".to_string());
622
-
623
- println!(
624
- "{:<12} {:>10} {:>10} {:>10} {:>10} {:>12} {:>12}",
625
- p.label, spent, saved, weighted, active, blended, cmds
626
- );
627
- }
628
- } else {
629
- // Default: single Savings column
630
- println!(
631
- "{:<12} {:>10} {:>10} {:>10} {:>12}",
632
- "Period", "Spent", "Saved", "Savings", "RTK Cmds"
633
- );
634
- println!(
635
- "{:-<12} {:-<10} {:-<10} {:-<10} {:-<12}",
636
- "", "", "", "", ""
637
- );
638
-
639
- for p in periods {
640
- let spent = p.cc_cost.map(format_usd).unwrap_or_else(|| "—".to_string());
641
- let saved = p
642
- .rtk_saved_tokens
643
- .map(format_tokens)
644
- .unwrap_or_else(|| "—".to_string());
645
- let weighted = p
646
- .savings_weighted
647
- .map(format_usd)
648
- .unwrap_or_else(|| "—".to_string());
649
- let cmds = p
650
- .rtk_commands
651
- .map(|c| c.to_string())
652
- .unwrap_or_else(|| "—".to_string());
653
-
654
- println!(
655
- "{:<12} {:>10} {:>10} {:>10} {:>12}",
656
- p.label, spent, saved, weighted, cmds
657
- );
658
- }
659
- }
660
- println!();
661
- }
662
-
663
- // ── Export ──
664
-
665
- fn export_json(
666
- tracker: &Tracker,
667
- daily: bool,
668
- weekly: bool,
669
- monthly: bool,
670
- all: bool,
671
- ) -> Result<()> {
672
- #[derive(Serialize)]
673
- struct Export {
674
- daily: Option<Vec<PeriodEconomics>>,
675
- weekly: Option<Vec<PeriodEconomics>>,
676
- monthly: Option<Vec<PeriodEconomics>>,
677
- totals: Option<Totals>,
678
- }
679
-
680
- let mut export = Export {
681
- daily: None,
682
- weekly: None,
683
- monthly: None,
684
- totals: None,
685
- };
686
-
687
- if all || daily {
688
- let cc = ccusage::fetch(Granularity::Daily)
689
- .context("Failed to fetch ccusage daily data for JSON export")?;
690
- let rtk = tracker
691
- .get_all_days()
692
- .context("Failed to load daily token savings for JSON export")?;
693
- export.daily = Some(merge_daily(cc, rtk));
694
- }
695
-
696
- if all || weekly {
697
- let cc = ccusage::fetch(Granularity::Weekly)
698
- .context("Failed to fetch ccusage weekly data for export")?;
699
- let rtk = tracker
700
- .get_by_week()
701
- .context("Failed to load weekly token savings for export")?;
702
- export.weekly = Some(merge_weekly(cc, rtk));
703
- }
704
-
705
- if all || monthly {
706
- let cc = ccusage::fetch(Granularity::Monthly)
707
- .context("Failed to fetch ccusage monthly data for export")?;
708
- let rtk = tracker
709
- .get_by_month()
710
- .context("Failed to load monthly token savings for export")?;
711
- let periods = merge_monthly(cc, rtk);
712
- export.totals = Some(compute_totals(&periods));
713
- export.monthly = Some(periods);
714
- }
715
-
716
- println!(
717
- "{}",
718
- serde_json::to_string_pretty(&export)
719
- .context("Failed to serialize economics data to JSON")?
720
- );
721
- Ok(())
722
- }
723
-
724
- fn export_csv(
725
- tracker: &Tracker,
726
- daily: bool,
727
- weekly: bool,
728
- monthly: bool,
729
- all: bool,
730
- ) -> Result<()> {
731
- // Header (new columns: input_tokens, output_tokens, cache_create, cache_read, weighted_savings)
732
- println!("period,spent,input_tokens,output_tokens,cache_create,cache_read,active_tokens,total_tokens,saved_tokens,weighted_savings,active_savings,blended_savings,rtk_commands");
733
-
734
- if all || daily {
735
- let cc = ccusage::fetch(Granularity::Daily)
736
- .context("Failed to fetch ccusage daily data for JSON export")?;
737
- let rtk = tracker
738
- .get_all_days()
739
- .context("Failed to load daily token savings for JSON export")?;
740
- let periods = merge_daily(cc, rtk);
741
- for p in periods {
742
- print_csv_row(&p);
743
- }
744
- }
745
-
746
- if all || weekly {
747
- let cc = ccusage::fetch(Granularity::Weekly)
748
- .context("Failed to fetch ccusage weekly data for export")?;
749
- let rtk = tracker
750
- .get_by_week()
751
- .context("Failed to load weekly token savings for export")?;
752
- let periods = merge_weekly(cc, rtk);
753
- for p in periods {
754
- print_csv_row(&p);
755
- }
756
- }
757
-
758
- if all || monthly {
759
- let cc = ccusage::fetch(Granularity::Monthly)
760
- .context("Failed to fetch ccusage monthly data for export")?;
761
- let rtk = tracker
762
- .get_by_month()
763
- .context("Failed to load monthly token savings for export")?;
764
- let periods = merge_monthly(cc, rtk);
765
- for p in periods {
766
- print_csv_row(&p);
767
- }
768
- }
769
-
770
- Ok(())
771
- }
772
-
773
- fn print_csv_row(p: &PeriodEconomics) {
774
- let spent = p.cc_cost.map(|c| format!("{:.4}", c)).unwrap_or_default();
775
- let input_tokens = p.cc_input_tokens.map(|t| t.to_string()).unwrap_or_default();
776
- let output_tokens = p
777
- .cc_output_tokens
778
- .map(|t| t.to_string())
779
- .unwrap_or_default();
780
- let cache_create = p
781
- .cc_cache_create_tokens
782
- .map(|t| t.to_string())
783
- .unwrap_or_default();
784
- let cache_read = p
785
- .cc_cache_read_tokens
786
- .map(|t| t.to_string())
787
- .unwrap_or_default();
788
- let active_tokens = p
789
- .cc_active_tokens
790
- .map(|t| t.to_string())
791
- .unwrap_or_default();
792
- let total_tokens = p.cc_total_tokens.map(|t| t.to_string()).unwrap_or_default();
793
- let saved_tokens = p
794
- .rtk_saved_tokens
795
- .map(|t| t.to_string())
796
- .unwrap_or_default();
797
- let weighted_savings = p
798
- .savings_weighted
799
- .map(|s| format!("{:.4}", s))
800
- .unwrap_or_default();
801
- let active_savings = p
802
- .savings_active
803
- .map(|s| format!("{:.4}", s))
804
- .unwrap_or_default();
805
- let blended_savings = p
806
- .savings_blended
807
- .map(|s| format!("{:.4}", s))
808
- .unwrap_or_default();
809
- let cmds = p.rtk_commands.map(|c| c.to_string()).unwrap_or_default();
810
-
811
- println!(
812
- "{},{},{},{},{},{},{},{},{},{},{},{},{}",
813
- p.label,
814
- spent,
815
- input_tokens,
816
- output_tokens,
817
- cache_create,
818
- cache_read,
819
- active_tokens,
820
- total_tokens,
821
- saved_tokens,
822
- weighted_savings,
823
- active_savings,
824
- blended_savings,
825
- cmds
826
- );
827
- }
828
-
829
- #[cfg(test)]
830
- mod tests {
831
- use super::*;
832
-
833
- #[test]
834
- fn test_convert_saturday_to_monday() {
835
- // Saturday Jan 18 -> Monday Jan 20
836
- assert_eq!(
837
- convert_saturday_to_monday("2026-01-18"),
838
- Some("2026-01-20".to_string())
839
- );
840
-
841
- // Invalid format
842
- assert_eq!(convert_saturday_to_monday("invalid"), None);
843
- }
844
-
845
- #[test]
846
- fn test_period_economics_new() {
847
- let p = PeriodEconomics::new("2026-01");
848
- assert_eq!(p.label, "2026-01");
849
- assert!(p.cc_cost.is_none());
850
- assert!(p.rtk_commands.is_none());
851
- }
852
-
853
- #[test]
854
- fn test_compute_dual_metrics_with_data() {
855
- let mut p = PeriodEconomics {
856
- label: "2026-01".to_string(),
857
- cc_cost: Some(100.0),
858
- cc_total_tokens: Some(1_000_000),
859
- cc_active_tokens: Some(10_000),
860
- rtk_saved_tokens: Some(5_000),
861
- ..PeriodEconomics::new("2026-01")
862
- };
863
-
864
- p.compute_dual_metrics();
865
-
866
- assert!(p.blended_cpt.is_some());
867
- assert_eq!(p.blended_cpt.unwrap(), 100.0 / 1_000_000.0);
868
-
869
- assert!(p.active_cpt.is_some());
870
- assert_eq!(p.active_cpt.unwrap(), 100.0 / 10_000.0);
871
-
872
- assert!(p.savings_blended.is_some());
873
- assert!(p.savings_active.is_some());
874
- }
875
-
876
- #[test]
877
- fn test_compute_dual_metrics_zero_tokens() {
878
- let mut p = PeriodEconomics {
879
- label: "2026-01".to_string(),
880
- cc_cost: Some(100.0),
881
- cc_total_tokens: Some(0),
882
- cc_active_tokens: Some(0),
883
- rtk_saved_tokens: Some(5_000),
884
- ..PeriodEconomics::new("2026-01")
885
- };
886
-
887
- p.compute_dual_metrics();
888
-
889
- assert!(p.blended_cpt.is_none());
890
- assert!(p.active_cpt.is_none());
891
- assert!(p.savings_blended.is_none());
892
- assert!(p.savings_active.is_none());
893
- }
894
-
895
- #[test]
896
- fn test_compute_dual_metrics_no_ccusage_data() {
897
- let mut p = PeriodEconomics {
898
- label: "2026-01".to_string(),
899
- rtk_saved_tokens: Some(5_000),
900
- ..PeriodEconomics::new("2026-01")
901
- };
902
-
903
- p.compute_dual_metrics();
904
-
905
- assert!(p.blended_cpt.is_none());
906
- assert!(p.active_cpt.is_none());
907
- }
908
-
909
- #[test]
910
- fn test_merge_monthly_both_present() {
911
- let cc = vec![CcusagePeriod {
912
- key: "2026-01".to_string(),
913
- metrics: ccusage::CcusageMetrics {
914
- input_tokens: 1000,
915
- output_tokens: 500,
916
- cache_creation_tokens: 100,
917
- cache_read_tokens: 200,
918
- total_tokens: 1800,
919
- total_cost: 12.34,
920
- },
921
- }];
922
-
923
- let rtk = vec![MonthStats {
924
- month: "2026-01".to_string(),
925
- commands: 10,
926
- input_tokens: 800,
927
- output_tokens: 400,
928
- saved_tokens: 5000,
929
- savings_pct: 50.0,
930
- total_time_ms: 0,
931
- avg_time_ms: 0,
932
- }];
933
-
934
- let merged = merge_monthly(Some(cc), rtk);
935
- assert_eq!(merged.len(), 1);
936
- assert_eq!(merged[0].label, "2026-01");
937
- assert_eq!(merged[0].cc_cost, Some(12.34));
938
- assert_eq!(merged[0].rtk_commands, Some(10));
939
- }
940
-
941
- #[test]
942
- fn test_merge_monthly_only_ccusage() {
943
- let cc = vec![CcusagePeriod {
944
- key: "2026-01".to_string(),
945
- metrics: ccusage::CcusageMetrics {
946
- input_tokens: 1000,
947
- output_tokens: 500,
948
- cache_creation_tokens: 100,
949
- cache_read_tokens: 200,
950
- total_tokens: 1800,
951
- total_cost: 12.34,
952
- },
953
- }];
954
-
955
- let merged = merge_monthly(Some(cc), vec![]);
956
- assert_eq!(merged.len(), 1);
957
- assert_eq!(merged[0].cc_cost, Some(12.34));
958
- assert!(merged[0].rtk_commands.is_none());
959
- }
960
-
961
- #[test]
962
- fn test_merge_monthly_only_rtk() {
963
- let rtk = vec![MonthStats {
964
- month: "2026-01".to_string(),
965
- commands: 10,
966
- input_tokens: 800,
967
- output_tokens: 400,
968
- saved_tokens: 5000,
969
- savings_pct: 50.0,
970
- total_time_ms: 0,
971
- avg_time_ms: 0,
972
- }];
973
-
974
- let merged = merge_monthly(None, rtk);
975
- assert_eq!(merged.len(), 1);
976
- assert!(merged[0].cc_cost.is_none());
977
- assert_eq!(merged[0].rtk_commands, Some(10));
978
- }
979
-
980
- #[test]
981
- fn test_merge_monthly_sorted() {
982
- let rtk = vec![
983
- MonthStats {
984
- month: "2026-03".to_string(),
985
- commands: 5,
986
- input_tokens: 100,
987
- output_tokens: 50,
988
- saved_tokens: 1000,
989
- savings_pct: 40.0,
990
- total_time_ms: 0,
991
- avg_time_ms: 0,
992
- },
993
- MonthStats {
994
- month: "2026-01".to_string(),
995
- commands: 10,
996
- input_tokens: 200,
997
- output_tokens: 100,
998
- saved_tokens: 2000,
999
- savings_pct: 60.0,
1000
- total_time_ms: 0,
1001
- avg_time_ms: 0,
1002
- },
1003
- ];
1004
-
1005
- let merged = merge_monthly(None, rtk);
1006
- assert_eq!(merged.len(), 2);
1007
- assert_eq!(merged[0].label, "2026-01");
1008
- assert_eq!(merged[1].label, "2026-03");
1009
- }
1010
-
1011
- #[test]
1012
- fn test_compute_weighted_input_cpt() {
1013
- let mut p = PeriodEconomics::new("2026-01");
1014
- p.cc_cost = Some(100.0);
1015
- p.cc_input_tokens = Some(1000);
1016
- p.cc_output_tokens = Some(500);
1017
- p.cc_cache_create_tokens = Some(200);
1018
- p.cc_cache_read_tokens = Some(5000);
1019
- p.rtk_saved_tokens = Some(10_000);
1020
-
1021
- p.compute_weighted_metrics();
1022
-
1023
- // weighted_units = 1000 + 5*500 + 1.25*200 + 0.1*5000 = 1000 + 2500 + 250 + 500 = 4250
1024
- // input_cpt = 100 / 4250 = 0.0235294...
1025
- // savings = 10000 * 0.0235294... = 235.29...
1026
-
1027
- assert!(p.weighted_input_cpt.is_some());
1028
- let cpt = p.weighted_input_cpt.unwrap();
1029
- assert!((cpt - (100.0 / 4250.0)).abs() < 1e-6);
1030
-
1031
- assert!(p.savings_weighted.is_some());
1032
- let savings = p.savings_weighted.unwrap();
1033
- assert!((savings - 235.294).abs() < 0.01);
1034
- }
1035
-
1036
- #[test]
1037
- fn test_compute_weighted_metrics_zero_tokens() {
1038
- let mut p = PeriodEconomics::new("2026-01");
1039
- p.cc_cost = Some(100.0);
1040
- p.cc_input_tokens = Some(0);
1041
- p.cc_output_tokens = Some(0);
1042
- p.cc_cache_create_tokens = Some(0);
1043
- p.cc_cache_read_tokens = Some(0);
1044
- p.rtk_saved_tokens = Some(5000);
1045
-
1046
- p.compute_weighted_metrics();
1047
-
1048
- assert!(p.weighted_input_cpt.is_none());
1049
- assert!(p.savings_weighted.is_none());
1050
- }
1051
-
1052
- #[test]
1053
- fn test_compute_weighted_metrics_no_cache() {
1054
- let mut p = PeriodEconomics::new("2026-01");
1055
- p.cc_cost = Some(60.0);
1056
- p.cc_input_tokens = Some(1000);
1057
- p.cc_output_tokens = Some(1000);
1058
- p.cc_cache_create_tokens = Some(0);
1059
- p.cc_cache_read_tokens = Some(0);
1060
- p.rtk_saved_tokens = Some(3000);
1061
-
1062
- p.compute_weighted_metrics();
1063
-
1064
- // weighted_units = 1000 + 5*1000 = 6000
1065
- // input_cpt = 60 / 6000 = 0.01
1066
- // savings = 3000 * 0.01 = 30
1067
-
1068
- assert!(p.weighted_input_cpt.is_some());
1069
- let cpt = p.weighted_input_cpt.unwrap();
1070
- assert!((cpt - 0.01).abs() < 1e-6);
1071
-
1072
- assert!(p.savings_weighted.is_some());
1073
- let savings = p.savings_weighted.unwrap();
1074
- assert!((savings - 30.0).abs() < 0.01);
1075
- }
1076
-
1077
- #[test]
1078
- fn test_set_ccusage_stores_per_type_tokens() {
1079
- let mut p = PeriodEconomics::new("2026-01");
1080
- let metrics = ccusage::CcusageMetrics {
1081
- input_tokens: 1000,
1082
- output_tokens: 500,
1083
- cache_creation_tokens: 200,
1084
- cache_read_tokens: 3000,
1085
- total_tokens: 4700,
1086
- total_cost: 50.0,
1087
- };
1088
-
1089
- p.set_ccusage(&metrics);
1090
-
1091
- assert_eq!(p.cc_input_tokens, Some(1000));
1092
- assert_eq!(p.cc_output_tokens, Some(500));
1093
- assert_eq!(p.cc_cache_create_tokens, Some(200));
1094
- assert_eq!(p.cc_cache_read_tokens, Some(3000));
1095
- assert_eq!(p.cc_total_tokens, Some(4700));
1096
- assert_eq!(p.cc_cost, Some(50.0));
1097
- }
1098
-
1099
- #[test]
1100
- fn test_compute_totals() {
1101
- let periods = vec![
1102
- PeriodEconomics {
1103
- label: "2026-01".to_string(),
1104
- cc_cost: Some(100.0),
1105
- cc_total_tokens: Some(1_000_000),
1106
- cc_active_tokens: Some(10_000),
1107
- cc_input_tokens: Some(5000),
1108
- cc_output_tokens: Some(5000),
1109
- cc_cache_create_tokens: Some(100),
1110
- cc_cache_read_tokens: Some(984_900),
1111
- rtk_commands: Some(5),
1112
- rtk_saved_tokens: Some(2000),
1113
- rtk_savings_pct: Some(50.0),
1114
- weighted_input_cpt: None,
1115
- savings_weighted: None,
1116
- blended_cpt: None,
1117
- active_cpt: None,
1118
- savings_blended: None,
1119
- savings_active: None,
1120
- },
1121
- PeriodEconomics {
1122
- label: "2026-02".to_string(),
1123
- cc_cost: Some(200.0),
1124
- cc_total_tokens: Some(2_000_000),
1125
- cc_active_tokens: Some(20_000),
1126
- cc_input_tokens: Some(10_000),
1127
- cc_output_tokens: Some(10_000),
1128
- cc_cache_create_tokens: Some(200),
1129
- cc_cache_read_tokens: Some(1_979_800),
1130
- rtk_commands: Some(10),
1131
- rtk_saved_tokens: Some(3000),
1132
- rtk_savings_pct: Some(60.0),
1133
- weighted_input_cpt: None,
1134
- savings_weighted: None,
1135
- blended_cpt: None,
1136
- active_cpt: None,
1137
- savings_blended: None,
1138
- savings_active: None,
1139
- },
1140
- ];
1141
-
1142
- let totals = compute_totals(&periods);
1143
- assert_eq!(totals.cc_cost, 300.0);
1144
- assert_eq!(totals.cc_total_tokens, 3_000_000);
1145
- assert_eq!(totals.cc_active_tokens, 30_000);
1146
- assert_eq!(totals.cc_input_tokens, 15_000);
1147
- assert_eq!(totals.cc_output_tokens, 15_000);
1148
- assert_eq!(totals.rtk_commands, 15);
1149
- assert_eq!(totals.rtk_saved_tokens, 5000);
1150
- assert_eq!(totals.rtk_avg_savings_pct, 55.0);
1151
-
1152
- assert!(totals.weighted_input_cpt.is_some());
1153
- assert!(totals.savings_weighted.is_some());
1154
- assert!(totals.blended_cpt.is_some());
1155
- assert!(totals.active_cpt.is_some());
1156
- }
1157
- }