Skip to main content

context_harness/
stats.rs

1//! Database statistics and health overview.
2//!
3//! Provides a quick summary of what's indexed: document counts, chunk counts,
4//! embedding coverage, and per-source breakdowns. Used by `ctx stats` to give
5//! confidence that syncs and embeddings are working as expected.
6
7use anyhow::Result;
8
9use crate::app_store::{AppStore, SqliteAppStore};
10use crate::config::Config;
11
12/// Run the stats command: query the database and print a summary.
13pub async fn run_stats(config: &Config) -> Result<()> {
14    let store = SqliteAppStore::connect(config).await?;
15    let stats = store.stats().await?;
16
17    println!("Context Harness — Database Stats");
18    println!("================================");
19    println!();
20    println!("  Database:    {}", config.db.path.display());
21    println!("  Size:        {}", format_bytes(stats.db_size_bytes));
22    println!();
23    println!("  Documents:   {}", stats.total_docs);
24    println!("  Chunks:      {}", stats.total_chunks);
25    println!(
26        "  Embedded:    {} / {} ({}%)",
27        stats.total_embedded,
28        stats.total_chunks,
29        if stats.total_chunks > 0 {
30            (stats.total_embedded * 100) / stats.total_chunks
31        } else {
32            0
33        }
34    );
35
36    if !stats.sources.is_empty() {
37        println!();
38        println!("  By source:");
39        println!(
40            "  {:<24} {:>6} {:>8} {:>10}   LAST SYNC",
41            "SOURCE", "DOCS", "CHUNKS", "EMBEDDED"
42        );
43        println!("  {}", "-".repeat(76));
44
45        for s in &stats.sources {
46            let sync_display = match s.last_sync_ts {
47                Some(ts) => format_ts_relative(ts),
48                None => "never".to_string(),
49            };
50            println!(
51                "  {:<24} {:>6} {:>8} {:>10}   {}",
52                s.source, s.doc_count, s.chunk_count, s.embedded_count, sync_display
53            );
54        }
55    }
56
57    println!();
58
59    store.close().await;
60    Ok(())
61}
62
63/// Format a byte count as a human-readable string.
64fn format_bytes(bytes: u64) -> String {
65    if bytes < 1024 {
66        format!("{} B", bytes)
67    } else if bytes < 1024 * 1024 {
68        format!("{:.1} KB", bytes as f64 / 1024.0)
69    } else if bytes < 1024 * 1024 * 1024 {
70        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
71    } else {
72        format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
73    }
74}
75
76/// Format a Unix timestamp as a relative time string (e.g. "3 hours ago").
77fn format_ts_relative(ts: i64) -> String {
78    let now = chrono::Utc::now().timestamp();
79    let delta = now - ts;
80
81    if delta < 0 {
82        return format_ts_iso(ts);
83    }
84
85    if delta < 60 {
86        "just now".to_string()
87    } else if delta < 3600 {
88        let mins = delta / 60;
89        format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
90    } else if delta < 86400 {
91        let hours = delta / 3600;
92        format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
93    } else if delta < 86400 * 30 {
94        let days = delta / 86400;
95        format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
96    } else {
97        format_ts_iso(ts)
98    }
99}
100
101fn format_ts_iso(ts: i64) -> String {
102    chrono::DateTime::from_timestamp(ts, 0)
103        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
104        .unwrap_or_else(|| ts.to_string())
105}