1use ccstat_core::aggregation_types::{
37 DailyInstanceUsage, DailyUsage, MonthlyUsage, SessionBlock, SessionUsage, Totals, WeeklyUsage,
38};
39use ccstat_core::model_formatter::{format_model_list, format_model_name};
40use prettytable::{Cell, Row, Table, format, row};
41use serde_json::json;
42
43pub trait OutputFormatter {
83 fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String;
85
86 fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String;
88
89 fn format_sessions(&self, data: &[SessionUsage], totals: &Totals, tz: &chrono_tz::Tz)
91 -> String;
92
93 fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String;
95
96 fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String;
98
99 fn format_blocks(&self, data: &[SessionBlock], tz: &chrono_tz::Tz) -> String;
101}
102
103pub struct TableFormatter {
109 pub full_model_names: bool,
111}
112
113impl TableFormatter {
114 pub fn new(full_model_names: bool) -> Self {
116 Self { full_model_names }
117 }
118
119 fn format_number(n: u64) -> String {
121 let s = n.to_string();
122 let mut result = String::new();
123
124 for (count, ch) in s.chars().rev().enumerate() {
125 if count > 0 && count % 3 == 0 {
126 result.push(',');
127 }
128 result.push(ch);
129 }
130
131 result.chars().rev().collect()
132 }
133
134 fn format_currency(amount: f64) -> String {
136 format!("${amount:.2}")
137 }
138
139 fn format_totals_row(totals: &Totals) -> Row {
141 row![
142 b -> "TOTAL",
143 b -> Self::format_number(totals.tokens.input_tokens),
144 b -> Self::format_number(totals.tokens.output_tokens),
145 b -> Self::format_number(totals.tokens.cache_creation_tokens),
146 b -> Self::format_number(totals.tokens.cache_read_tokens),
147 b -> Self::format_number(totals.tokens.total()),
148 b -> Self::format_currency(totals.total_cost),
149 ""
150 ]
151 }
152
153 fn format_datetime_with_tz(dt: &chrono::DateTime<chrono::Utc>, tz: &chrono_tz::Tz) -> String {
155 dt.with_timezone(tz).format("%Y-%m-%d %H:%M %Z").to_string()
156 }
157
158 fn format_duration(duration: chrono::Duration) -> String {
160 if duration.num_seconds() <= 0 {
161 return "0m".to_string();
162 }
163 let total_minutes = duration.num_minutes();
164 if total_minutes < 60 {
165 format!("{}m", total_minutes)
166 } else {
167 let hours = total_minutes / 60;
168 let minutes = total_minutes % 60;
169 format!("{}h {}m", hours, minutes)
170 }
171 }
172
173 pub(crate) fn format_blocks_with_now(
175 &self,
176 data: &[SessionBlock],
177 tz: &chrono_tz::Tz,
178 now: chrono::DateTime<chrono::Utc>,
179 ) -> String {
180 let mut table = Table::new();
181 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
182
183 table.set_titles(row![
184 b -> "Block Start",
185 b -> "Status",
186 b -> "Sessions",
187 b -> "Input",
188 b -> "Output",
189 b -> "Total Tokens",
190 b -> "Cost",
191 b -> "Time Remaining"
192 ]);
193
194 for block in data {
195 let status = if block.is_gap {
196 "GAP"
197 } else if block.is_active {
198 "ACTIVE"
199 } else {
200 "Complete"
201 };
202 let time_remaining = if block.is_active {
203 let remaining = block.end_time - now;
204 if remaining.num_seconds() > 0 {
205 format!(
206 "{}h {}m",
207 remaining.num_hours(),
208 remaining.num_minutes() % 60
209 )
210 } else {
211 "Expired".to_string()
212 }
213 } else {
214 "-".to_string()
215 };
216
217 let formatted_start = if block.is_gap {
219 let gap_duration = block.end_time - block.start_time;
221 format!(
222 "{} - {} ({} gap)",
223 Self::format_datetime_with_tz(&block.start_time, tz),
224 Self::format_datetime_with_tz(&block.end_time, tz),
225 Self::format_duration(gap_duration)
226 )
227 } else if block.is_active {
228 let elapsed = now - block.start_time;
230 format!(
231 "{} ({} elapsed)",
232 Self::format_datetime_with_tz(&block.start_time, tz),
233 Self::format_duration(elapsed)
234 )
235 } else {
236 if let (Some(start), Some(end)) = (block.actual_start_time, block.actual_end_time) {
238 let activity_duration = end - start;
239 format!(
240 "{} ({})",
241 Self::format_datetime_with_tz(&block.start_time, tz),
242 Self::format_duration(activity_duration)
243 )
244 } else {
245 Self::format_datetime_with_tz(&block.start_time, tz)
247 }
248 };
249
250 table.add_row(row![
251 formatted_start,
252 status,
253 c -> block.sessions.len(),
254 r -> Self::format_number(block.tokens.input_tokens),
255 r -> Self::format_number(block.tokens.output_tokens),
256 r -> Self::format_number(block.tokens.total()),
257 r -> Self::format_currency(block.total_cost),
258 time_remaining
259 ]);
260 }
261
262 table.to_string()
263 }
264}
265
266impl OutputFormatter for TableFormatter {
267 fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String {
268 let mut output = String::new();
269
270 let is_verbose = data.iter().any(|d| d.entries.is_some());
272
273 if is_verbose {
274 for daily in data {
276 output.push_str(&format!("\n=== {} ===\n", daily.date.format("%Y-%m-%d")));
278
279 if let Some(ref entries) = daily.entries {
280 let mut table = Table::new();
281 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
282
283 table.set_titles(row![
284 b -> "Time",
285 b -> "Session ID",
286 b -> "Model",
287 b -> "Input",
288 b -> "Output",
289 b -> "Cache Create",
290 b -> "Cache Read",
291 b -> "Total",
292 b -> "Cost"
293 ]);
294
295 for entry in entries {
296 table.add_row(row![
297 entry.timestamp.format("%H:%M:%S"),
298 entry.session_id,
299 format_model_name(&entry.model, self.full_model_names),
300 r -> Self::format_number(entry.tokens.input_tokens),
301 r -> Self::format_number(entry.tokens.output_tokens),
302 r -> Self::format_number(entry.tokens.cache_creation_tokens),
303 r -> Self::format_number(entry.tokens.cache_read_tokens),
304 r -> Self::format_number(entry.tokens.total()),
305 r -> Self::format_currency(entry.cost)
306 ]);
307 }
308
309 output.push_str(&table.to_string());
310 }
311
312 output.push_str(&format!(
314 "\nDay Total: {} tokens, {}\n",
315 Self::format_number(daily.tokens.total()),
316 Self::format_currency(daily.total_cost)
317 ));
318 }
319
320 output.push_str("\n=== OVERALL SUMMARY ===\n");
322 }
323
324 let mut table = Table::new();
326 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
327
328 table.set_titles(row![
329 b -> "Date",
330 b -> "Input",
331 b -> "Output",
332 b -> "Cache Create",
333 b -> "Cache Read",
334 b -> "Total",
335 b -> "Cost",
336 b -> "Models"
337 ]);
338
339 for entry in data {
340 table.add_row(row![
341 entry.date.format("%Y-%m-%d"),
342 r -> Self::format_number(entry.tokens.input_tokens),
343 r -> Self::format_number(entry.tokens.output_tokens),
344 r -> Self::format_number(entry.tokens.cache_creation_tokens),
345 r -> Self::format_number(entry.tokens.cache_read_tokens),
346 r -> Self::format_number(entry.tokens.total()),
347 r -> Self::format_currency(entry.total_cost),
348 format_model_list(&entry.models_used, self.full_model_names, ", ")
349 ]);
350 }
351
352 table.add_row(Row::new(vec![Cell::new(""); 8]));
354
355 table.add_row(Self::format_totals_row(totals));
357
358 output.push_str(&table.to_string());
359 output
360 }
361
362 fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String {
363 let mut table = Table::new();
364 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
365
366 table.set_titles(row![
367 b -> "Date",
368 b -> "Instance",
369 b -> "Input",
370 b -> "Output",
371 b -> "Cache Create",
372 b -> "Cache Read",
373 b -> "Total Tokens",
374 b -> "Cost",
375 b -> "Models"
376 ]);
377
378 for entry in data {
379 table.add_row(row![
380 entry.date.format("%Y-%m-%d"),
381 entry.instance_id,
382 r -> Self::format_number(entry.tokens.input_tokens),
383 r -> Self::format_number(entry.tokens.output_tokens),
384 r -> Self::format_number(entry.tokens.cache_creation_tokens),
385 r -> Self::format_number(entry.tokens.cache_read_tokens),
386 r -> Self::format_number(entry.tokens.total()),
387 r -> Self::format_currency(entry.total_cost),
388 format_model_list(&entry.models_used, self.full_model_names, ", ")
389 ]);
390 }
391
392 table.add_row(Row::new(vec![Cell::new(""); 9]));
394
395 table.add_row(row![
397 b -> "TOTAL",
398 "",
399 b -> Self::format_number(totals.tokens.input_tokens),
400 b -> Self::format_number(totals.tokens.output_tokens),
401 b -> Self::format_number(totals.tokens.cache_creation_tokens),
402 b -> Self::format_number(totals.tokens.cache_read_tokens),
403 b -> Self::format_number(totals.tokens.total()),
404 b -> Self::format_currency(totals.total_cost),
405 ""
406 ]);
407
408 table.to_string()
409 }
410
411 fn format_sessions(
412 &self,
413 data: &[SessionUsage],
414 totals: &Totals,
415 tz: &chrono_tz::Tz,
416 ) -> String {
417 let mut table = Table::new();
418 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
419
420 table.set_titles(row![
421 b -> "Session ID",
422 b -> "Start Time",
423 b -> "Duration",
424 b -> "Input",
425 b -> "Output",
426 b -> "Total Tokens",
427 b -> "Cost",
428 b -> "Model"
429 ]);
430
431 for session in data {
432 let duration = session.end_time - session.start_time;
433 let duration_str =
434 format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60);
435
436 let formatted_start = Self::format_datetime_with_tz(&session.start_time, tz);
437
438 table.add_row(row![
439 session.session_id.as_str(),
440 formatted_start,
441 duration_str,
442 r -> Self::format_number(session.tokens.input_tokens),
443 r -> Self::format_number(session.tokens.output_tokens),
444 r -> Self::format_number(session.tokens.total()),
445 r -> Self::format_currency(session.total_cost),
446 format_model_name(session.model.as_str(), self.full_model_names)
447 ]);
448 }
449
450 table.add_row(Row::new(vec![Cell::new(""); 8]));
452
453 table.add_row(row![
455 b -> "TOTAL",
456 "",
457 "",
458 b -> Self::format_number(totals.tokens.input_tokens),
459 b -> Self::format_number(totals.tokens.output_tokens),
460 b -> Self::format_number(totals.tokens.total()),
461 b -> Self::format_currency(totals.total_cost),
462 ""
463 ]);
464
465 table.to_string()
466 }
467
468 fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String {
469 let mut table = Table::new();
470 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
471
472 table.set_titles(row![
473 b -> "Month",
474 b -> "Input",
475 b -> "Output",
476 b -> "Cache Create",
477 b -> "Cache Read",
478 b -> "Total",
479 b -> "Cost",
480 b -> "Active Days"
481 ]);
482
483 for entry in data {
484 table.add_row(row![
485 entry.month,
486 r -> Self::format_number(entry.tokens.input_tokens),
487 r -> Self::format_number(entry.tokens.output_tokens),
488 r -> Self::format_number(entry.tokens.cache_creation_tokens),
489 r -> Self::format_number(entry.tokens.cache_read_tokens),
490 r -> Self::format_number(entry.tokens.total()),
491 r -> Self::format_currency(entry.total_cost),
492 c -> entry.active_days
493 ]);
494 }
495
496 table.add_row(Row::new(vec![Cell::new(""); 8]));
498
499 table.add_row(Self::format_totals_row(totals));
501
502 table.to_string()
503 }
504
505 fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String {
506 let mut table = Table::new();
507 table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
508
509 table.set_titles(row![
510 b -> "Week",
511 b -> "Input",
512 b -> "Output",
513 b -> "Cache Create",
514 b -> "Cache Read",
515 b -> "Total",
516 b -> "Cost",
517 b -> "Active Days"
518 ]);
519
520 for entry in data {
521 table.add_row(row![
522 entry.week,
523 r -> Self::format_number(entry.tokens.input_tokens),
524 r -> Self::format_number(entry.tokens.output_tokens),
525 r -> Self::format_number(entry.tokens.cache_creation_tokens),
526 r -> Self::format_number(entry.tokens.cache_read_tokens),
527 r -> Self::format_number(entry.tokens.total()),
528 r -> Self::format_currency(entry.total_cost),
529 c -> entry.active_days
530 ]);
531 }
532
533 table.add_row(Row::new(vec![Cell::new(""); 8]));
535
536 table.add_row(Self::format_totals_row(totals));
538
539 table.to_string()
540 }
541
542 fn format_blocks(&self, data: &[SessionBlock], tz: &chrono_tz::Tz) -> String {
543 self.format_blocks_with_now(data, tz, chrono::Utc::now())
544 }
545}
546
547pub struct JsonFormatter;
553
554impl OutputFormatter for JsonFormatter {
555 fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String {
556 let output = json!({
557 "daily": data.iter().map(|d| {
558 let mut day_json = json!({
559 "date": d.date.format("%Y-%m-%d"),
560 "tokens": {
561 "input_tokens": d.tokens.input_tokens,
562 "output_tokens": d.tokens.output_tokens,
563 "cache_creation_tokens": d.tokens.cache_creation_tokens,
564 "cache_read_tokens": d.tokens.cache_read_tokens,
565 "total": d.tokens.total(),
566 },
567 "total_cost": d.total_cost,
568 "models_used": d.models_used,
569 });
570
571 if let Some(ref entries) = d.entries {
573 day_json["entries"] = json!(entries.iter().map(|e| json!({
574 "timestamp": e.timestamp.to_rfc3339(),
575 "session_id": e.session_id,
576 "model": e.model,
577 "tokens": {
578 "input_tokens": e.tokens.input_tokens,
579 "output_tokens": e.tokens.output_tokens,
580 "cache_creation_tokens": e.tokens.cache_creation_tokens,
581 "cache_read_tokens": e.tokens.cache_read_tokens,
582 "total": e.tokens.total(),
583 },
584 "cost": e.cost,
585 })).collect::<Vec<_>>());
586 }
587
588 day_json
589 }).collect::<Vec<_>>(),
590 "totals": {
591 "tokens": {
592 "input_tokens": totals.tokens.input_tokens,
593 "output_tokens": totals.tokens.output_tokens,
594 "cache_creation_tokens": totals.tokens.cache_creation_tokens,
595 "cache_read_tokens": totals.tokens.cache_read_tokens,
596 "total": totals.tokens.total(),
597 },
598 "total_cost": totals.total_cost,
599 }
600 });
601
602 serde_json::to_string_pretty(&output).unwrap()
603 }
604
605 fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String {
606 let output = json!({
607 "daily_by_instance": data.iter().map(|d| json!({
608 "date": d.date.format("%Y-%m-%d"),
609 "instance_id": d.instance_id,
610 "tokens": {
611 "input_tokens": d.tokens.input_tokens,
612 "output_tokens": d.tokens.output_tokens,
613 "cache_creation_tokens": d.tokens.cache_creation_tokens,
614 "cache_read_tokens": d.tokens.cache_read_tokens,
615 "total": d.tokens.total(),
616 },
617 "total_cost": d.total_cost,
618 "models_used": d.models_used,
619 })).collect::<Vec<_>>(),
620 "totals": {
621 "tokens": {
622 "input_tokens": totals.tokens.input_tokens,
623 "output_tokens": totals.tokens.output_tokens,
624 "cache_creation_tokens": totals.tokens.cache_creation_tokens,
625 "cache_read_tokens": totals.tokens.cache_read_tokens,
626 "total": totals.tokens.total(),
627 },
628 "total_cost": totals.total_cost,
629 }
630 });
631
632 serde_json::to_string_pretty(&output).unwrap()
633 }
634
635 fn format_sessions(
636 &self,
637 data: &[SessionUsage],
638 totals: &Totals,
639 _tz: &chrono_tz::Tz,
640 ) -> String {
641 let output = json!({
642 "sessions": data.iter().map(|s| json!({
643 "session_id": s.session_id.as_str(),
644 "start_time": s.start_time.to_rfc3339(),
645 "end_time": s.end_time.to_rfc3339(),
646 "duration_seconds": (s.end_time - s.start_time).num_seconds(),
647 "tokens": {
648 "input_tokens": s.tokens.input_tokens,
649 "output_tokens": s.tokens.output_tokens,
650 "cache_creation_tokens": s.tokens.cache_creation_tokens,
651 "cache_read_tokens": s.tokens.cache_read_tokens,
652 "total": s.tokens.total(),
653 },
654 "total_cost": s.total_cost,
655 "model": s.model.as_str(),
656 })).collect::<Vec<_>>(),
657 "totals": {
658 "tokens": {
659 "input_tokens": totals.tokens.input_tokens,
660 "output_tokens": totals.tokens.output_tokens,
661 "cache_creation_tokens": totals.tokens.cache_creation_tokens,
662 "cache_read_tokens": totals.tokens.cache_read_tokens,
663 "total": totals.tokens.total(),
664 },
665 "total_cost": totals.total_cost,
666 }
667 });
668
669 serde_json::to_string_pretty(&output).unwrap()
670 }
671
672 fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String {
673 let output = json!({
674 "monthly": data.iter().map(|m| json!({
675 "month": m.month,
676 "tokens": {
677 "input_tokens": m.tokens.input_tokens,
678 "output_tokens": m.tokens.output_tokens,
679 "cache_creation_tokens": m.tokens.cache_creation_tokens,
680 "cache_read_tokens": m.tokens.cache_read_tokens,
681 "total": m.tokens.total(),
682 },
683 "total_cost": m.total_cost,
684 "active_days": m.active_days,
685 })).collect::<Vec<_>>(),
686 "totals": {
687 "tokens": {
688 "input_tokens": totals.tokens.input_tokens,
689 "output_tokens": totals.tokens.output_tokens,
690 "cache_creation_tokens": totals.tokens.cache_creation_tokens,
691 "cache_read_tokens": totals.tokens.cache_read_tokens,
692 "total": totals.tokens.total(),
693 },
694 "total_cost": totals.total_cost,
695 }
696 });
697
698 serde_json::to_string_pretty(&output).unwrap()
699 }
700
701 fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String {
702 let output = json!({
703 "weekly": data.iter().map(|w| json!({
704 "week": w.week,
705 "tokens": {
706 "input_tokens": w.tokens.input_tokens,
707 "output_tokens": w.tokens.output_tokens,
708 "cache_creation_tokens": w.tokens.cache_creation_tokens,
709 "cache_read_tokens": w.tokens.cache_read_tokens,
710 "total": w.tokens.total(),
711 },
712 "total_cost": w.total_cost,
713 "active_days": w.active_days,
714 })).collect::<Vec<_>>(),
715 "totals": {
716 "tokens": {
717 "input_tokens": totals.tokens.input_tokens,
718 "output_tokens": totals.tokens.output_tokens,
719 "cache_creation_tokens": totals.tokens.cache_creation_tokens,
720 "cache_read_tokens": totals.tokens.cache_read_tokens,
721 "total": totals.tokens.total(),
722 },
723 "total_cost": totals.total_cost,
724 }
725 });
726
727 serde_json::to_string_pretty(&output).unwrap()
728 }
729
730 fn format_blocks(&self, data: &[SessionBlock], _tz: &chrono_tz::Tz) -> String {
731 let output = json!({
732 "blocks": data.iter().map(|b| json!({
733 "start_time": b.start_time.to_rfc3339(),
734 "end_time": b.end_time.to_rfc3339(),
735 "is_active": b.is_active,
736 "is_gap": b.is_gap,
737 "session_count": b.sessions.len(),
738 "tokens": {
739 "input_tokens": b.tokens.input_tokens,
740 "output_tokens": b.tokens.output_tokens,
741 "cache_creation_tokens": b.tokens.cache_creation_tokens,
742 "cache_read_tokens": b.tokens.cache_read_tokens,
743 "total": b.tokens.total(),
744 },
745 "total_cost": b.total_cost,
746 "sessions": b.sessions.iter().map(|s| s.session_id.as_str()).collect::<Vec<_>>(),
747 "models_used": &b.models_used,
748 })).collect::<Vec<_>>()
749 });
750
751 serde_json::to_string_pretty(&output).unwrap()
752 }
753}
754
755pub fn get_formatter(json: bool, full_model_names: bool) -> Box<dyn OutputFormatter> {
798 if json {
799 Box::new(JsonFormatter)
800 } else {
801 Box::new(TableFormatter::new(full_model_names))
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use ccstat_core::aggregation_types::{MonthlyUsage, SessionBlock, SessionUsage};
809 use ccstat_core::types::{DailyDate, ModelName, SessionId, TokenCounts};
810 use chrono::{NaiveDate, TimeZone, Utc};
811
812 #[test]
813 fn test_number_formatting() {
814 assert_eq!(TableFormatter::format_number(1234567), "1,234,567");
815 assert_eq!(TableFormatter::format_number(999), "999");
816 assert_eq!(TableFormatter::format_number(0), "0");
817 assert_eq!(TableFormatter::format_number(1000000000), "1,000,000,000");
818 assert_eq!(TableFormatter::format_number(42), "42");
819 }
820
821 #[test]
822 fn test_currency_formatting() {
823 assert_eq!(TableFormatter::format_currency(12.345), "$12.35");
824 assert_eq!(TableFormatter::format_currency(0.0), "$0.00");
825 assert_eq!(TableFormatter::format_currency(1000.0), "$1000.00");
826 assert_eq!(TableFormatter::format_currency(0.001), "$0.00");
827 assert_eq!(TableFormatter::format_currency(999999.99), "$999999.99");
828 }
829
830 #[test]
831 fn test_get_formatter() {
832 let json_formatter = get_formatter(true, false);
834 assert!(
835 json_formatter
836 .format_daily(&[], &Totals::default())
837 .contains("\"daily\"")
838 );
839
840 let table_formatter = get_formatter(false, true);
842 let daily_data = vec![DailyUsage {
843 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
844 tokens: TokenCounts::new(100, 50, 10, 5),
845 total_cost: 1.25,
846 models_used: vec!["claude-3-opus".to_string()],
847 entries: None,
848 }];
849 let totals = Totals::from_daily(&daily_data);
850 let output = table_formatter.format_daily(&daily_data, &totals);
851 assert!(output.contains("2024-01-01"));
852 }
853
854 #[test]
855 fn test_table_formatter_daily() {
856 let formatter = TableFormatter::new(false);
857
858 let empty_totals = Totals::default();
860 let empty_output = formatter.format_daily(&[], &empty_totals);
861 assert!(empty_output.contains("TOTAL"));
862
863 let daily_data = vec![DailyUsage {
865 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
866 tokens: TokenCounts::new(1000, 500, 100, 50),
867 total_cost: 2.50,
868 models_used: vec!["claude-3-opus".to_string(), "claude-3-sonnet".to_string()],
869 entries: None,
870 }];
871 let totals = Totals::from_daily(&daily_data);
872 let output = formatter.format_daily(&daily_data, &totals);
873
874 assert!(output.contains("2024-03-15"));
875 assert!(output.contains("1,000"));
876 assert!(output.contains("500"));
877 assert!(output.contains("$2.50"));
878 assert!(output.contains("TOTAL"));
879
880 let multi_day_data = vec![
882 DailyUsage {
883 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
884 tokens: TokenCounts::new(1000, 500, 0, 0),
885 total_cost: 1.50,
886 models_used: vec!["claude-3-opus".to_string()],
887 entries: None,
888 },
889 DailyUsage {
890 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 16).unwrap()),
891 tokens: TokenCounts::new(2000, 1000, 200, 100),
892 total_cost: 3.00,
893 models_used: vec!["claude-3-sonnet".to_string()],
894 entries: None,
895 },
896 ];
897 let multi_totals = Totals::from_daily(&multi_day_data);
898 let multi_output = formatter.format_daily(&multi_day_data, &multi_totals);
899
900 assert!(multi_output.contains("2024-03-15"));
901 assert!(multi_output.contains("2024-03-16"));
902 assert!(multi_output.contains("3,000")); }
904
905 #[test]
906 fn test_table_formatter_daily_verbose() {
907 let formatter = TableFormatter::new(false);
908
909 let timestamp = Utc.with_ymd_and_hms(2024, 3, 15, 10, 30, 0).unwrap();
911 let verbose_entry = ccstat_core::aggregation_types::VerboseEntry {
912 timestamp,
913 session_id: "test-session".to_string(),
914 model: "claude-3-opus".to_string(),
915 tokens: TokenCounts::new(100, 50, 10, 5),
916 cost: 0.25,
917 };
918
919 let daily_data = vec![DailyUsage {
920 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
921 tokens: TokenCounts::new(100, 50, 10, 5),
922 total_cost: 0.25,
923 models_used: vec!["claude-3-opus".to_string()],
924 entries: Some(vec![verbose_entry]),
925 }];
926
927 let totals = Totals::from_daily(&daily_data);
928 let output = formatter.format_daily(&daily_data, &totals);
929
930 assert!(output.contains("=== 2024-03-15 ==="));
932 assert!(output.contains("test-session"));
933 assert!(output.contains("10:30:00"));
934 assert!(output.contains("Day Total"));
935 assert!(output.contains("OVERALL SUMMARY"));
936 }
937
938 #[test]
939 fn test_table_formatter_daily_by_instance() {
940 let formatter = TableFormatter::new(false);
941
942 let instance_data = vec![
943 DailyInstanceUsage {
944 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
945 instance_id: "instance-1".to_string(),
946 tokens: TokenCounts::new(1000, 500, 0, 0),
947 total_cost: 1.50,
948 models_used: vec!["claude-3-opus".to_string()],
949 },
950 DailyInstanceUsage {
951 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
952 instance_id: "instance-2".to_string(),
953 tokens: TokenCounts::new(2000, 1000, 100, 50),
954 total_cost: 3.00,
955 models_used: vec!["claude-3-sonnet".to_string()],
956 },
957 ];
958
959 let totals = Totals::from_daily_instances(&instance_data);
960 let output = formatter.format_daily_by_instance(&instance_data, &totals);
961
962 assert!(output.contains("instance-1"));
963 assert!(output.contains("instance-2"));
964 assert!(output.contains("2024-03-15"));
965 assert!(output.contains("3,000")); assert!(output.contains("$4.50")); }
968
969 #[test]
970 fn test_table_formatter_sessions() {
971 let formatter = TableFormatter::new(false);
972 let tz = chrono_tz::UTC;
973
974 let start_time = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap();
975 let end_time = Utc.with_ymd_and_hms(2024, 3, 15, 12, 30, 0).unwrap();
976
977 let sessions = vec![SessionUsage {
978 session_id: SessionId::new("session-123"),
979 start_time,
980 end_time,
981 tokens: TokenCounts::new(5000, 2500, 500, 250),
982 total_cost: 7.50,
983 model: ModelName::new("claude-3-opus"),
984 }];
985
986 let totals = Totals::from_sessions(&sessions);
987 let output = formatter.format_sessions(&sessions, &totals, &tz);
988
989 assert!(output.contains("session-123"));
990 assert!(output.contains("2h 30m")); assert!(output.contains("5,000")); assert!(output.contains("$7.50"));
993 assert!(output.contains("Opus")); }
995
996 #[test]
997 fn test_table_formatter_monthly() {
998 let formatter = TableFormatter::new(true); let monthly_data = vec![
1001 MonthlyUsage {
1002 month: "2024-01".to_string(),
1003 tokens: TokenCounts::new(100000, 50000, 10000, 5000),
1004 total_cost: 150.00,
1005 active_days: 15,
1006 },
1007 MonthlyUsage {
1008 month: "2024-02".to_string(),
1009 tokens: TokenCounts::new(200000, 100000, 20000, 10000),
1010 total_cost: 300.00,
1011 active_days: 20,
1012 },
1013 ];
1014
1015 let totals = Totals::from_monthly(&monthly_data);
1016 let output = formatter.format_monthly(&monthly_data, &totals);
1017
1018 assert!(output.contains("2024-01"));
1019 assert!(output.contains("2024-02"));
1020 assert!(output.contains("100,000"));
1021 assert!(output.contains("200,000"));
1022 assert!(output.contains("$450.00")); assert!(output.contains("15")); assert!(output.contains("20"));
1025 }
1026
1027 #[test]
1028 fn test_table_formatter_blocks() {
1029 let formatter = TableFormatter::new(false);
1030 let tz = chrono_tz::US::Eastern;
1031
1032 let now = Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap();
1034
1035 let session1 = SessionUsage {
1037 session_id: SessionId::new("session-1"),
1038 start_time: now - chrono::Duration::hours(2),
1039 end_time: now - chrono::Duration::hours(1),
1040 tokens: TokenCounts::new(1500, 750, 150, 75),
1041 total_cost: 2.25,
1042 model: ModelName::new("claude-3-opus"),
1043 };
1044
1045 let session2 = SessionUsage {
1046 session_id: SessionId::new("session-2"),
1047 start_time: now - chrono::Duration::hours(1),
1048 end_time: now,
1049 tokens: TokenCounts::new(1500, 750, 150, 75),
1050 total_cost: 2.25,
1051 model: ModelName::new("claude-3-sonnet"),
1052 };
1053
1054 let session3 = SessionUsage {
1055 session_id: SessionId::new("session-3"),
1056 start_time: now - chrono::Duration::hours(10),
1057 end_time: now - chrono::Duration::hours(9),
1058 tokens: TokenCounts::new(1000, 500, 100, 50),
1059 total_cost: 1.50,
1060 model: ModelName::new("claude-3-haiku"),
1061 };
1062
1063 let active_block = SessionBlock {
1064 start_time: now - chrono::Duration::hours(2),
1065 end_time: now + chrono::Duration::hours(3),
1066 actual_start_time: Some(now - chrono::Duration::hours(2)),
1067 actual_end_time: Some(now - chrono::Duration::minutes(30)),
1068 is_active: true,
1069 is_gap: false,
1070 sessions: vec![session1, session2],
1071 tokens: TokenCounts::new(3000, 1500, 300, 150),
1072 total_cost: 4.50,
1073 models_used: vec!["claude-3-opus".to_string(), "claude-3-sonnet".to_string()],
1074 projects_used: vec![],
1075 warning: None,
1076 };
1077
1078 let expired_block = SessionBlock {
1079 start_time: now - chrono::Duration::hours(10),
1080 end_time: now - chrono::Duration::hours(5),
1081 actual_start_time: Some(now - chrono::Duration::hours(10)),
1082 actual_end_time: Some(now - chrono::Duration::hours(5) - chrono::Duration::minutes(30)),
1083 is_active: false,
1084 is_gap: false,
1085 sessions: vec![session3],
1086 tokens: TokenCounts::new(1000, 500, 100, 50),
1087 total_cost: 1.50,
1088 models_used: vec!["claude-3-haiku".to_string()],
1089 projects_used: vec![],
1090 warning: None,
1091 };
1092
1093 let gap_block = SessionBlock {
1095 start_time: now - chrono::Duration::hours(20),
1096 end_time: now - chrono::Duration::hours(15),
1097 actual_start_time: None,
1098 actual_end_time: None,
1099 is_active: false,
1100 is_gap: true,
1101 sessions: vec![],
1102 tokens: TokenCounts::new(0, 0, 0, 0),
1103 total_cost: 0.0,
1104 models_used: vec![],
1105 projects_used: vec![],
1106 warning: None,
1107 };
1108
1109 let blocks = vec![gap_block, active_block, expired_block];
1110 let output = formatter.format_blocks_with_now(&blocks, &tz, now);
1111
1112 assert!(output.contains("GAP"));
1113 assert!(output.contains("ACTIVE"));
1114 assert!(output.contains("Complete"));
1115 assert!(output.contains("3,000"));
1116 assert!(output.contains("1,000"));
1117 assert!(output.contains("$4.50"));
1118 assert!(output.contains("$1.50"));
1119 assert!(output.contains("3h 0m")); }
1122
1123 #[test]
1124 fn test_json_formatter_daily() {
1125 let formatter = JsonFormatter;
1126
1127 let daily_data = vec![DailyUsage {
1128 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
1129 tokens: TokenCounts::new(1000, 500, 100, 50),
1130 total_cost: 2.50,
1131 models_used: vec!["claude-3-opus".to_string()],
1132 entries: None,
1133 }];
1134
1135 let totals = Totals::from_daily(&daily_data);
1136 let output = formatter.format_daily(&daily_data, &totals);
1137
1138 let json: serde_json::Value =
1140 serde_json::from_str(&output).expect("Failed to parse JSON output");
1141 assert_eq!(json["daily"][0]["date"], "2024-03-15");
1142 assert_eq!(json["daily"][0]["tokens"]["input_tokens"], 1000);
1143 assert_eq!(json["daily"][0]["total_cost"], 2.5);
1144 assert_eq!(json["totals"]["total_cost"], 2.5);
1145 }
1146
1147 #[test]
1148 fn test_json_formatter_daily_by_instance() {
1149 let formatter = JsonFormatter;
1150
1151 let instance_data = vec![DailyInstanceUsage {
1152 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 3, 15).unwrap()),
1153 instance_id: "instance-1".to_string(),
1154 tokens: TokenCounts::new(1000, 500, 0, 0),
1155 total_cost: 1.50,
1156 models_used: vec!["claude-3-opus".to_string()],
1157 }];
1158
1159 let totals = Totals::from_daily_instances(&instance_data);
1160 let output = formatter.format_daily_by_instance(&instance_data, &totals);
1161
1162 let json: serde_json::Value =
1163 serde_json::from_str(&output).expect("Failed to parse JSON output");
1164 assert_eq!(json["daily_by_instance"][0]["instance_id"], "instance-1");
1165 assert_eq!(json["daily_by_instance"][0]["tokens"]["input_tokens"], 1000);
1166 }
1167
1168 #[test]
1169 fn test_json_formatter_sessions() {
1170 let formatter = JsonFormatter;
1171 let tz = chrono_tz::UTC;
1172
1173 let start_time = Utc.with_ymd_and_hms(2024, 3, 15, 10, 0, 0).unwrap();
1174 let end_time = Utc.with_ymd_and_hms(2024, 3, 15, 12, 30, 0).unwrap();
1175
1176 let sessions = vec![SessionUsage {
1177 session_id: SessionId::new("session-123"),
1178 start_time,
1179 end_time,
1180 tokens: TokenCounts::new(5000, 2500, 0, 0),
1181 total_cost: 7.50,
1182 model: ModelName::new("claude-3-opus"),
1183 }];
1184
1185 let totals = Totals::from_sessions(&sessions);
1186 let output = formatter.format_sessions(&sessions, &totals, &tz);
1187
1188 let json: serde_json::Value =
1189 serde_json::from_str(&output).expect("Failed to parse JSON output");
1190 assert_eq!(json["sessions"][0]["session_id"], "session-123");
1191 assert_eq!(json["sessions"][0]["duration_seconds"], 9000); assert_eq!(json["sessions"][0]["total_cost"], 7.5);
1193 }
1194
1195 #[test]
1196 fn test_json_formatter_monthly() {
1197 let formatter = JsonFormatter;
1198
1199 let monthly_data = vec![MonthlyUsage {
1200 month: "2024-01".to_string(),
1201 tokens: TokenCounts::new(100000, 50000, 0, 0),
1202 total_cost: 150.00,
1203 active_days: 15,
1204 }];
1205
1206 let totals = Totals::from_monthly(&monthly_data);
1207 let output = formatter.format_monthly(&monthly_data, &totals);
1208
1209 let json: serde_json::Value =
1210 serde_json::from_str(&output).expect("Failed to parse JSON output");
1211 assert_eq!(json["monthly"][0]["month"], "2024-01");
1212 assert_eq!(json["monthly"][0]["active_days"], 15);
1213 assert_eq!(json["totals"]["total_cost"], 150.0);
1214 }
1215
1216 #[test]
1217 fn test_json_formatter_blocks() {
1218 let formatter = JsonFormatter;
1219 let tz = chrono_tz::UTC;
1220
1221 let now = Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap();
1223
1224 let session = SessionUsage {
1225 session_id: SessionId::new("session-1"),
1226 start_time: now - chrono::Duration::hours(2),
1227 end_time: now - chrono::Duration::hours(1),
1228 tokens: TokenCounts::new(3000, 1500, 0, 0),
1229 total_cost: 4.50,
1230 model: ModelName::new("claude-3-opus"),
1231 };
1232
1233 let block = SessionBlock {
1234 start_time: now - chrono::Duration::hours(2),
1235 end_time: now + chrono::Duration::hours(3),
1236 actual_start_time: Some(now - chrono::Duration::hours(2)),
1237 actual_end_time: Some(now - chrono::Duration::hours(1)),
1238 is_active: true,
1239 is_gap: false,
1240 sessions: vec![session],
1241 tokens: TokenCounts::new(3000, 1500, 0, 0),
1242 total_cost: 4.50,
1243 models_used: vec!["claude-3-opus".to_string()],
1244 projects_used: vec![],
1245 warning: None,
1246 };
1247
1248 let blocks = vec![block];
1249 let output = formatter.format_blocks(&blocks, &tz);
1250
1251 let json: serde_json::Value =
1252 serde_json::from_str(&output).expect("Failed to parse JSON output");
1253 assert_eq!(json["blocks"][0]["is_active"], true);
1254 assert_eq!(json["blocks"][0]["session_count"], 1);
1255 assert_eq!(json["blocks"][0]["total_cost"], 4.5);
1256 }
1257
1258 #[test]
1259 fn test_datetime_formatting_with_timezone() {
1260 let utc_time = Utc.with_ymd_and_hms(2024, 3, 15, 15, 30, 0).unwrap();
1261
1262 let utc_formatted = TableFormatter::format_datetime_with_tz(&utc_time, &chrono_tz::UTC);
1264 assert!(utc_formatted.contains("2024-03-15 15:30"));
1265 assert!(utc_formatted.contains("UTC"));
1266
1267 let est_formatted =
1269 TableFormatter::format_datetime_with_tz(&utc_time, &chrono_tz::US::Eastern);
1270 assert!(est_formatted.contains("2024-03-15 11:30"));
1272 assert!(est_formatted.contains("EDT"));
1273 }
1274
1275 #[test]
1276 fn test_edge_cases() {
1277 let formatter = TableFormatter::new(false);
1278
1279 let zero_data = vec![DailyUsage {
1281 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1282 tokens: TokenCounts::new(0, 0, 0, 0),
1283 total_cost: 0.0,
1284 models_used: vec![],
1285 entries: None,
1286 }];
1287 let zero_totals = Totals::from_daily(&zero_data);
1288 let zero_output = formatter.format_daily(&zero_data, &zero_totals);
1289 assert!(zero_output.contains("$0.00"));
1290
1291 let large_data = vec![DailyUsage {
1293 date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1294 tokens: TokenCounts::new(999999999, 888888888, 777777777, 666666666),
1295 total_cost: 9999999.99,
1296 models_used: vec!["model".to_string()],
1297 entries: None,
1298 }];
1299 let large_totals = Totals::from_daily(&large_data);
1300 let large_output = formatter.format_daily(&large_data, &large_totals);
1301 assert!(large_output.contains("999,999,999"));
1302 }
1303}