Skip to main content

ccstat_terminal/
output.rs

1//! Output formatting module for ccstat
2//!
3//! This module provides formatters for displaying usage data in different formats:
4//! - Table format for human-readable terminal output
5//! - JSON format for machine-readable output and integration with other tools
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ccstat_terminal::output::get_formatter;
11//! use ccstat_core::aggregation_types::{DailyUsage, Totals};
12//! use ccstat_core::types::{DailyDate, TokenCounts};
13//! use chrono::NaiveDate;
14//!
15//! let daily_data = vec![
16//!     DailyUsage {
17//!         date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
18//!         tokens: TokenCounts::new(1000, 500, 100, 50),
19//!         total_cost: 0.025,
20//!         models_used: vec!["claude-3-opus".to_string()],
21//!         entries: None,
22//!     },
23//! ];
24//!
25//! let totals = Totals::from_daily(&daily_data);
26//!
27//! // Get table formatter for human-readable output
28//! let formatter = get_formatter(false, false);
29//! println!("{}", formatter.format_daily(&daily_data, &totals));
30//!
31//! // Get JSON formatter for machine-readable output
32//! let json_formatter = get_formatter(true, false);
33//! println!("{}", json_formatter.format_daily(&daily_data, &totals));
34//! ```
35
36use 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
43/// Trait for output formatters
44///
45/// This trait defines the interface for formatting various types of usage data.
46/// Implementations can provide different output formats (table, JSON, CSV, etc.).
47///
48/// # Example Implementation
49///
50/// ```
51/// use ccstat_terminal::output::OutputFormatter;
52/// use ccstat_core::aggregation_types::{DailyUsage, DailyInstanceUsage, SessionUsage, MonthlyUsage, WeeklyUsage, SessionBlock, Totals};
53///
54/// struct CustomFormatter;
55///
56/// impl OutputFormatter for CustomFormatter {
57///     fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String {
58///         format!("Total days: {}, Total cost: ${:.2}", data.len(), totals.total_cost)
59///     }
60///
61///     fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String {
62///         format!("Total instances: {}", data.len())
63///     }
64///
65///     fn format_sessions(&self, data: &[SessionUsage], totals: &Totals, _tz: &chrono_tz::Tz) -> String {
66///         format!("Total sessions: {}", data.len())
67///     }
68///
69///     fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String {
70///         format!("Total months: {}", data.len())
71///     }
72///
73///     fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String {
74///         format!("Total weeks: {}", data.len())
75///     }
76///
77///     fn format_blocks(&self, data: &[SessionBlock], _tz: &chrono_tz::Tz) -> String {
78///         format!("Total blocks: {}", data.len())
79///     }
80/// }
81/// ```
82pub trait OutputFormatter {
83    /// Format daily usage data with totals
84    fn format_daily(&self, data: &[DailyUsage], totals: &Totals) -> String;
85
86    /// Format daily usage data grouped by instance
87    fn format_daily_by_instance(&self, data: &[DailyInstanceUsage], totals: &Totals) -> String;
88
89    /// Format session usage data with totals
90    fn format_sessions(&self, data: &[SessionUsage], totals: &Totals, tz: &chrono_tz::Tz)
91    -> String;
92
93    /// Format monthly usage data with totals
94    fn format_monthly(&self, data: &[MonthlyUsage], totals: &Totals) -> String;
95
96    /// Format weekly usage data with totals
97    fn format_weekly(&self, data: &[WeeklyUsage], totals: &Totals) -> String;
98
99    /// Format billing blocks (5-hour windows)
100    fn format_blocks(&self, data: &[SessionBlock], tz: &chrono_tz::Tz) -> String;
101}
102
103/// Table formatter for human-readable output
104///
105/// Produces nicely formatted ASCII tables suitable for terminal display.
106/// Numbers are formatted with thousands separators and costs are shown
107/// with dollar signs for clarity.
108pub struct TableFormatter {
109    /// Whether to show full model names or shortened versions
110    pub full_model_names: bool,
111}
112
113impl TableFormatter {
114    /// Create a new TableFormatter
115    pub fn new(full_model_names: bool) -> Self {
116        Self { full_model_names }
117    }
118
119    /// Format a number with thousands separators
120    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    /// Format currency with dollar sign
135    fn format_currency(amount: f64) -> String {
136        format!("${amount:.2}")
137    }
138
139    /// Create a totals row for tables
140    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    /// Format a datetime with the specified timezone
154    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    /// Format a duration as "Xh Ym" or "Xm" if less than an hour
159    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    /// Format blocks with custom current time (for testing)
174    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            // Format the Block Start column based on block type
218            let formatted_start = if block.is_gap {
219                // Gap blocks: "start_time - end_time (Xh gap)"
220                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                // Active blocks: "YYYY-MM-DD, HH:MM:SS (Xh Ym elapsed)"
229                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                // Regular blocks: "YYYY-MM-DD, HH:MM:SS (Xh Ym)" with actual activity duration
237                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                    // Fallback if no actual times (shouldn't happen in normal use)
246                    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        // Check if we have verbose entries
271        let is_verbose = data.iter().any(|d| d.entries.is_some());
272
273        if is_verbose {
274            // Verbose mode: show detailed entries for each day
275            for daily in data {
276                // Day header
277                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                // Day summary
313                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            // Overall summary
321            output.push_str("\n=== OVERALL SUMMARY ===\n");
322        }
323
324        // Regular summary table (shown in both verbose and non-verbose modes)
325        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        // Add separator
353        table.add_row(Row::new(vec![Cell::new(""); 8]));
354
355        // Add totals row
356        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        // Add separator
393        table.add_row(Row::new(vec![Cell::new(""); 9]));
394
395        // Add totals row with extra column for instance
396        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        // Add separator
451        table.add_row(Row::new(vec![Cell::new(""); 8]));
452
453        // Add totals row
454        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        // Add separator
497        table.add_row(Row::new(vec![Cell::new(""); 8]));
498
499        // Add totals row
500        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        // Add separator
534        table.add_row(Row::new(vec![Cell::new(""); 8]));
535
536        // Add totals row
537        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
547/// JSON formatter for machine-readable output
548///
549/// Produces structured JSON output that can be easily parsed by other tools
550/// or used in automation pipelines. All data is preserved in its raw form
551/// for maximum flexibility.
552pub 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                // Add verbose entries if available
572                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
755/// Get appropriate formatter based on JSON flag
756///
757/// This is the main entry point for obtaining a formatter. It returns the appropriate
758/// formatter based on whether JSON output is requested.
759///
760/// # Arguments
761///
762/// * `json` - If true, returns a JSON formatter; otherwise returns a table formatter
763/// * `full_model_names` - If true, shows full model names; otherwise shows shortened versions
764///
765/// # Returns
766///
767/// A boxed trait object implementing the OutputFormatter trait
768///
769/// # Examples
770///
771/// ```
772/// use ccstat_terminal::output::{get_formatter, OutputFormatter};
773/// use ccstat_core::aggregation_types::{DailyUsage, Totals};
774/// use ccstat_core::types::{DailyDate, TokenCounts};
775/// use chrono::NaiveDate;
776///
777/// // Get table formatter for human-readable output
778/// let formatter = get_formatter(false, false);
779///
780/// // Get JSON formatter for machine-readable output
781/// let json_formatter = get_formatter(true, false);
782///
783/// // Use with data
784/// let daily_data = vec![
785///     DailyUsage {
786///         date: DailyDate::new(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
787///         tokens: TokenCounts::new(1000, 500, 0, 0),
788///         total_cost: 0.025,
789///         models_used: vec!["claude-3-opus".to_string()],
790///         entries: None,
791///     },
792/// ];
793/// let totals = Totals::from_daily(&daily_data);
794///
795/// let output = formatter.format_daily(&daily_data, &totals);
796/// ```
797pub 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        // Test JSON formatter
833        let json_formatter = get_formatter(true, false);
834        assert!(
835            json_formatter
836                .format_daily(&[], &Totals::default())
837                .contains("\"daily\"")
838        );
839
840        // Test table formatter with full model names
841        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        // Test with empty data
859        let empty_totals = Totals::default();
860        let empty_output = formatter.format_daily(&[], &empty_totals);
861        assert!(empty_output.contains("TOTAL"));
862
863        // Test with single day
864        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        // Test with multiple days
881        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")); // Total input tokens
903    }
904
905    #[test]
906    fn test_table_formatter_daily_verbose() {
907        let formatter = TableFormatter::new(false);
908
909        // Create verbose entries
910        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        // Verbose mode should show detailed entries
931        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")); // Total input tokens
966        assert!(output.contains("$4.50")); // Total cost
967    }
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")); // Duration
991        assert!(output.contains("5,000")); // Input tokens
992        assert!(output.contains("$7.50"));
993        assert!(output.contains("Opus")); // Model name (shortened)
994    }
995
996    #[test]
997    fn test_table_formatter_monthly() {
998        let formatter = TableFormatter::new(true); // Test with full model names
999
1000        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")); // Total cost
1023        assert!(output.contains("15")); // Active days
1024        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        // Use fixed time for deterministic testing
1033        let now = Utc.with_ymd_and_hms(2024, 7, 15, 12, 0, 0).unwrap();
1034
1035        // Create sessions for the blocks
1036        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        // Create a gap block for testing
1094        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        // Now we can reliably test time remaining with fixed timestamp
1120        assert!(output.contains("3h 0m")); // Active block has 3 hours remaining
1121    }
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        // Parse JSON to verify structure
1139        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); // 2.5 hours
1192        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        // Use fixed time for deterministic testing
1222        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        // Test with UTC
1263        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        // Test with Eastern timezone
1268        let est_formatted =
1269            TableFormatter::format_datetime_with_tz(&utc_time, &chrono_tz::US::Eastern);
1270        // On 2024-03-15, US/Eastern is in EDT (UTC-4)
1271        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        // Test with zero tokens
1280        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        // Test with very large numbers
1292        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}