diff --git a/src/analyzers/codex_cli.rs b/src/analyzers/codex_cli.rs index 69de15e..060f50c 100644 --- a/src/analyzers/codex_cli.rs +++ b/src/analyzers/codex_cli.rs @@ -392,45 +392,43 @@ pub(crate) fn parse_codex_cli_jsonl_file( session_name: effective_name, }); } - "assistant" => { - // Token usage is now emitted immediately when processing token_count - // events. We still track assistant messages without additional stats - // to avoid double-counting when Codex emits separate reasoning/tool - // outputs. - if !saw_token_usage { - let model_state = session_model.clone().unwrap_or_else(|| { - let fallback = SessionModel::inferred( - DEFAULT_FALLBACK_MODEL.to_string(), - ); - warn_once(format!( - "WARNING: session {file_path_str} missing model metadata; using fallback model {} for cost estimation.", - fallback.name - )); - session_model = Some(fallback.clone()); - fallback - }); - - entries.push(ConversationMessage { - application: Application::CodexCli, - model: Some(model_state.name.clone()), - global_hash: hash_text(&format!( - "{}_{}_assistant_{}", - file_path_str, - wrapper.timestamp.to_rfc3339(), - entries.len() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - date: wrapper.timestamp, - project_hash: "".to_string(), - stats: Stats::default(), - role: MessageRole::Assistant, - uuid: None, - session_name: session_name - .clone() - .or_else(|| fallback_session_name.clone()), - }); - } + // Token usage is now emitted immediately when processing token_count + // events. We still track assistant messages without additional stats + // to avoid double-counting when Codex emits separate reasoning/tool + // outputs. + "assistant" if !saw_token_usage => { + let model_state = session_model.clone().unwrap_or_else(|| { + let fallback = SessionModel::inferred( + DEFAULT_FALLBACK_MODEL.to_string(), + ); + warn_once(format!( + "WARNING: session {file_path_str} missing model metadata; using fallback model {} for cost estimation.", + fallback.name + )); + session_model = Some(fallback.clone()); + fallback + }); + + entries.push(ConversationMessage { + application: Application::CodexCli, + model: Some(model_state.name.clone()), + global_hash: hash_text(&format!( + "{}_{}_assistant_{}", + file_path_str, + wrapper.timestamp.to_rfc3339(), + entries.len() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + date: wrapper.timestamp, + project_hash: "".to_string(), + stats: Stats::default(), + role: MessageRole::Assistant, + uuid: None, + session_name: session_name + .clone() + .or_else(|| fallback_session_name.clone()), + }); } _ => {} } diff --git a/src/analyzers/copilot.rs b/src/analyzers/copilot.rs index 1ec28f2..40e94a0 100644 --- a/src/analyzers/copilot.rs +++ b/src/analyzers/copilot.rs @@ -149,15 +149,14 @@ fn count_tokens(text: &str) -> u64 { // Recursively extract all text content from a nested JSON structure fn extract_text_from_value(value: &simd_json::OwnedValue, accumulated_text: &mut String) { match value { - simd_json::OwnedValue::String(s) => { - // Only accumulate if it's a "text" field value, not metadata like URIs + // Only accumulate if it's a "text" field value, not metadata like URIs + simd_json::OwnedValue::String(s) if !s.starts_with("vscode-") && !s.starts_with("file://") - && !s.starts_with("ssh-remote") - { - accumulated_text.push_str(s); - accumulated_text.push(' '); - } + && !s.starts_with("ssh-remote") => + { + accumulated_text.push_str(s); + accumulated_text.push(' '); } simd_json::OwnedValue::Object(obj) => { // Look for "text" fields specifically diff --git a/src/tui.rs b/src/tui.rs index e9b8ca6..267cac8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,9 @@ use crate::models::is_model_estimated; use crate::types::{ AnalyzerStatsView, CompactDate, MultiAnalyzerStatsView, SharedAnalyzerView, resolve_model, }; -use crate::utils::{NumberFormatOptions, format_date_for_display, format_number}; +use crate::utils::{ + NumberFormatOptions, format_date_for_display, format_number, format_number_fit, +}; use crate::watcher::{FileWatcher, RealtimeStatsManager, WatcherEvent}; use anyhow::Result; use chrono::Local; @@ -95,6 +97,17 @@ struct UiState<'a> { show_totals: bool, } +/// Column width for all token count columns (Cached, Input, Output, Reasoning). +/// +/// Width of 12 accommodates: +/// - All u32 per-day values without commas (max 10 digits: "4294967295") +/// - Most comma-formatted values (up to "999,999,999" = 11 chars) +/// - Most u64 total values without commas (up to 999 billion = 12 digits) +/// +/// Values that still overflow (e.g. u64 totals with comma format) are handled +/// by `format_number_fit` which falls back to human-readable format. +const TOKEN_COL_WIDTH: u16 = 12; + pub fn run_tui( stats_receiver: watch::Receiver, format_options: &NumberFormatOptions, @@ -365,61 +378,59 @@ async fn run_app( } match key.code { - KeyCode::Left | KeyCode::Char('h') => { - if *selected_tab > 0 { - *selected_tab -= 1; + KeyCode::Left | KeyCode::Char('h') if *selected_tab > 0 => { + *selected_tab -= 1; - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let view = view.read(); + let target_len = match session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) { - let view = view.read(); - let target_len = match session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - { - Some(day) => view - .session_aggregates - .iter() - .filter(|s| &s.date == day) - .count(), - None => view.session_aggregates.len(), - }; - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + Some(day) => view + .session_aggregates + .iter() + .filter(|s| &s.date == day) + .count(), + None => view.session_aggregates.len(), + }; + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } - - needs_redraw = true; } + + needs_redraw = true; } - KeyCode::Right | KeyCode::Char('l') => { - if *selected_tab < filtered_stats.len().saturating_sub(1) { - *selected_tab += 1; + KeyCode::Right | KeyCode::Char('l') + if *selected_tab < filtered_stats.len().saturating_sub(1) => + { + *selected_tab += 1; - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let view = view.read(); + let target_len = match session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) { - let view = view.read(); - let target_len = match session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - { - Some(day) => view - .session_aggregates - .iter() - .filter(|s| &s.date == day) - .count(), - None => view.session_aggregates.len(), - }; - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + Some(day) => view + .session_aggregates + .iter() + .filter(|s| &s.date == day) + .count(), + None => view.session_aggregates.len(), + }; + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } - - needs_redraw = true; } + + needs_redraw = true; } KeyCode::Down | KeyCode::Char('j') => { if let Some(table_state) = table_states.get_mut(*selected_tab) @@ -629,43 +640,41 @@ async fn run_app( needs_redraw = true; } } - KeyCode::Char('t') => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - *stats_view_mode = match *stats_view_mode { - StatsViewMode::Daily => { - session_day_filters[*selected_tab] = None; - StatsViewMode::Session - } - StatsViewMode::Session => StatsViewMode::Daily, - }; + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + *stats_view_mode = match *stats_view_mode { + StatsViewMode::Daily => { + session_day_filters[*selected_tab] = None; + StatsViewMode::Session + } + StatsViewMode::Session => StatsViewMode::Daily, + }; - date_jump_active = false; - date_jump_buffer.clear(); + date_jump_active = false; + date_jump_buffer.clear(); - if let StatsViewMode::Session = *stats_view_mode - && let Some(table_state) = table_states.get_mut(*selected_tab) - && let Some(view) = filtered_stats.get(*selected_tab) - { - let v = view.read(); - if !v.session_aggregates.is_empty() { - let target_len = session_day_filters - .get(*selected_tab) - .and_then(|f| f.as_ref()) - .map(|day| { - v.session_aggregates - .iter() - .filter(|s| &s.date == day) - .count() - }) - .unwrap_or_else(|| v.session_aggregates.len()); - if target_len > 0 { - table_state.select(Some(target_len.saturating_sub(1))); - } + if let StatsViewMode::Session = *stats_view_mode + && let Some(table_state) = table_states.get_mut(*selected_tab) + && let Some(view) = filtered_stats.get(*selected_tab) + { + let v = view.read(); + if !v.session_aggregates.is_empty() { + let target_len = session_day_filters + .get(*selected_tab) + .and_then(|f| f.as_ref()) + .map(|day| { + v.session_aggregates + .iter() + .filter(|s| &s.date == day) + .count() + }) + .unwrap_or_else(|| v.session_aggregates.len()); + if target_len > 0 { + table_state.select(Some(target_len.saturating_sub(1))); } } - - needs_redraw = true; } + + needs_redraw = true; } KeyCode::Enter => { if let StatsViewMode::Daily = *stats_view_mode @@ -1141,19 +1150,21 @@ fn draw_daily_stats_table( } .right_aligned(); + let tw = TOKEN_COL_WIDTH as usize; + let cached_cell = if is_empty_row { Line::from(Span::styled( - format_number(day_stats.stats.cached_tokens, format_options), + format_number_fit(day_stats.stats.cached_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } else if i == best_cached_tokens_i { Line::from(Span::styled( - format_number(day_stats.stats.cached_tokens, format_options), + format_number_fit(day_stats.stats.cached_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { Line::from(Span::styled( - format_number(day_stats.stats.cached_tokens, format_options), + format_number_fit(day_stats.stats.cached_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } @@ -1161,54 +1172,57 @@ fn draw_daily_stats_table( let input_cell = if is_empty_row { Line::from(Span::styled( - format_number(day_stats.stats.input_tokens, format_options), + format_number_fit(day_stats.stats.input_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } else if i == best_input_tokens_i { Line::from(Span::styled( - format_number(day_stats.stats.input_tokens, format_options), + format_number_fit(day_stats.stats.input_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( day_stats.stats.input_tokens, format_options, + tw, ))) } .right_aligned(); let output_cell = if is_empty_row { Line::from(Span::styled( - format_number(day_stats.stats.output_tokens, format_options), + format_number_fit(day_stats.stats.output_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } else if i == best_output_tokens_i { Line::from(Span::styled( - format_number(day_stats.stats.output_tokens, format_options), + format_number_fit(day_stats.stats.output_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( day_stats.stats.output_tokens, format_options, + tw, ))) } .right_aligned(); let reasoning_cell = if is_empty_row { Line::from(Span::styled( - format_number(day_stats.stats.reasoning_tokens, format_options), + format_number_fit(day_stats.stats.reasoning_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } else if i == best_reasoning_tokens_i { Line::from(Span::styled( - format_number(day_stats.stats.reasoning_tokens, format_options), + format_number_fit(day_stats.stats.reasoning_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( day_stats.stats.reasoning_tokens, format_options, + tw, ))) } .right_aligned(); @@ -1308,6 +1322,7 @@ fn draw_daily_stats_table( let all_models_text = all_models_vec.join(", "); // Add separator row before totals + let token_sep = "─".repeat(TOKEN_COL_WIDTH as usize); let separator_row = Row::new(vec![ Line::from(Span::styled( "", @@ -1322,19 +1337,19 @@ fn draw_daily_stats_table( Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "────────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "─────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "──────────", + token_sep, Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( @@ -1345,12 +1360,6 @@ fn draw_daily_stats_table( "──────", Style::default().add_modifier(Modifier::DIM), )), - /* - Line::from(Span::styled( - "───────────────────────", - Style::default().add_modifier(Modifier::DIM), - )), - */ Line::from(Span::styled( "─".repeat(all_models_text.len().max(18)), Style::default().add_modifier(Modifier::DIM), @@ -1361,6 +1370,7 @@ fn draw_daily_stats_table( // Add totals row let total_cost = total_cost_cents as f64 / 100.0; + let tw = TOKEN_COL_WIDTH as usize; let totals_row = Row::new(vec![ // Arrow indicator for totals row when selected if table_state.selected() == Some(rows.len()) { @@ -1385,24 +1395,24 @@ fn draw_daily_stats_table( )) .right_aligned(), Line::from(Span::styled( - format_number(total_cached, format_options), + format_number_fit(total_cached, format_options, tw), Style::default() .add_modifier(Modifier::DIM) .add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_input, format_options), + format_number_fit(total_input, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_output, format_options), + format_number_fit(total_output, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_reasoning, format_options), + format_number_fit(total_reasoning, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), @@ -1432,17 +1442,16 @@ fn draw_daily_stats_table( let table = Table::new( rows, [ - Constraint::Length(1), // Arrow - Constraint::Length(11), // Date - Constraint::Length(10), // Cost - Constraint::Length(12), // Cached - Constraint::Length(8), // Input - Constraint::Length(9), // Output - Constraint::Length(11), // Reasoning - Constraint::Length(6), // Convs - Constraint::Length(6), // Tools - // Constraint::Length(23), // Lines - Constraint::Min(10), // Models + Constraint::Length(1), // Arrow + Constraint::Length(11), // Date + Constraint::Length(10), // Cost + Constraint::Length(TOKEN_COL_WIDTH), // Cached + Constraint::Length(TOKEN_COL_WIDTH), // Input + Constraint::Length(TOKEN_COL_WIDTH), // Output + Constraint::Length(TOKEN_COL_WIDTH), // Reasoning + Constraint::Length(6), // Convs + Constraint::Length(6), // Tools + Constraint::Min(10), // Models ], ) .header(header) @@ -1657,14 +1666,16 @@ fn draw_session_stats_table( } .right_aligned(); + let tw = TOKEN_COL_WIDTH as usize; + let cached_cell = if best_cached_tokens_i == Some(i) { Line::from(Span::styled( - format_number(session.stats.cached_tokens, format_options), + format_number_fit(session.stats.cached_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { Line::from(Span::styled( - format_number(session.stats.cached_tokens, format_options), + format_number_fit(session.stats.cached_tokens, format_options, tw), Style::default().add_modifier(Modifier::DIM), )) } @@ -1672,39 +1683,42 @@ fn draw_session_stats_table( let input_cell = if best_input_tokens_i == Some(i) { Line::from(Span::styled( - format_number(session.stats.input_tokens, format_options), + format_number_fit(session.stats.input_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( session.stats.input_tokens, format_options, + tw, ))) } .right_aligned(); let output_cell = if best_output_tokens_i == Some(i) { Line::from(Span::styled( - format_number(session.stats.output_tokens, format_options), + format_number_fit(session.stats.output_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( session.stats.output_tokens, format_options, + tw, ))) } .right_aligned(); let reasoning_cell = if best_reasoning_tokens_i == Some(i) { Line::from(Span::styled( - format_number(session.stats.reasoning_tokens, format_options), + format_number_fit(session.stats.reasoning_tokens, format_options, tw), Style::default().fg(Color::Red), )) } else { - Line::from(Span::raw(format_number( + Line::from(Span::raw(format_number_fit( session.stats.reasoning_tokens, format_options, + tw, ))) } .right_aligned(); @@ -1753,13 +1767,14 @@ fn draw_session_stats_table( rows.push(row); } else if i == total_session_rows && total_session_rows > 0 { // Separator row + let token_sep = "─".repeat(TOKEN_COL_WIDTH as usize); let separator_row = Row::new(vec![ Line::from(Span::styled( "", Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "────────────", + "────────────────────────────────", Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( @@ -1771,19 +1786,19 @@ fn draw_session_stats_table( Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "──────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "─────────", + token_sep.clone(), Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( - "───────────", + token_sep, Style::default().add_modifier(Modifier::DIM), )), Line::from(Span::styled( @@ -1799,6 +1814,7 @@ fn draw_session_stats_table( } else { // Totals row let total_cost = total_cost_cents as f64 / 100.0; + let tw = TOKEN_COL_WIDTH as usize; let totals_row = Row::new(vec![ Line::from(Span::raw("")), Line::from(Span::styled( @@ -1814,24 +1830,24 @@ fn draw_session_stats_table( )) .right_aligned(), Line::from(Span::styled( - format_number(total_cached_tokens, format_options), + format_number_fit(total_cached_tokens, format_options, tw), Style::default() .add_modifier(Modifier::DIM) .add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_input_tokens, format_options), + format_number_fit(total_input_tokens, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_output_tokens, format_options), + format_number_fit(total_output_tokens, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), Line::from(Span::styled( - format_number(total_reasoning_tokens, format_options), + format_number_fit(total_reasoning_tokens, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), @@ -1857,16 +1873,16 @@ fn draw_session_stats_table( let table = Table::new( rows, [ - Constraint::Length(1), // Arrow / highlight symbol space - Constraint::Length(32), // Session (increased width for name) - Constraint::Length(17), // Started - Constraint::Length(10), // Cost - Constraint::Length(10), // Cached Tks - Constraint::Length(8), // Input - Constraint::Length(9), // Output - Constraint::Length(11), // Reason Tks - Constraint::Length(6), // Tools - Constraint::Min(10), // Models + Constraint::Length(1), // Arrow / highlight symbol space + Constraint::Length(32), // Session (increased width for name) + Constraint::Length(17), // Started + Constraint::Length(10), // Cost + Constraint::Length(TOKEN_COL_WIDTH), // Cached Tks + Constraint::Length(TOKEN_COL_WIDTH), // Input + Constraint::Length(TOKEN_COL_WIDTH), // Output + Constraint::Length(TOKEN_COL_WIDTH), // Reason Tks + Constraint::Length(6), // Tools + Constraint::Min(10), // Models ], ) .header(header) diff --git a/src/utils.rs b/src/utils.rs index cf51125..ebc866b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -90,6 +90,72 @@ pub fn format_number(n: impl Into, options: &NumberFormatOptions) -> String } } +/// Format a number to fit within a given column width. +/// +/// Falls back to progressively more compact representations if the user's +/// preferred format (commas, plain digits, etc.) overflows the column: +/// 1. User's preferred format +/// 2. Human-readable with configured decimal places (e.g. "193.1m") +/// 3. Human-readable with fewer decimal places (e.g. "193m") +/// 4. Plain digits (no separators) +/// +/// This ensures that the most significant digits are never clipped by +/// ratatui's column rendering — instead the number is abbreviated. +pub fn format_number_fit( + n: impl Into, + options: &NumberFormatOptions, + max_width: usize, +) -> String { + let n: u64 = n.into(); + + // 1. Try the user's preferred format first + let preferred = format_number(n, options); + if preferred.len() <= max_width { + return preferred; + } + + // 2. Try human-readable with configured decimal places + let human_options = NumberFormatOptions { + use_human: true, + use_comma: false, + locale: options.locale.clone(), + decimal_places: options.decimal_places, + }; + let human = format_number(n, &human_options); + if human.len() <= max_width { + return human; + } + + // 3. Try human-readable with progressively fewer decimal places + for dp in (0..options.decimal_places).rev() { + let compact_options = NumberFormatOptions { + use_human: true, + use_comma: false, + locale: options.locale.clone(), + decimal_places: dp, + }; + let compact = format_number(n, &compact_options); + if compact.len() <= max_width { + return compact; + } + } + + // 4. Fall back to plain digits (no separators) + let plain = n.to_string(); + if plain.len() <= max_width { + return plain; + } + + // 5. Last resort: human-readable with 0 decimal places (should always be short) + let minimal = NumberFormatOptions { + use_human: true, + use_comma: false, + locale: options.locale.clone(), + decimal_places: 0, + }; + format_number(n, &minimal) +} + pub fn format_date_for_display(date: &str) -> String { if date == "unknown" { return "Unknown".to_string(); diff --git a/src/utils/tests.rs b/src/utils/tests.rs index 3aa3b22..e218f5b 100644 --- a/src/utils/tests.rs +++ b/src/utils/tests.rs @@ -33,6 +33,174 @@ fn test_format_number_human() { assert_eq!(format_number(1_500_000_000_000_u64, &options), "1.5t"); } +// ============================================================================= +// FORMAT_NUMBER_FIT TESTS +// ============================================================================= + +#[test] +fn test_format_number_fit_preferred_format_fits() { + // When the preferred format fits within max_width, use it as-is + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + + // "1,000" = 5 chars, fits in 12 + assert_eq!(format_number_fit(1000_u64, &options, 12), "1,000"); + // "1,000,000" = 9 chars, fits in 12 + assert_eq!(format_number_fit(1_000_000_u64, &options, 12), "1,000,000"); + // Plain small number + assert_eq!(format_number_fit(42_u64, &options, 12), "42"); +} + +#[test] +fn test_format_number_fit_comma_overflow_falls_back_to_human() { + // Comma-formatted number too wide → falls back to human-readable + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + + // "4,294,967,295" = 13 chars, doesn't fit in 12 → fallback to "4.3b" (4 chars) + assert_eq!(format_number_fit(4_294_967_295_u64, &options, 12), "4.3b"); + + // "1,000,000,000" = 13 chars, doesn't fit in 12 → "1.0b" (4 chars) + assert_eq!(format_number_fit(1_000_000_000_u64, &options, 12), "1.0b"); + + // "100,000,000" = 11 chars, fits in 12 + assert_eq!( + format_number_fit(100_000_000_u64, &options, 12), + "100,000,000" + ); +} + +#[test] +fn test_format_number_fit_narrow_column_forces_compact() { + // Very narrow column forces most compact representation + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 2, + }; + + // In a 6-wide column: "1,000,000" (9 chars) → try human "1.00m" (5 chars) → fits! + assert_eq!(format_number_fit(1_000_000_u64, &options, 6), "1.00m"); + + // In a 4-wide column: "1.00m" (5 chars) → try "1.0m" (4 chars) → fits! + assert_eq!(format_number_fit(1_000_000_u64, &options, 4), "1.0m"); + + // In a 3-wide column: "1.0m" (4 chars) → try "1m" (2 chars) → fits! + assert_eq!(format_number_fit(1_000_000_u64, &options, 3), "1m"); +} + +#[test] +fn test_format_number_fit_plain_digits_fallback() { + // When human format still too wide but plain digits fit + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 2, + }; + + // "10,000" = 6 chars; width=5. Human: "10.00k"=6 chars, "10.0k"=5 → fits! + assert_eq!(format_number_fit(10_000_u64, &options, 5), "10.0k"); + + // "1,500" = 5 chars; width=4. Human: "1.50k"=5, "1.5k"=4 → fits! + assert_eq!(format_number_fit(1_500_u64, &options, 4), "1.5k"); +} + +#[test] +fn test_format_number_fit_u64_totals() { + // Large u64 totals that represent aggregated token counts + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + + // "48,057,854,897" = 14 chars → "48.1b" = 5 chars, fits in 12 + assert_eq!(format_number_fit(48_057_854_897_u64, &options, 12), "48.1b"); + + // "193,069,111" = 11 chars, fits in 12 + assert_eq!( + format_number_fit(193_069_111_u64, &options, 12), + "193,069,111" + ); + + // "1,245" = 5 chars, fits in 12 + assert_eq!(format_number_fit(1_245_u64, &options, 12), "1,245"); +} + +#[test] +fn test_format_number_fit_human_mode_passthrough() { + // When user already uses human format, it should just work + let options = NumberFormatOptions { + use_comma: false, + use_human: true, + locale: "en".to_string(), + decimal_places: 1, + }; + + assert_eq!(format_number_fit(1_500_000_u64, &options, 12), "1.5m"); + assert_eq!(format_number_fit(1_500_000_000_u64, &options, 12), "1.5b"); +} + +#[test] +fn test_format_number_fit_plain_mode_overflow() { + // Plain digits (no commas, no human) overflowing + let options = NumberFormatOptions { + use_comma: false, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + + // "106529574" = 9 chars, fits in 12 + assert_eq!( + format_number_fit(106_529_574_u64, &options, 12), + "106529574" + ); + + // "106529574" = 9 chars, doesn't fit in 8 → fallback to "106.5m" = 6 chars + assert_eq!(format_number_fit(106_529_574_u64, &options, 8), "106.5m"); +} + +#[test] +fn test_format_number_fit_zero() { + let options = NumberFormatOptions { + use_comma: true, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + assert_eq!(format_number_fit(0_u64, &options, 12), "0"); + assert_eq!(format_number_fit(0_u64, &options, 1), "0"); +} + +#[test] +fn test_format_number_fit_exact_boundary() { + // Test values that are exactly at the column boundary + let options = NumberFormatOptions { + use_comma: false, + use_human: false, + locale: "en".to_string(), + decimal_places: 1, + }; + + // "99999999" = 8 chars, fits exactly in 8 + assert_eq!(format_number_fit(99_999_999_u64, &options, 8), "99999999"); + + // "100000000" = 9 chars, doesn't fit in 8 → fallback "100.0m" = 6 chars + assert_eq!(format_number_fit(100_000_000_u64, &options, 8), "100.0m"); +} + #[test] fn test_format_number_plain() { let options = NumberFormatOptions {