Skip to main content

coding_agent_search/analytics/
types.rs

1//! Shared types for the analytics library.
2//!
3//! These types are used by both CLI commands and the FrankenTUI analytics
4//! dashboards, keeping query logic, bucketing, and derived-metric math in
5//! one place.
6
7use serde::Serialize;
8use thiserror::Error;
9
10// ---------------------------------------------------------------------------
11// Error type
12// ---------------------------------------------------------------------------
13
14/// Analytics-specific error.
15#[derive(Debug, Error)]
16pub enum AnalyticsError {
17    /// The required table does not exist — caller should suggest `cass analytics rebuild`.
18    #[error("table '{0}' does not exist — run 'cass analytics rebuild'")]
19    MissingTable(String),
20    /// A database query failed.
21    #[error("analytics db error: {0}")]
22    Db(String),
23}
24
25/// Convenience alias.
26pub type AnalyticsResult<T> = std::result::Result<T, AnalyticsError>;
27
28// ---------------------------------------------------------------------------
29// GroupBy
30// ---------------------------------------------------------------------------
31
32/// Time-bucket granularity (library-side, no clap dependency).
33#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "lowercase")]
35pub enum GroupBy {
36    Hour,
37    #[default]
38    Day,
39    Week,
40    Month,
41}
42
43impl GroupBy {
44    /// Stable lowercase string used in CLI/JSON display surfaces.
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::Hour => "hour",
48            Self::Day => "day",
49            Self::Week => "week",
50            Self::Month => "month",
51        }
52    }
53
54    /// Human-readable label for display in TUI headers.
55    pub fn label(self) -> &'static str {
56        match self {
57            Self::Hour => "Hourly",
58            Self::Day => "Daily",
59            Self::Week => "Weekly",
60            Self::Month => "Monthly",
61        }
62    }
63
64    /// Cycle to the next granularity (Hour → Day → Week → Month → Hour).
65    pub fn next(self) -> Self {
66        match self {
67            Self::Hour => Self::Day,
68            Self::Day => Self::Week,
69            Self::Week => Self::Month,
70            Self::Month => Self::Hour,
71        }
72    }
73
74    /// Cycle to the previous granularity.
75    pub fn prev(self) -> Self {
76        match self {
77            Self::Hour => Self::Month,
78            Self::Day => Self::Hour,
79            Self::Week => Self::Day,
80            Self::Month => Self::Week,
81        }
82    }
83}
84
85impl std::fmt::Display for GroupBy {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Filters
93// ---------------------------------------------------------------------------
94
95/// Source filter for analytics queries.
96#[derive(Clone, Debug, Default, PartialEq, Eq)]
97pub enum SourceFilter {
98    /// No source filtering.
99    #[default]
100    All,
101    /// Only local data.
102    Local,
103    /// Only remote data (anything that is not "local").
104    Remote,
105    /// A specific source_id string.
106    Specific(String),
107}
108
109/// CLI-agnostic analytics filter.
110///
111/// Callers convert from their own arg structs (e.g. `AnalyticsCommon`) via
112/// `From` impls kept in lib.rs.
113#[derive(Clone, Debug, Default)]
114pub struct AnalyticsFilter {
115    /// Inclusive lower bound, epoch milliseconds.
116    pub since_ms: Option<i64>,
117    /// Inclusive upper bound, epoch milliseconds.
118    pub until_ms: Option<i64>,
119    /// Agent slug allow-list (empty = all agents).
120    pub agents: Vec<String>,
121    /// Source filter.
122    pub source: SourceFilter,
123    /// Workspace id allow-list (empty = all workspaces).
124    pub workspace_ids: Vec<i64>,
125}
126
127// ---------------------------------------------------------------------------
128// Dimension and Metric enums
129// ---------------------------------------------------------------------------
130
131/// Dimension for breakdown queries — which column to GROUP BY.
132#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Dim {
135    Agent,
136    Workspace,
137    Source,
138    Model,
139}
140
141impl Dim {
142    /// Stable lowercase string used in CLI/JSON display surfaces.
143    pub fn as_str(self) -> &'static str {
144        match self {
145            Self::Agent => "agent",
146            Self::Workspace => "workspace",
147            Self::Source => "source",
148            Self::Model => "model",
149        }
150    }
151}
152
153impl std::fmt::Display for Dim {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.write_str(self.as_str())
156    }
157}
158
159/// Metric selector for breakdown/explorer queries.
160#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum Metric {
163    /// Total API tokens (input + output + cache + thinking).
164    #[default]
165    ApiTotal,
166    ApiInput,
167    ApiOutput,
168    CacheRead,
169    CacheCreation,
170    Thinking,
171    /// Estimated content tokens (chars / 4).
172    ContentEstTotal,
173    /// Tool call count.
174    ToolCalls,
175    /// Plan message count.
176    PlanCount,
177    /// API coverage percentage.
178    CoveragePct,
179    /// Message count.
180    MessageCount,
181    /// Estimated cost in USD from model pricing.
182    EstimatedCostUsd,
183}
184
185impl Metric {
186    /// Stable snake_case string used in CLI/JSON display surfaces.
187    pub fn as_str(self) -> &'static str {
188        match self {
189            Self::ApiTotal => "api_total",
190            Self::ApiInput => "api_input",
191            Self::ApiOutput => "api_output",
192            Self::CacheRead => "cache_read",
193            Self::CacheCreation => "cache_creation",
194            Self::Thinking => "thinking",
195            Self::ContentEstTotal => "content_est_total",
196            Self::ToolCalls => "tool_calls",
197            Self::PlanCount => "plan_count",
198            Self::CoveragePct => "coverage_pct",
199            Self::MessageCount => "message_count",
200            Self::EstimatedCostUsd => "estimated_cost_usd",
201        }
202    }
203
204    /// Return the SQL column name in the `usage_daily`/`usage_hourly` rollup
205    /// tables that corresponds to this metric, or `None` if the metric is
206    /// derived and not stored directly.
207    pub fn rollup_column(&self) -> Option<&'static str> {
208        match self {
209            Self::ApiTotal => Some("api_tokens_total"),
210            Self::ApiInput => Some("api_input_tokens_total"),
211            Self::ApiOutput => Some("api_output_tokens_total"),
212            Self::CacheRead => Some("api_cache_read_tokens_total"),
213            Self::CacheCreation => Some("api_cache_creation_tokens_total"),
214            Self::Thinking => Some("api_thinking_tokens_total"),
215            Self::ContentEstTotal => Some("content_tokens_est_total"),
216            Self::ToolCalls => Some("tool_call_count"),
217            Self::PlanCount => Some("plan_message_count"),
218            Self::MessageCount => Some("message_count"),
219            Self::CoveragePct => None,      // derived
220            Self::EstimatedCostUsd => None, // only in token_daily_stats (Track B)
221        }
222    }
223}
224
225impl std::fmt::Display for Metric {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.write_str(self.as_str())
228    }
229}
230
231// ---------------------------------------------------------------------------
232// UsageBucket — the core aggregate row
233// ---------------------------------------------------------------------------
234
235/// A single bucket of aggregated token / message metrics.
236///
237/// Mirrors the columns of `usage_daily` / `usage_hourly`.  Implements additive
238/// `merge()` for re-bucketing (day → week, day → month).
239///
240/// Note: `Eq` is not derived because `estimated_cost_usd` is `f64`.
241#[derive(Debug, Default, Clone, PartialEq, Serialize)]
242pub struct UsageBucket {
243    pub message_count: i64,
244    pub user_message_count: i64,
245    pub assistant_message_count: i64,
246    pub tool_call_count: i64,
247    pub plan_message_count: i64,
248    pub api_coverage_message_count: i64,
249    pub content_tokens_est_total: i64,
250    pub content_tokens_est_user: i64,
251    pub content_tokens_est_assistant: i64,
252    pub api_tokens_total: i64,
253    pub api_input_tokens_total: i64,
254    pub api_output_tokens_total: i64,
255    pub api_cache_read_tokens_total: i64,
256    pub api_cache_creation_tokens_total: i64,
257    pub api_thinking_tokens_total: i64,
258    pub plan_content_tokens_est_total: i64,
259    pub plan_api_tokens_total: i64,
260    /// Estimated cost in USD from model pricing tables.
261    /// Populated from Track B (token_daily_stats); 0.0 from Track A.
262    pub estimated_cost_usd: f64,
263}
264
265impl UsageBucket {
266    /// Accumulate another bucket into this one (additive merge).
267    pub fn merge(&mut self, other: &UsageBucket) {
268        self.message_count += other.message_count;
269        self.user_message_count += other.user_message_count;
270        self.assistant_message_count += other.assistant_message_count;
271        self.tool_call_count += other.tool_call_count;
272        self.plan_message_count += other.plan_message_count;
273        self.api_coverage_message_count += other.api_coverage_message_count;
274        self.content_tokens_est_total += other.content_tokens_est_total;
275        self.content_tokens_est_user += other.content_tokens_est_user;
276        self.content_tokens_est_assistant += other.content_tokens_est_assistant;
277        self.api_tokens_total += other.api_tokens_total;
278        self.api_input_tokens_total += other.api_input_tokens_total;
279        self.api_output_tokens_total += other.api_output_tokens_total;
280        self.api_cache_read_tokens_total += other.api_cache_read_tokens_total;
281        self.api_cache_creation_tokens_total += other.api_cache_creation_tokens_total;
282        self.api_thinking_tokens_total += other.api_thinking_tokens_total;
283        self.plan_content_tokens_est_total += other.plan_content_tokens_est_total;
284        self.plan_api_tokens_total += other.plan_api_tokens_total;
285        self.estimated_cost_usd += other.estimated_cost_usd;
286    }
287
288    /// Produce the nested JSON shape expected by CLI consumers.
289    ///
290    /// The shape is backwards-compatible with the original `TokenBucketRow::to_json`.
291    pub fn to_json(&self, bucket_key: &str) -> serde_json::Value {
292        let derived = super::derive::compute_derived(self);
293
294        serde_json::json!({
295            "bucket": bucket_key,
296            "counts": {
297                "message_count": self.message_count,
298                "user_message_count": self.user_message_count,
299                "assistant_message_count": self.assistant_message_count,
300                "tool_call_count": self.tool_call_count,
301                "plan_message_count": self.plan_message_count,
302            },
303            "content_tokens": {
304                "est_total": self.content_tokens_est_total,
305                "est_user": self.content_tokens_est_user,
306                "est_assistant": self.content_tokens_est_assistant,
307            },
308            "api_tokens": {
309                "total": self.api_tokens_total,
310                "input": self.api_input_tokens_total,
311                "output": self.api_output_tokens_total,
312                "cache_read": self.api_cache_read_tokens_total,
313                "cache_creation": self.api_cache_creation_tokens_total,
314                "thinking": self.api_thinking_tokens_total,
315            },
316            "plan_tokens": {
317                "content_est_total": self.plan_content_tokens_est_total,
318                "api_total": self.plan_api_tokens_total,
319            },
320            "coverage": {
321                "api_coverage_message_count": self.api_coverage_message_count,
322                "api_coverage_pct": derived.api_coverage_pct,
323            },
324            "derived": {
325                "api_tokens_per_assistant_msg": derived.api_tokens_per_assistant_msg,
326                "content_tokens_per_user_msg": derived.content_tokens_per_user_msg,
327                "tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
328                "tool_calls_per_1k_content_tokens": derived.tool_calls_per_1k_content_tokens,
329                "plan_message_pct": derived.plan_message_pct,
330                "plan_token_share_content": derived.plan_token_share_content,
331                "plan_token_share_api": derived.plan_token_share_api,
332            },
333        })
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Timeseries result
339// ---------------------------------------------------------------------------
340
341/// Result of a token/usage timeseries query.
342pub struct TimeseriesResult {
343    /// Ordered (label, bucket) pairs.
344    pub buckets: Vec<(String, UsageBucket)>,
345    /// Grand totals across all buckets.
346    pub totals: UsageBucket,
347    /// Which rollup or raw table backed the query.
348    pub source_table: String,
349    /// Granularity that was used.
350    pub group_by: GroupBy,
351    /// Query wall-time in milliseconds.
352    pub elapsed_ms: u64,
353    /// "rollup" or "slow".
354    pub path: String,
355}
356
357impl TimeseriesResult {
358    /// Produce the CLI-compatible JSON envelope.
359    pub fn to_cli_json(&self) -> serde_json::Value {
360        let bucket_json: Vec<serde_json::Value> = self
361            .buckets
362            .iter()
363            .map(|(key, row)| row.to_json(key))
364            .collect();
365
366        serde_json::json!({
367            "buckets": bucket_json,
368            "totals": self.totals.to_json("all"),
369            "bucket_count": self.buckets.len(),
370            "_meta": {
371                "elapsed_ms": self.elapsed_ms,
372                "path": self.path,
373                "group_by": self.group_by.to_string(),
374                "source_table": self.source_table,
375                "rows_read": self.buckets.len(),
376            }
377        })
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Breakdown result
383// ---------------------------------------------------------------------------
384
385/// A single row in a breakdown query result (one value of the chosen dimension).
386#[derive(Debug, Clone, Serialize)]
387pub struct BreakdownRow {
388    /// The dimension value (agent slug, workspace id, source id, or model family).
389    pub key: String,
390    /// The metric value (SUM of the selected metric column).
391    pub value: i64,
392    /// Message count for this slice (useful for context).
393    pub message_count: i64,
394    /// Full bucket for this slice (available for derived metric computation).
395    pub bucket: UsageBucket,
396}
397
398impl BreakdownRow {
399    /// Produce JSON for CLI output.
400    pub fn to_json(&self) -> serde_json::Value {
401        let derived = super::derive::compute_derived(&self.bucket);
402        serde_json::json!({
403            "key": self.key,
404            "value": self.value,
405            "message_count": self.message_count,
406            "derived": {
407                "api_coverage_pct": derived.api_coverage_pct,
408                "tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
409                "plan_message_pct": derived.plan_message_pct,
410            },
411        })
412    }
413}
414
415/// Result of a breakdown query.
416pub struct BreakdownResult {
417    /// Rows ordered by the metric value descending.
418    pub rows: Vec<BreakdownRow>,
419    /// Which dimension was grouped by.
420    pub dim: Dim,
421    /// Which metric was selected.
422    pub metric: Metric,
423    /// Which rollup table was queried.
424    pub source_table: String,
425    /// Query wall-time in milliseconds.
426    pub elapsed_ms: u64,
427}
428
429impl BreakdownResult {
430    /// Produce the CLI-compatible JSON envelope.
431    pub fn to_cli_json(&self) -> serde_json::Value {
432        let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
433        serde_json::json!({
434            "dim": self.dim.to_string(),
435            "metric": self.metric.to_string(),
436            "rows": rows_json,
437            "row_count": self.rows.len(),
438            "_meta": {
439                "elapsed_ms": self.elapsed_ms,
440                "source_table": self.source_table,
441            }
442        })
443    }
444}
445
446// ---------------------------------------------------------------------------
447// Tool report
448// ---------------------------------------------------------------------------
449
450/// A single row in a tool usage report.
451#[derive(Debug, Clone, Serialize)]
452pub struct ToolRow {
453    /// The agent slug or workspace — dimension key.
454    pub key: String,
455    /// Total tool call count.
456    pub tool_call_count: i64,
457    /// Total message count.
458    pub message_count: i64,
459    /// Total API tokens.
460    pub api_tokens_total: i64,
461    /// Tool calls per 1k API tokens (derived).
462    pub tool_calls_per_1k_api_tokens: Option<f64>,
463    /// Tool calls per 1k content tokens (derived).
464    pub tool_calls_per_1k_content_tokens: Option<f64>,
465}
466
467impl ToolRow {
468    pub fn to_json(&self) -> serde_json::Value {
469        serde_json::json!({
470            "key": self.key,
471            "tool_call_count": self.tool_call_count,
472            "message_count": self.message_count,
473            "api_tokens_total": self.api_tokens_total,
474            "tool_calls_per_1k_api_tokens": self.tool_calls_per_1k_api_tokens,
475            "tool_calls_per_1k_content_tokens": self.tool_calls_per_1k_content_tokens,
476        })
477    }
478}
479
480/// Result of a tool usage report query.
481pub struct ToolReport {
482    /// Rows ordered by tool_call_count descending.
483    pub rows: Vec<ToolRow>,
484    /// Totals across all rows.
485    pub total_tool_calls: i64,
486    pub total_messages: i64,
487    pub total_api_tokens: i64,
488    /// Which rollup table was queried.
489    pub source_table: String,
490    /// Query wall-time in milliseconds.
491    pub elapsed_ms: u64,
492}
493
494impl ToolReport {
495    /// Produce the CLI-compatible JSON envelope.
496    pub fn to_cli_json(&self) -> serde_json::Value {
497        let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
498        let overall_per_1k = if self.total_api_tokens > 0 {
499            Some(self.total_tool_calls as f64 / (self.total_api_tokens as f64 / 1000.0))
500        } else {
501            None
502        };
503        serde_json::json!({
504            "rows": rows_json,
505            "row_count": self.rows.len(),
506            "totals": {
507                "tool_call_count": self.total_tool_calls,
508                "message_count": self.total_messages,
509                "api_tokens_total": self.total_api_tokens,
510                "tool_calls_per_1k_api_tokens": overall_per_1k,
511            },
512            "_meta": {
513                "elapsed_ms": self.elapsed_ms,
514                "source_table": self.source_table,
515            }
516        })
517    }
518}
519
520// ---------------------------------------------------------------------------
521// Session scatter result
522// ---------------------------------------------------------------------------
523
524/// A single per-session point for Explorer scatter plots.
525#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
526pub struct SessionScatterPoint {
527    /// Source identifier (`local`, remote source id, etc.).
528    pub source_id: String,
529    /// Session path used as the stable per-session key.
530    pub source_path: String,
531    /// Total messages in the session (x-axis).
532    pub message_count: i64,
533    /// Total API tokens in the session (y-axis).
534    pub api_tokens_total: i64,
535}
536
537// ---------------------------------------------------------------------------
538// Status result types
539// ---------------------------------------------------------------------------
540
541/// Per-table statistics.
542#[derive(Debug, Default, Clone, Serialize)]
543pub struct TableInfo {
544    pub table: String,
545    pub exists: bool,
546    pub row_count: i64,
547    pub min_day_id: Option<i64>,
548    pub max_day_id: Option<i64>,
549    pub last_updated: Option<i64>,
550}
551
552impl TableInfo {
553    pub fn to_json(&self) -> serde_json::Value {
554        serde_json::json!({
555            "table": self.table,
556            "exists": self.exists,
557            "row_count": self.row_count,
558            "min_day_id": self.min_day_id,
559            "max_day_id": self.max_day_id,
560            "last_updated": self.last_updated,
561        })
562    }
563}
564
565/// Coverage statistics.
566#[derive(Debug, Default, Clone, Serialize)]
567pub struct CoverageInfo {
568    pub total_messages: i64,
569    pub message_metrics_coverage_pct: f64,
570    pub api_token_coverage_pct: f64,
571    pub model_name_coverage_pct: f64,
572    pub estimate_only_pct: f64,
573}
574
575/// Drift detection output.
576#[derive(Debug, Default, Clone, Serialize)]
577pub struct DriftInfo {
578    pub signals: Vec<DriftSignal>,
579    pub track_a_fresh: bool,
580    pub track_b_fresh: bool,
581}
582
583/// A single drift detection signal.
584#[derive(Debug, Clone, Serialize)]
585pub struct DriftSignal {
586    pub signal: String,
587    pub detail: String,
588    pub severity: String,
589}
590
591impl DriftSignal {
592    fn to_json(&self) -> serde_json::Value {
593        serde_json::json!({
594            "signal": self.signal,
595            "detail": self.detail,
596            "severity": self.severity,
597        })
598    }
599}
600
601/// Full status result.
602pub struct StatusResult {
603    pub tables: Vec<TableInfo>,
604    pub coverage: CoverageInfo,
605    pub drift: DriftInfo,
606    pub recommended_action: String,
607}
608
609impl StatusResult {
610    /// Produce the CLI-compatible JSON output.
611    pub fn to_json(&self) -> serde_json::Value {
612        let tables_json: Vec<serde_json::Value> = self.tables.iter().map(|t| t.to_json()).collect();
613        let signals_json: Vec<serde_json::Value> = self
614            .drift
615            .signals
616            .iter()
617            .map(DriftSignal::to_json)
618            .collect();
619
620        serde_json::json!({
621            "tables": tables_json,
622            "coverage": {
623                "total_messages": self.coverage.total_messages,
624                "message_metrics_coverage_pct": self.coverage.message_metrics_coverage_pct,
625                "api_token_coverage_pct": self.coverage.api_token_coverage_pct,
626                "model_name_coverage_pct": self.coverage.model_name_coverage_pct,
627                "estimate_only_pct": self.coverage.estimate_only_pct,
628            },
629            "drift": {
630                "signals": signals_json,
631                "track_a_fresh": self.drift.track_a_fresh,
632                "track_b_fresh": self.drift.track_b_fresh,
633            },
634            "recommended_action": self.recommended_action,
635        })
636    }
637}
638
639// ---------------------------------------------------------------------------
640// Unpriced model report
641// ---------------------------------------------------------------------------
642
643/// A model name with no matching pricing entry, and its token volume.
644#[derive(Debug, Clone, Serialize)]
645pub struct UnpricedModel {
646    /// The model name (or "(none)" if no model name was recorded).
647    pub model_name: String,
648    /// Total tokens across all unpriced usages of this model.
649    pub total_tokens: i64,
650    /// Number of token_usage rows with this model.
651    pub row_count: i64,
652}
653
654/// Result of the unpriced-models query.
655#[derive(Debug, Clone, Serialize)]
656pub struct UnpricedModelsReport {
657    /// Models with no pricing match, sorted by total_tokens descending.
658    pub models: Vec<UnpricedModel>,
659    /// Total unpriced tokens across all models.
660    pub total_unpriced_tokens: i64,
661    /// Total priced tokens for context.
662    pub total_priced_tokens: i64,
663}
664
665// ---------------------------------------------------------------------------
666// Derived metrics
667// ---------------------------------------------------------------------------
668
669/// Computed ratios from a [`UsageBucket`].
670#[derive(Debug, Clone, Serialize)]
671pub struct DerivedMetrics {
672    pub api_coverage_pct: f64,
673    pub api_tokens_per_assistant_msg: Option<f64>,
674    pub content_tokens_per_user_msg: Option<f64>,
675    pub tool_calls_per_1k_api_tokens: Option<f64>,
676    pub tool_calls_per_1k_content_tokens: Option<f64>,
677    pub plan_message_pct: Option<f64>,
678    pub plan_token_share_content: Option<f64>,
679    pub plan_token_share_api: Option<f64>,
680}
681
682// ---------------------------------------------------------------------------
683// Tests
684// ---------------------------------------------------------------------------
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    const GROUP_BY_CASES: [(GroupBy, &str, &str, GroupBy, GroupBy); 4] = [
691        (
692            GroupBy::Hour,
693            "hour",
694            "Hourly",
695            GroupBy::Day,
696            GroupBy::Month,
697        ),
698        (GroupBy::Day, "day", "Daily", GroupBy::Week, GroupBy::Hour),
699        (
700            GroupBy::Week,
701            "week",
702            "Weekly",
703            GroupBy::Month,
704            GroupBy::Day,
705        ),
706        (
707            GroupBy::Month,
708            "month",
709            "Monthly",
710            GroupBy::Hour,
711            GroupBy::Week,
712        ),
713    ];
714
715    #[test]
716    fn analytics_error_display_and_sources_are_preserved() {
717        let missing = AnalyticsError::MissingTable("usage_daily".to_string());
718        assert_eq!(
719            missing.to_string(),
720            "table 'usage_daily' does not exist — run 'cass analytics rebuild'"
721        );
722        assert!(std::error::Error::source(&missing).is_none());
723
724        let db = AnalyticsError::Db("query failed".to_string());
725        assert_eq!(db.to_string(), "analytics db error: query failed");
726        assert!(std::error::Error::source(&db).is_none());
727    }
728
729    #[test]
730    fn usage_bucket_merge_is_additive() {
731        let mut a = UsageBucket {
732            message_count: 10,
733            user_message_count: 5,
734            assistant_message_count: 5,
735            tool_call_count: 3,
736            api_tokens_total: 1000,
737            api_input_tokens_total: 600,
738            api_output_tokens_total: 400,
739            estimated_cost_usd: 0.50,
740            ..Default::default()
741        };
742        let b = UsageBucket {
743            message_count: 20,
744            user_message_count: 10,
745            assistant_message_count: 10,
746            tool_call_count: 7,
747            api_tokens_total: 2000,
748            api_input_tokens_total: 1200,
749            api_output_tokens_total: 800,
750            estimated_cost_usd: 1.25,
751            ..Default::default()
752        };
753        a.merge(&b);
754        assert_eq!(a.message_count, 30);
755        assert_eq!(a.user_message_count, 15);
756        assert_eq!(a.assistant_message_count, 15);
757        assert_eq!(a.tool_call_count, 10);
758        assert_eq!(a.api_tokens_total, 3000);
759        assert_eq!(a.api_input_tokens_total, 1800);
760        assert_eq!(a.api_output_tokens_total, 1200);
761        assert!((a.estimated_cost_usd - 1.75).abs() < 0.001);
762    }
763
764    #[test]
765    fn usage_bucket_to_json_shape() {
766        let bucket = UsageBucket {
767            message_count: 100,
768            assistant_message_count: 50,
769            plan_message_count: 10,
770            plan_content_tokens_est_total: 1_000,
771            plan_api_tokens_total: 2_000,
772            content_tokens_est_total: 10_000,
773            api_tokens_total: 5000,
774            api_coverage_message_count: 80,
775            estimated_cost_usd: 2.50,
776            ..Default::default()
777        };
778        let json = bucket.to_json("2025-01-15");
779        assert_eq!(json["bucket"], "2025-01-15");
780        assert!(json["counts"]["message_count"].is_number());
781        assert!(json["plan_tokens"]["content_est_total"].is_number());
782        assert!(json["content_tokens"]["est_total"].is_number());
783        assert!(json["api_tokens"]["total"].is_number());
784        assert!(json["coverage"]["api_coverage_pct"].is_number());
785        assert!(json["derived"].is_object());
786        assert!(json["derived"]["plan_token_share_content"].is_number());
787        assert!(json["derived"]["plan_token_share_api"].is_number());
788    }
789
790    #[test]
791    fn group_by_display() {
792        for (group_by, expected_display, _, _, _) in GROUP_BY_CASES {
793            assert_eq!(group_by.as_str(), expected_display, "{group_by:?}");
794            assert_eq!(group_by.to_string(), expected_display, "{group_by:?}");
795        }
796    }
797
798    #[test]
799    fn group_by_next_cycles_through_all() {
800        for (group_by, _, _, expected_next, _) in GROUP_BY_CASES {
801            assert_eq!(group_by.next(), expected_next, "{group_by:?}");
802        }
803    }
804
805    #[test]
806    fn group_by_prev_cycles_through_all() {
807        for (group_by, _, _, _, expected_prev) in GROUP_BY_CASES {
808            assert_eq!(group_by.prev(), expected_prev, "{group_by:?}");
809        }
810    }
811
812    #[test]
813    fn group_by_label() {
814        for (group_by, _, expected_label, _, _) in GROUP_BY_CASES {
815            assert_eq!(group_by.label(), expected_label, "{group_by:?}");
816        }
817    }
818
819    #[test]
820    fn dim_as_str_matches_display_for_all_variants() {
821        let cases = [
822            (Dim::Agent, "agent"),
823            (Dim::Workspace, "workspace"),
824            (Dim::Source, "source"),
825            (Dim::Model, "model"),
826        ];
827
828        for (dim, expected) in cases {
829            assert_eq!(dim.as_str(), expected, "{dim:?}");
830            assert_eq!(dim.to_string(), expected, "{dim:?}");
831        }
832    }
833
834    #[test]
835    fn metric_as_str_matches_display_for_all_variants() {
836        let cases = [
837            (Metric::ApiTotal, "api_total"),
838            (Metric::ApiInput, "api_input"),
839            (Metric::ApiOutput, "api_output"),
840            (Metric::CacheRead, "cache_read"),
841            (Metric::CacheCreation, "cache_creation"),
842            (Metric::Thinking, "thinking"),
843            (Metric::ContentEstTotal, "content_est_total"),
844            (Metric::ToolCalls, "tool_calls"),
845            (Metric::PlanCount, "plan_count"),
846            (Metric::CoveragePct, "coverage_pct"),
847            (Metric::MessageCount, "message_count"),
848            (Metric::EstimatedCostUsd, "estimated_cost_usd"),
849        ];
850
851        for (metric, expected) in cases {
852            assert_eq!(metric.as_str(), expected);
853            assert_eq!(metric.to_string(), expected);
854        }
855    }
856
857    #[test]
858    fn drift_signal_to_json_shape() {
859        let signal = DriftSignal {
860            signal: "track-a-stale".to_string(),
861            detail: "usage_daily is older than token_usage".to_string(),
862            severity: "warning".to_string(),
863        };
864
865        let json = signal.to_json();
866        assert_eq!(json["signal"], "track-a-stale");
867        assert_eq!(json["detail"], "usage_daily is older than token_usage");
868        assert_eq!(json["severity"], "warning");
869        assert_eq!(json.as_object().expect("object").len(), 3);
870    }
871
872    #[test]
873    fn default_filter_is_unfiltered() {
874        let f = AnalyticsFilter::default();
875        assert!(f.since_ms.is_none());
876        assert!(f.until_ms.is_none());
877        assert!(f.agents.is_empty());
878        assert_eq!(f.source, SourceFilter::All);
879        assert!(f.workspace_ids.is_empty());
880    }
881}