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::collections::HashMap;
use std::path::Path;
use walkdir::WalkDir;

/// Map file extension to language name.
fn ext_to_lang(ext: &str) -> Option<&'static str> {
    let ext = ext.to_lowercase();
    match ext.as_str() {
        "rs" => Some("Rust"),
        "c" => Some("C"),
        "cpp" | "cc" | "cxx" => Some("C++"),
        "h" | "hpp" | "hxx" => Some("C/C++ Header"),
        "go" => Some("Go"),
        "zig" => Some("Zig"),
        "java" => Some("Java"),
        "kt" | "kts" => Some("Kotlin"),
        "scala" => Some("Scala"),
        "cs" => Some("C#"),
        "fs" | "fsx" => Some("F#"),
        "py" | "pyw" => Some("Python"),
        "rb" => Some("Ruby"),
        "pl" => Some("Perl"),
        "php" => Some("PHP"),
        "lua" => Some("Lua"),
        "r" => Some("R"),
        "js" | "mjs" | "cjs" => Some("JavaScript"),
        "ts" => Some("TypeScript"),
        "jsx" => Some("JSX"),
        "tsx" => Some("TSX"),
        "html" | "htm" => Some("HTML"),
        "css" => Some("CSS"),
        "scss" | "sass" => Some("SCSS"),
        "less" => Some("Less"),
        "vue" => Some("Vue"),
        "svelte" => Some("Svelte"),
        "sh" | "bash" | "zsh" => Some("Shell"),
        "ps1" | "psm1" | "psd1" => Some("PowerShell"),
        "bat" | "cmd" => Some("Batch"),
        "toml" => Some("TOML"),
        "yaml" | "yml" => Some("YAML"),
        "xml" => Some("XML"),
        "json" => Some("JSON"),
        "md" | "mdx" => Some("Markdown"),
        "rst" => Some("reST"),
        "tex" => Some("LaTeX"),
        "typ" => Some("Typst"),
        "sql" => Some("SQL"),
        "proto" => Some("Protobuf"),
        "graphql" | "gql" => Some("GraphQL"),
        "dockerfile" => Some("Docker"),
        "nix" => Some("Nix"),
        "jl" => Some("Julia"),
        "swift" => Some("Swift"),
        "dart" => Some("Dart"),
        "ex" | "exs" => Some("Elixir"),
        "erl" | "hrl" => Some("Erlang"),
        "hs" => Some("Haskell"),
        "ml" | "mli" => Some("OCaml"),
        "elm" => Some("Elm"),
        "clj" | "cljs" | "edn" => Some("Clojure"),
        "tf" | "tfvars" => Some("Terraform"),
        "cmake" | "CMakeLists.txt" => Some("CMake"),
        "Makefile" | "makefile" => Some("Makefile"),
        "ipynb" => Some("Jupyter"),
        _ => None,
    }
}

/// Directories to skip when scanning.
const SKIP_DIRS: &[&str] = &[
    ".git", ".claude", "node_modules", "target", "__pycache__",
    "venv", ".venv", "dist", "build", ".next", ".nuxt",
    "vendor", "bower_components", ".tox", ".eggs", ".mypy_cache",
    ".pytest_cache", ".ruff_cache", "coverage", "htmlcov",
];

/// Extensions to skip (non-code).
const SKIP_EXTS: &[&str] = &[
    "lock", "txt", "log", "png", "jpg", "jpeg", "gif", "svg", "ico",
    "pdf", "exe", "dll", "so", "dylib", "o", "a", "class",
    "jar", "war", "zip", "tar", "gz", "bz2", "7z", "rar",
    "mp3", "mp4", "avi", "mov", "wav", "flac",
    "ttf", "otf", "woff", "woff2", "eot",
    "db", "sqlite", "sqlite3",
    "pem", "crt", "key", "keystore",
    "bin", "dat", "pkl", "pickle",
    "pyc", "pyo", "rlib", "rmeta",
];

