claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

// Cache version — bump when prompt changes to invalidate old cached reports
const CACHE_VERSION: u32 = 2;

#[derive(Serialize, Deserialize)]
struct CachedReport {
    source_mtime: u64,
    generated_at: String,
    content: String,
    #[serde(default)]
    version: u32,
}

pub fn cache_dir() -> PathBuf {
    dirs::home_dir()
        .unwrap_or_default()
        .join(".claude-dashboard")
}

fn cache_path(key: &str) -> PathBuf {
    cache_dir().join(format!("llm-{}.json", key))
}

/// Read cached report. Returns `Some((source_mtime, content))` on hit.
/// Automatically rejects cache entries from older prompt versions.
pub fn read_cache(key: &str) -> Option<(u64, String)> {
    let path = cache_path(key);
    let content = fs::read_to_string(&path).ok()?;
    let report: CachedReport = serde_json::from_str(&content).ok()?;
    if report.version != CACHE_VERSION {
        return None; // old prompt version, invalidate
    }
    Some((report.source_mtime, report.content))
}

/// Write a report to the cache.
pub fn write_cache(key: &str, source_mtime: u64, content: &str) -> Result<(), String> {
    let dir = cache_dir();
    fs::create_dir_all(&dir).map_err(|e| format!("Cannot create cache dir: {}", e))?;
    let report = CachedReport {
        source_mtime,
        generated_at: chrono::Local::now()
            .format("%Y-%m-%dT%H:%M:%S")
            .to_string(),
        content: content.to_string(),
        version: CACHE_VERSION,
    };
    let json = serde_json::to_string_pretty(&report)
        .map_err(|e| format!("Cannot serialize cache: {}", e))?;
    fs::write(cache_path(key), json)
        .map_err(|e| format!("Cannot write cache: {}", e))?;
    Ok(())
}

/// Delete a cached report.
#[allow(dead_code)]
pub fn delete_cache(key: &str) -> Result<(), String> {
    let path = cache_path(key);
    if path.exists() {
        fs::remove_file(&path).map_err(|e| format!("Cannot delete cache: {}", e))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cache_key_formats() {
        // Verify cache keys don't panic
        assert!(cache_path("all").to_string_lossy().contains("llm-all"));
        assert!(cache_path("monthly_2026_05").to_string_lossy().contains("monthly_2026_05"));
        assert!(cache_path("yearly_2026").to_string_lossy().contains("yearly_2026"));
    }

    #[test]
    fn test_cache_write_read_delete() {
        let key = "__test_cache_roundtrip";
        let _ = delete_cache(key);
        assert!(read_cache(key).is_none());

        write_cache(key, 12345, "hello world").unwrap();
        let (mtime, content) = read_cache(key).unwrap();
        assert_eq!(mtime, 12345);
        assert_eq!(content, "hello world");

        delete_cache(key).unwrap();
        assert!(read_cache(key).is_none());
    }

    #[test]
    fn test_cache_overwrite() {
        let key = "__test_cache_overwrite";
        let _ = delete_cache(key);
        write_cache(key, 100, "first").unwrap();
        write_cache(key, 200, "second").unwrap();
        let (mtime, content) = read_cache(key).unwrap();
        assert_eq!(mtime, 200);
        assert_eq!(content, "second");
        delete_cache(key).unwrap();
    }

    #[test]
    fn test_cache_version_check() {
        // Write a cache entry with wrong version, verify it's rejected
        let key = "__test_version_check";
        let _ = delete_cache(key);

        // Manually write a cache entry with old version
        let dir = cache_dir();
        let _ = std::fs::create_dir_all(&dir);
        let old_report = serde_json::json!({
            "source_mtime": 99999,
            "generated_at": "2026-01-01T00:00:00",
            "content": "old content",
            "version": 0  // old version
        });
        let path = cache_path(key);
        let _ = std::fs::write(&path, serde_json::to_string_pretty(&old_report).unwrap());

        // Should be rejected due to version mismatch
        assert!(read_cache(key).is_none());

        // Cleanup
        let _ = delete_cache(key);
    }
}