use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
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
}
}
#[derive(Debug, Clone)]
pub enum StartTab {
Monthly(i32, u32),
Yearly(i32),
}
#[derive(Debug, Default)]
pub struct CliArgs {
pub data_dir: Option<PathBuf>,
pub start_tab: Option<StartTab>,
}
pub fn parse_cli_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect();
let mut result = CliArgs::default();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--data-dir" if i + 1 < args.len() => {
result.data_dir = Some(PathBuf::from(&args[i + 1]));
i += 1;
}
"--month" if i + 1 < args.len() => {
if let Some((y, m)) = parse_year_month(&args[i + 1]) {
result.start_tab = Some(StartTab::Monthly(y, m));
}
i += 1;
}
"--year" if i + 1 < args.len() => {
if let Ok(y) = args[i + 1].parse::<i32>() {
result.start_tab = Some(StartTab::Yearly(y));
}
i += 1;
}
_ => {}
}
i += 1;
}
result
}
fn parse_year_month(s: &str) -> Option<(i32, u32)> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() == 2 {
let y = parts[0].parse::<i32>().ok()?;
let m = parts[1].parse::<u32>().ok()?;
if m >= 1 && m <= 12 {
return Some((y, m));
}
}
None
}
pub fn is_lossy_encoded(encoded: &str) -> bool {
if encoded.len() < 4 {
return false;
}
encoded[3..].contains("---")
}
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))
}
}
pub fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{}K", n / 1_000)
} else {
n.to_string()
}
}
pub fn parse_iso_timestamp(s: &str) -> Option<chrono::NaiveDateTime> {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.naive_utc());
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ") {
return Some(dt);
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ") {
return Some(dt);
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%m/%d/%Y %H:%M:%S") {
return Some(dt);
}
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%m/%d/%Y %I:%M:%S %p") {
return Some(dt);
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d.and_hms_opt(0, 0, 0).unwrap());
}
None
}
pub fn month_bounds(year: i32, month: u32) -> (chrono::NaiveDate, chrono::NaiveDate) {
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let end = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1)
.unwrap()
.pred_opt()
.unwrap()
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1)
.unwrap()
.pred_opt()
.unwrap()
};
(start, end)
}
pub fn year_bounds(year: i32) -> (chrono::NaiveDate, chrono::NaiveDate) {
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(year, 12, 31).unwrap();
(start, end)
}
pub fn date_str_in_range(date_str: &str, start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool {
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
d >= start && d <= end
} else {
false
}
}
pub fn timestamp_in_range_fast(line: &str, start: chrono::NaiveDate, end: chrono::NaiveDate) -> bool {
if let Some(idx) = line.find("\"timestamp\":\"") {
let after = &line[idx + 13..];
if after.len() >= 10 {
let date_str = &after[..10];
return date_str_in_range(date_str, start, end);
}
}
if let Some(idx) = line.find("\"timestamp\": \"") {
let after = &line[idx + 14..];
if after.len() >= 10 {
let date_str = &after[..10];
return date_str_in_range(date_str, start, end);
}
}
false
}
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,
}
}
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",
];
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",
];
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()) {
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::*;
use chrono::Timelike;
#[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() {
assert_eq!(decode_project_name("D--dev-foo---bar"), None);
}
#[test]
fn test_decode_pure_chinese_rejected() {
assert_eq!(decode_project_name("D--dev------"), None);
}
#[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() {
assert!(!is_lossy_encoded("D--dev-my-app"));
}
#[test]
fn test_is_lossy_encoded_triple_dash_edge() {
assert!(is_lossy_encoded("D--dev-my---app"));
}
#[test]
fn test_format_tokens_millions() {
assert_eq!(format_tokens(5_200_000), "5.2M");
assert_eq!(format_tokens(1_000_000), "1.0M");
assert_eq!(format_tokens(12_500_000), "12.5M");
}
#[test]
fn test_format_tokens_kilo() {
assert_eq!(format_tokens(123_000), "123K");
assert_eq!(format_tokens(1_000), "1K");
assert_eq!(format_tokens(999), "999");
}
#[test]
fn test_format_tokens_small() {
assert_eq!(format_tokens(500), "500");
assert_eq!(format_tokens(0), "0");
}
#[test]
fn test_parse_iso_timestamp_full() {
let dt = parse_iso_timestamp("2025-11-04T08:34:16.259Z");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.date().to_string(), "2025-11-04");
assert_eq!(dt.time().hour(), 8);
}
#[test]
fn test_parse_iso_timestamp_date_only() {
let dt = parse_iso_timestamp("2024-03-15");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.date().to_string(), "2024-03-15");
}
#[test]
fn test_parse_iso_timestamp_invalid() {
assert!(parse_iso_timestamp("not-a-date").is_none());
assert!(parse_iso_timestamp("").is_none());
}
#[test]
fn test_parse_iso_timestamp_without_ms() {
let dt = parse_iso_timestamp("2026-04-15T10:30:00Z");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.date().to_string(), "2026-04-15");
assert_eq!(dt.time().hour(), 10);
}
#[test]
fn test_parse_iso_timestamp_with_timezone_offset() {
let dt = parse_iso_timestamp("2026-04-15T10:30:00+08:00");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.date().to_string(), "2026-04-15");
}
#[test]
fn test_parse_localized_timestamp() {
let dt = parse_iso_timestamp("03/09/2026 14:00:30");
assert!(dt.is_some());
let dt = dt.unwrap();
assert_eq!(dt.date().to_string(), "2026-03-09");
assert_eq!(dt.time().hour(), 14);
assert_eq!(dt.time().minute(), 0);
assert_eq!(dt.time().second(), 30);
}
#[test]
fn test_parse_localized_timestamp_invalid() {
assert!(parse_iso_timestamp("13/32/2026 25:00:00").is_none());
}
#[test]
fn test_month_bounds() {
let (start, end) = month_bounds(2026, 1);
assert_eq!(start.to_string(), "2026-01-01");
assert_eq!(end.to_string(), "2026-01-31");
let (start, end) = month_bounds(2026, 2);
assert_eq!(start.to_string(), "2026-02-01");
assert_eq!(end.to_string(), "2026-02-28");
let (_start, end) = month_bounds(2024, 2); assert_eq!(end.to_string(), "2024-02-29");
}
#[test]
fn test_month_bounds_december() {
let (start, end) = month_bounds(2026, 12);
assert_eq!(start.to_string(), "2026-12-01");
assert_eq!(end.to_string(), "2026-12-31");
}
#[test]
fn test_year_bounds() {
let (start, end) = year_bounds(2026);
assert_eq!(start.to_string(), "2026-01-01");
assert_eq!(end.to_string(), "2026-12-31");
}
#[test]
fn test_year_bounds_leap_year() {
let (start, end) = year_bounds(2024);
assert_eq!(start.to_string(), "2024-01-01");
assert_eq!(end.to_string(), "2024-12-31");
}
#[test]
fn test_date_str_in_range() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();
assert!(date_str_in_range("2026-04-15", start, end));
assert!(date_str_in_range("2026-04-01", start, end));
assert!(date_str_in_range("2026-04-30", start, end));
assert!(!date_str_in_range("2026-03-31", start, end));
assert!(!date_str_in_range("2026-05-01", start, end));
assert!(!date_str_in_range("bad-date", start, end));
}
#[test]
fn test_date_str_in_range_boundary() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
assert!(date_str_in_range("2026-01-01", start, end));
assert!(date_str_in_range("2026-01-31", start, end));
assert!(!date_str_in_range("2025-12-31", start, end));
assert!(!date_str_in_range("2026-02-01", start, end));
}
#[test]
fn test_timestamp_in_range_fast() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();
let line = r#"{"type":"assistant","timestamp":"2026-04-15T10:30:00Z","message":{}}"#;
assert!(timestamp_in_range_fast(line, start, end));
let line = r#"{"type":"assistant","timestamp":"2026-03-01T10:30:00Z","message":{}}"#;
assert!(!timestamp_in_range_fast(line, start, end));
let line = r#"{"type":"assistant","no_timestamp":"here"}"#;
assert!(!timestamp_in_range_fast(line, start, end));
}
#[test]
fn test_timestamp_in_range_fast_boundary() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();
let line = r#"{"timestamp":"2026-04-01T00:00:00Z","type":"assistant"}"#;
assert!(timestamp_in_range_fast(line, start, end));
let line = r#"{"timestamp":"2026-04-30T23:59:59Z","type":"assistant"}"#;
assert!(timestamp_in_range_fast(line, start, end));
let line = r#"{"timestamp":"2026-03-31T23:59:59Z","type":"assistant"}"#;
assert!(!timestamp_in_range_fast(line, start, end));
}
#[test]
fn test_timestamp_in_range_fast_with_space_after_colon() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();
let line = r#"{"timestamp": "2026-04-15T10:30:00Z", "type": "assistant"}"#;
assert!(timestamp_in_range_fast(line, start, end));
}
#[test]
fn test_timestamp_in_range_fast_line_without_date() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 4, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 4, 30).unwrap();
let line = r#"{"timestamp":"bad"}"#;
assert!(!timestamp_in_range_fast(line, start, end));
}
#[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_format_tokens_boundary_values() {
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(1_000), "1K");
assert_eq!(format_tokens(999_999), "999K");
assert_eq!(format_tokens(1_000_000), "1.0M");
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(999_999), "999K");
assert_eq!(format_tokens(1_001), "1K"); assert_eq!(format_tokens(1_000_001), "1.0M");
}
#[test]
fn test_format_tokens_large_values() {
assert_eq!(format_tokens(100_000_000), "100.0M");
assert_eq!(format_tokens(1_500_000_000), "1500.0M");
}
#[test]
fn test_parse_year_month_valid() {
assert_eq!(parse_year_month("2026-05"), Some((2026, 5)));
assert_eq!(parse_year_month("2024-01"), Some((2024, 1)));
assert_eq!(parse_year_month("2024-12"), Some((2024, 12)));
assert_eq!(parse_year_month("2000-06"), Some((2000, 6)));
}
#[test]
fn test_parse_year_month_invalid() {
assert_eq!(parse_year_month(""), None);
assert_eq!(parse_year_month("2026"), None);
assert_eq!(parse_year_month("2026-00"), None); assert_eq!(parse_year_month("2026-13"), None); assert_eq!(parse_year_month("abc-def"), None);
assert_eq!(parse_year_month("2026-5-extra"), None); }
#[test]
fn test_parse_year_month_edge_cases() {
assert_eq!(parse_year_month("0-1"), Some((0, 1)));
assert_eq!(parse_year_month("9999-12"), Some((9999, 12)));
assert_eq!(parse_year_month("-1-6"), None);
}
#[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);
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"));
let total: f64 = result.values().sum();
assert!((total - 100.0).abs() < 0.01);
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"), "");
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);
assert!(result.contains_key("TypeScript"));
assert!(result.get("TypeScript").unwrap() > &0.0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_lossy_encoded_exactly_three_dashes() {
assert!(is_lossy_encoded("D------")); assert!(is_lossy_encoded("D--a---b")); }
#[test]
fn test_is_lossy_encoded_minimum_length() {
assert!(!is_lossy_encoded(""));
assert!(!is_lossy_encoded("D"));
assert!(!is_lossy_encoded("D-"));
assert!(!is_lossy_encoded("D--"));
}
#[test]
fn test_decode_project_name_single_segment() {
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() {
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() {
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");
}
#[test]
fn test_timestamp_in_range_fast_empty_line() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
assert!(!timestamp_in_range_fast("", start, end));
}
#[test]
fn test_timestamp_in_range_fast_short_timestamp() {
let start = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let end = chrono::NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
let line = r#"{"timestamp":"2026-0"}"#;
assert!(!timestamp_in_range_fast(line, start, end));
}
}