/// Detect programming languages in a project directory.
/// Runs on a background thread and sends results via channel.
pub fn detect_languages(project_paths: &[(String, String, bool)]) -> HashMap<String, f64> {
    let mut lang_counts: HashMap<String, u64> = HashMap::new();
    let mut total = 0u64;

    for (_display_name, full_path, path_resolved) in project_paths {
        if !path_resolved || full_path.is_empty() {
            continue;
        }
        let path = Path::new(full_path);
        if !path.exists() {
            continue;
        }

        let walker = WalkDir::new(path)
            .max_depth(3)
            .into_iter()
            .filter_entry(|e| {
                if let Some(name) = e.file_name().to_str() {
                    !SKIP_DIRS.contains(&name)
                } else {
                    false
                }
            });

        let mut count = 0u64;
        for entry in walker {
            if count >= 10_000 {
                break;
            }
            if let Ok(entry) = entry {
                if !entry.file_type().is_file() {
                    continue;
                }
                if let Some(ext) = entry.path().extension() {
                    let ext_str = ext.to_string_lossy();
                    if SKIP_EXTS.contains(&ext_str.as_ref()) {
                        continue;
                    }
                    if let Some(lang) = ext_to_lang(&ext_str) {
                        *lang_counts.entry(lang.to_string()).or_insert(0) += 1;
                        total += 1;
                    }
                } else if let Some(name) = entry.path().file_name().and_then(|n| n.to_str()) {
                    // Handle special filenames like Dockerfile, Makefile, CMakeLists.txt
                    if let Some(lang) = ext_to_lang(name) {
                        *lang_counts.entry(lang.to_string()).or_insert(0) += 1;
                        total += 1;
                    }
                }
                count += 1;
            }
        }
    }

    if total == 0 {
        return HashMap::new();
    }

    let mut result: HashMap<String, f64> = HashMap::new();
    for (lang, count) in &lang_counts {
        result.insert(lang.clone(), (*count as f64 / total as f64) * 100.0);
    }
    result
}

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

    #[test]
    fn test_ext_to_lang_common_languages() {
        assert_eq!(ext_to_lang("rs"), Some("Rust"));
        assert_eq!(ext_to_lang("py"), Some("Python"));
        assert_eq!(ext_to_lang("js"), Some("JavaScript"));
        assert_eq!(ext_to_lang("ts"), Some("TypeScript"));
        assert_eq!(ext_to_lang("go"), Some("Go"));
        assert_eq!(ext_to_lang("java"), Some("Java"));
        assert_eq!(ext_to_lang("rb"), Some("Ruby"));
        assert_eq!(ext_to_lang("c"), Some("C"));
        assert_eq!(ext_to_lang("cpp"), Some("C++"));
        assert_eq!(ext_to_lang("cc"), Some("C++"));
    }

    #[test]
    fn test_ext_to_lang_case_insensitive() {
        assert_eq!(ext_to_lang("RS"), Some("Rust"));
        assert_eq!(ext_to_lang("Py"), Some("Python"));
        assert_eq!(ext_to_lang("JS"), Some("JavaScript"));
        assert_eq!(ext_to_lang("Ts"), Some("TypeScript"));
    }

    #[test]
    fn test_ext_to_lang_web_extensions() {
        assert_eq!(ext_to_lang("tsx"), Some("TSX"));
        assert_eq!(ext_to_lang("jsx"), Some("JSX"));
        assert_eq!(ext_to_lang("vue"), Some("Vue"));
        assert_eq!(ext_to_lang("svelte"), Some("Svelte"));
        assert_eq!(ext_to_lang("html"), Some("HTML"));
        assert_eq!(ext_to_lang("htm"), Some("HTML"));
        assert_eq!(ext_to_lang("css"), Some("CSS"));
        assert_eq!(ext_to_lang("scss"), Some("SCSS"));
    }

    #[test]
    fn test_ext_to_lang_shell_and_config() {
        assert_eq!(ext_to_lang("sh"), Some("Shell"));
        assert_eq!(ext_to_lang("bash"), Some("Shell"));
        assert_eq!(ext_to_lang("zsh"), Some("Shell"));
        assert_eq!(ext_to_lang("toml"), Some("TOML"));
        assert_eq!(ext_to_lang("yaml"), Some("YAML"));
        assert_eq!(ext_to_lang("yml"), Some("YAML"));
        assert_eq!(ext_to_lang("json"), Some("JSON"));
    }

    #[test]
    fn test_ext_to_lang_special_filenames() {
        assert_eq!(ext_to_lang("dockerfile"), Some("Docker"));
        assert_eq!(ext_to_lang("Makefile"), Some("Makefile"));
        assert_eq!(ext_to_lang("makefile"), Some("Makefile"));
    }

    #[test]
    fn test_ext_to_lang_unknown() {
        assert_eq!(ext_to_lang("xyz"), None);
        assert_eq!(ext_to_lang(""), None);
        assert_eq!(ext_to_lang("foobar"), None);
    }

    #[test]
    fn test_ext_to_lang_functional_languages() {
        assert_eq!(ext_to_lang("hs"), Some("Haskell"));
        assert_eq!(ext_to_lang("ml"), Some("OCaml"));
        assert_eq!(ext_to_lang("elm"), Some("Elm"));
        assert_eq!(ext_to_lang("clj"), Some("Clojure"));
        assert_eq!(ext_to_lang("ex"), Some("Elixir"));
        assert_eq!(ext_to_lang("erl"), Some("Erlang"));
    }

    #[test]
    fn test_ext_to_lang_microsoft_languages() {
        assert_eq!(ext_to_lang("cs"), Some("C#"));
        assert_eq!(ext_to_lang("fs"), Some("F#"));
        assert_eq!(ext_to_lang("ps1"), Some("PowerShell"));
        assert_eq!(ext_to_lang("bat"), Some("Batch"));
        assert_eq!(ext_to_lang("cmd"), Some("Batch"));
    }

    #[test]
    fn test_detect_languages_empty_input() {
        let result = detect_languages(&[]);
        assert!(result.is_empty());
    }

    #[test]
    fn test_detect_languages_unresolved_path_skipped() {
        let projects = vec![
            ("test".to_string(), "/some/path".to_string(), false),
        ];
        let result = detect_languages(&projects);
        assert!(result.is_empty());
    }

    #[test]
    fn test_detect_languages_nonexistent_path() {
        let projects = vec![
            ("test".to_string(), "/nonexistent/path/xyz".to_string(), true),
        ];
        let result = detect_languages(&projects);
        assert!(result.is_empty());
    }

    #[test]
    fn test_detect_languages_with_real_files() {
        let dir = std::env::temp_dir().join("claude-dash-test-lang-detect");
        let _ = std::fs::create_dir_all(&dir);
        // Create some test files
        let _ = std::fs::write(dir.join("main.rs"), "fn main() {}");
        let _ = std::fs::write(dir.join("lib.py"), "pass");
        let _ = std::fs::write(dir.join("app.js"), "");

        let projects = vec![
            ("test".to_string(), dir.to_string_lossy().to_string(), true),
        ];
        let result = detect_languages(&projects);

        assert!(!result.is_empty());
        assert!(result.contains_key("Rust"));
        assert!(result.contains_key("Python"));
        assert!(result.contains_key("JavaScript"));

        // Total should be 100%
        let total: f64 = result.values().sum();
        assert!((total - 100.0).abs() < 0.01);

        // Cleanup
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_detect_languages_skips_node_modules() {
        let dir = std::env::temp_dir().join("claude-dash-test-lang-skip");
        let _ = std::fs::remove_dir_all(&dir);
        let nm_dir = dir.join("node_modules");
        let _ = std::fs::create_dir_all(&nm_dir);
        let _ = std::fs::write(dir.join("index.ts"), "");
        // Files inside node_modules should be skipped
        let _ = std::fs::write(nm_dir.join("dep.js"), "");

        let projects = vec![
            ("test".to_string(), dir.to_string_lossy().to_string(), true),
        ];
        let result = detect_languages(&projects);

        // Should only have TypeScript, not JavaScript from node_modules
        assert!(result.contains_key("TypeScript"));
        // JavaScript might still appear if walkdir visits it at depth ≤ 3,
        // but the node_modules dir itself should be filtered
        // At minimum, TypeScript should be present
        assert!(result.get("TypeScript").unwrap() > &0.0);

        let _ = std::fs::remove_dir_all(&dir);
    }
}