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::path::{Path, PathBuf};

/// Locate the .claude directory.
/// Checks: CLI override, then env var CLAUDE_DATA_DIR, then ~/.claude.
pub fn find_claude_dir(override_path: Option<&Path>) -> Option<PathBuf> {
    if let Some(p) = override_path {
        if p.exists() {
            return Some(p.to_path_buf());
        }
    }
    if let Ok(val) = std::env::var("CLAUDE_DATA_DIR") {
        let p = PathBuf::from(val);
        if p.exists() {
            return Some(p);
        }
    }
    let home = dirs::home_dir()?;
    let default = home.join(".claude");
    if default.exists() {
        Some(default)
    } else {
        None
    }
}

/// True when the encoded directory name contains patterns indicating non-ASCII
/// characters were irreversibly collapsed into dashes by Claude Code's encoding.
///
/// Claude Code maps each non-ASCII character to a single `-`. This produces 3+
/// consecutive dashes when a non-ASCII segment follows a path separator (also
/// `-`). Pure ASCII names never produce 3+ consecutive dashes.
pub fn is_lossy_encoded(encoded: &str) -> bool {
    if encoded.len() < 4 {
        return false;
    }
    encoded[3..].contains("---")
}

/// Attempt to decode project directory name to a plausible path.
///
/// Returns `None` when the encoded name shows non-ASCII loss patterns (3+
/// consecutive dashes in the path body) that make reliable decoding impossible.
/// Callers should fall back to showing the encoded name as-is.
///
/// Encoding rules (from Claude Code): `:` → `-`, `\` → `-`, `_` → `-`,
/// and each non-ASCII character → `-`.
pub fn decode_project_name(encoded: &str) -> Option<(String, String)> {
    if encoded.len() < 3 {
        return Some((encoded.to_string(), encoded.to_string()));
    }

    if is_lossy_encoded(encoded) {
        return None;
    }

    let chars: Vec<char> = encoded.chars().collect();
    let drive = chars[0];
    let rest: String = chars[3..].iter().collect();

    if drive.is_ascii_alphabetic() && encoded.chars().nth(1) == Some('-') && encoded.chars().nth(2) == Some('-') {
        let full_path = format!("{}:\\{}", drive, rest.replace('-', "\\"));
        let display = Path::new(&full_path)
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| encoded.to_string());
        Some((display, full_path))
    } else if encoded.starts_with("d--") {
        let full_path = format!("/{}", rest.replace('-', "/"));
        let display = Path::new(&full_path)
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| encoded.to_string());
        Some((display, full_path))
    } else {
        let full_path = rest.replace('-', "/");
        let display = Path::new(&full_path)
            .file_name()
            .map(|n| n.to_string_lossy().to_string())
            .unwrap_or_else(|| encoded.to_string());
        Some((display, full_path))
    }
}

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

    #[test]
    fn test_decode_windows_path() {
        let result = decode_project_name("D--dev-rust-my-crate");
        assert!(result.is_some());
        let (display, full) = result.unwrap();
        assert_eq!(display, "crate");
        assert_eq!(full, "D:\\dev\\rust\\my\\crate");
    }

    #[test]
    fn test_decode_windows_path_dash_in_name() {
        let result = decode_project_name("D--dev-foo-bar-baz");
        assert!(result.is_some());
        let (display, _full) = result.unwrap();
        assert_eq!(display, "baz");
    }

    #[test]
    fn test_decode_unix_path() {
        let result = decode_project_name("d--opt-shared-lib");
        assert!(result.is_some());
        let (_display, full) = result.unwrap();
        assert!(full.contains("opt"));
        assert!(full.contains("shared"));
    }

    #[test]
    fn test_decode_short_name() {
        let result = decode_project_name("ab");
        assert!(result.is_some());
        let (display, full) = result.unwrap();
        assert_eq!(display, "ab");
        assert_eq!(full, "ab");
    }

    #[test]
    fn test_decode_chinese_path_rejected() {
        // 3+ consecutive dashes in body = non-ASCII loss → reject
        assert_eq!(decode_project_name("D--dev-foo---bar"), None);
    }

    #[test]
    fn test_decode_pure_chinese_rejected() {
        // All dashes after prefix = pure non-ASCII name → reject
        assert_eq!(decode_project_name("D--dev------"), None);
    }

    // --- is_lossy_encoded ---

    #[test]
    fn test_is_lossy_encoded_chinese_path() {
        assert!(is_lossy_encoded("D--dev-foo---bar"));
    }

    #[test]
    fn test_is_lossy_encoded_pure_chinese_name() {
        assert!(is_lossy_encoded("D--dev------"));
    }

    #[test]
    fn test_is_lossy_encoded_normal_ascii() {
        assert!(!is_lossy_encoded("D--dev-my-app"));
    }

    #[test]
    fn test_is_lossy_encoded_short_names() {
        assert!(!is_lossy_encoded("ab"));
        assert!(!is_lossy_encoded("D--"));
        assert!(!is_lossy_encoded("D--a"));
    }

    #[test]
    fn test_is_lossy_encoded_dash_in_project_name() {
        // Two consecutive dashes (--) is fine — not a non-ASCII fingerprint
        assert!(!is_lossy_encoded("D--dev-my-app"));
    }

    #[test]
    fn test_is_lossy_encoded_triple_dash_edge() {
        // Triple dashes from 3+ underscores in original name: safe false positive
        assert!(is_lossy_encoded("D--dev-my---app"));
    }

    #[test]
    fn test_is_lossy_encoded_minimum_length() {
        // len < 4 → always false
        assert!(!is_lossy_encoded(""));
        assert!(!is_lossy_encoded("D"));
        assert!(!is_lossy_encoded("D-"));
        assert!(!is_lossy_encoded("D--"));
    }

    #[test]
    fn test_is_lossy_encoded_exactly_three_dashes() {
        // Exactly "---" at position 3+ triggers lossy detection
        assert!(is_lossy_encoded("D------")); // 4 dashes after "D--"
        assert!(is_lossy_encoded("D--a---b")); // "---" in body
    }

    // --- decode_project_name more edge cases ---

    #[test]
    fn test_decode_project_name_single_segment() {
        // Short names (< 3 chars) return as-is
        let result = decode_project_name("x");
        assert!(result.is_some());
        let (display, full) = result.unwrap();
        assert_eq!(display, "x");
        assert_eq!(full, "x");
    }

    #[test]
    fn test_decode_project_name_unix_style() {
        // 'd' is alphabetic + chars[1]=chars[2]='-' → treated as Windows drive letter
        let result = decode_project_name("d--home-user-project");
        assert!(result.is_some());
        let (display, full) = result.unwrap();
        assert_eq!(display, "project");
        assert_eq!(full, "d:\\home\\user\\project");
    }

    #[test]
    fn test_decode_project_name_non_drive_prefix() {
        // 'a' is alphabetic but chars[1]='b' != '-' → falls to else branch
        // rest = chars[3..] = "-def-ghi" → full_path = "/def/ghi"
        let result = decode_project_name("abc-def-ghi");
        assert!(result.is_some());
        let (display, full) = result.unwrap();
        assert_eq!(display, "ghi");
        assert_eq!(full, "/def/ghi");
    }
}