tokmesh-cli 0.1.1

CLI and TUI for tokmesh — AI token usage analytics
Documentation
use std::path::{Path, PathBuf};

#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticPath {
    pub label: &'static str,
    pub path: String,
    pub exists: bool,
}

#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientDiagnostic {
    pub code: &'static str,
    pub severity: &'static str,
    pub message: &'static str,
    pub help: &'static str,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub paths: Vec<DiagnosticPath>,
}

const CLAUDE_DESKTOP_MESSAGE: &str =
    "Claude Desktop app data was detected, but Tokmesh counts Claude Code JSONL transcripts only.";

const CLAUDE_DESKTOP_HELP: &str = "Claude Desktop chat storage and Claude data exports do not expose a documented per-message token ledger. Use `tokmesh usage` for Claude subscription quota bars; organization/API billing requires Anthropic Admin Usage/Cost API outside local scanning.";

pub fn diagnostics_for_clients_row(home_dir: &Path) -> Vec<ClientDiagnostic> {
    claude_diagnostics(home_dir, true)
}

pub fn diagnostics_for_empty_explicit_report(
    home_dir: &Path,
    clients: &Option<Vec<String>>,
    claude_message_count: i32,
) -> Vec<ClientDiagnostic> {
    if !explicitly_requests_claude(clients) || claude_message_count > 0 {
        return Vec::new();
    }

    claude_diagnostics(home_dir, false)
}

fn explicitly_requests_claude(clients: &Option<Vec<String>>) -> bool {
    clients
        .as_ref()
        .is_some_and(|ids| ids.iter().any(|id| id == "claude"))
}

fn claude_diagnostics(home_dir: &Path, include_info: bool) -> Vec<ClientDiagnostic> {
    let mut diagnostics = Vec::new();

    let desktop_paths: Vec<PathBuf> = claude_desktop_storage_paths(home_dir)
        .into_iter()
        .filter(|path| path.exists())
        .collect();

    if !desktop_paths.is_empty() {
        diagnostics.push(ClientDiagnostic {
            code: "claude_desktop_not_scanned",
            severity: "warning",
            message: CLAUDE_DESKTOP_MESSAGE,
            help: CLAUDE_DESKTOP_HELP,
            paths: diagnostic_paths(home_dir, desktop_paths),
        });
    }

    let stats_cache = home_dir.join(".claude").join("stats-cache.json");
    if include_info && stats_cache.exists() {
        diagnostics.push(ClientDiagnostic {
            code: "claude_stats_cache_not_imported",
            severity: "info",
            message: "Claude Code stats-cache.json was detected, but Tokmesh does not import aggregate cache totals as session usage.",
            help: "stats-cache.json contains aggregate /usage data without stable per-message/session attribution, so importing it would risk double counting or fabricated model/session totals.",
            paths: vec![DiagnosticPath {
                label: "statsCache",
                path: stats_cache.to_string_lossy().to_string(),
                exists: true,
            }],
        });
    }

    diagnostics
}

fn claude_desktop_storage_paths(home_dir: &Path) -> Vec<PathBuf> {
    vec![
        home_dir
            .join("Library")
            .join("Application Support")
            .join("Claude"),
        home_dir.join("AppData").join("Roaming").join("Claude"),
        home_dir.join(".config").join("Claude"),
    ]
}

fn diagnostic_paths(home_dir: &Path, desktop_paths: Vec<PathBuf>) -> Vec<DiagnosticPath> {
    let mut paths: Vec<DiagnosticPath> = desktop_paths
        .into_iter()
        .map(|path| DiagnosticPath {
            label: "desktopStorage",
            path: path.to_string_lossy().to_string(),
            exists: true,
        })
        .collect();

    for (label, path) in [
        (
            "claudeCodeProjects",
            home_dir.join(".claude").join("projects"),
        ),
        (
            "claudeCodeTranscripts",
            home_dir.join(".claude").join("transcripts"),
        ),
    ] {
        let exists = path.exists();
        paths.push(DiagnosticPath {
            label,
            path: path.to_string_lossy().to_string(),
            exists,
        });
    }

    paths
}