diff --git a/src/analytics/ccusage.rs b/src/analytics/ccusage.rs index c291615b70..28b50d565e 100644 --- a/src/analytics/ccusage.rs +++ b/src/analytics/ccusage.rs @@ -77,6 +77,7 @@ struct MonthlyResponse { #[derive(Debug, Deserialize)] struct MonthlyEntry { + #[serde(alias = "period")] month: String, #[serde(flatten)] metrics: CcusageMetrics, @@ -235,6 +236,32 @@ mod tests { assert_eq!(periods[0].metrics.total_cost, 12.34); } + #[test] + fn test_parse_monthly_period_field() { + // ccusage 20.x changed the field name from "month" to "period" in the + // all-agents monthly response. Both must be accepted. + let json = r#"{ + "monthly": [ + { + "period": "2026-02", + "inputTokens": 2000, + "outputTokens": 800, + "cacheCreationTokens": 0, + "cacheReadTokens": 0, + "totalTokens": 2800, + "totalCost": 9.99 + } + ] + }"#; + + let result = parse_json(json, Granularity::Monthly); + assert!(result.is_ok(), "should accept 'period' field: {:?}", result); + let periods = result.unwrap(); + assert_eq!(periods.len(), 1); + assert_eq!(periods[0].key, "2026-02"); + assert_eq!(periods[0].metrics.total_cost, 9.99); + } + #[test] fn test_parse_daily_valid() { let json = r#"{ diff --git a/src/discover/provider.rs b/src/discover/provider.rs index d8f73c6fa6..21a76900cf 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -117,15 +117,15 @@ impl ClaudeProvider { /// Encode a filesystem path to Claude Code's directory name format. /// - /// Claude Code replaces `/`, `.`, `_`, `\`, and any non-ASCII character + /// Claude Code replaces `/`, `.`, `_`, `\`, `:`, and any non-ASCII character /// with `-` when computing the project directory slug under `~/.claude/projects/`. /// /// `/Users/foo/bar` → `-Users-foo-bar` /// `/Users/first.last/bar` → `-Users-first-last-bar` /// `/home/chris/2_project` → `-home-chris-2-project` - /// `C:\Users\foo\bar` → `C:-Users-foo-bar` + /// `C:\Users\foo\bar` → `C--Users-foo-bar` pub fn encode_project_path(path: &str) -> String { - const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ' ', '[', ']']; + const SANITIZED_CHARS: &[char] = &['/', '.', '_', '\\', ' ', '[', ']', ':']; path.chars() .map(|c| { @@ -403,10 +403,21 @@ mod tests { #[test] fn test_encode_project_path_windows() { - // Windows backslashes are also replaced with '-' + // Windows drive colons and backslashes are both replaced with '-', + // matching Claude Code's own slug generation on Windows. assert_eq!( ClaudeProvider::encode_project_path(r"C:\Users\foo\bar"), - "C:-Users-foo-bar" + "C--Users-foo-bar" + ); + } + + #[test] + fn test_encode_project_path_windows_with_space() { + // Spaces are also replaced; matches real-world paths like "f:\AMPLIA_APP 1" + // which Claude Code slugifies to "f--AMPLIA-APP-1". + assert_eq!( + ClaudeProvider::encode_project_path(r"f:\AMPLIA_APP 1"), + "f--AMPLIA-APP-1" ); }