csusage-core 0.0.1

Coding agent CLI usage reports (ccusage fork with Claude Science support)
use std::collections::HashMap;

pub(crate) fn parse_project_aliases(raw: Option<&str>) -> HashMap<String, String> {
    raw.unwrap_or_default()
        .split(',')
        .filter_map(|pair| {
            let (key, value) = pair.split_once('=')?;
            let key = key.trim();
            let value = value.trim();
            if key.is_empty() || value.is_empty() {
                None
            } else {
                Some((key.to_string(), value.to_string()))
            }
        })
        .collect()
}

pub fn format_project_name(project: &str, aliases: &HashMap<String, String>) -> String {
    if let Some(alias) = aliases.get(project) {
        return alias.clone();
    }
    let parsed = parse_project_name(project);
    aliases.get(&parsed).cloned().unwrap_or(parsed)
}

fn parse_project_name(project: &str) -> String {
    if project.is_empty() || project == "unknown" {
        return "Unknown Project".to_string();
    }
    let mut cleaned = project.to_string();
    if is_windows_users_path(&cleaned) {
        let segments = cleaned.split('\\').collect::<Vec<_>>();
        if let Some(index) = segments.iter().position(|segment| *segment == "Users") {
            if index + 3 < segments.len() {
                cleaned = segments[index + 3..].join("-");
            } else if index + 2 < segments.len() {
                cleaned = segments[index + 2..].join("-");
            }
        }
    } else if cleaned.starts_with("-Users-") || cleaned.starts_with("/Users/") {
        let separator = if cleaned.starts_with("-Users-") {
            '-'
        } else {
            '/'
        };
        let segments = cleaned
            .split(separator)
            .filter(|segment| !segment.is_empty())
            .collect::<Vec<_>>();
        if let Some(index) = segments.iter().position(|segment| *segment == "Users") {
            if index + 3 < segments.len() {
                cleaned = segments[index + 3..].join("-");
            } else if index + 2 < segments.len() {
                cleaned = segments[index + 2..].join("-");
            }
        }
    } else {
        cleaned = cleaned
            .trim_matches(|ch| ch == '/' || ch == '\\' || ch == '-')
            .to_string();
    }
    if cleaned.split('-').count() >= 5
        && cleaned
            .chars()
            .all(|ch| ch.is_ascii_hexdigit() || ch == '-' || ch == '.')
    {
        let parts = cleaned.split('-').collect::<Vec<_>>();
        cleaned = parts[parts.len().saturating_sub(2)..].join("-");
    }
    if let Some((main, _)) = cleaned.split_once("--") {
        cleaned = main.to_string();
    }
    if cleaned.contains('-') && cleaned.len() > 20 {
        let meaningful = cleaned
            .split('-')
            .filter(|segment| {
                segment.len() > 2
                    && !matches!(
                        segment.to_ascii_lowercase().as_str(),
                        "dev"
                            | "development"
                            | "feat"
                            | "feature"
                            | "fix"
                            | "bug"
                            | "test"
                            | "staging"
                            | "prod"
                            | "production"
                            | "main"
                            | "master"
                            | "branch"
                    )
            })
            .collect::<Vec<_>>();
        if meaningful.len() >= 2 {
            let last_two = meaningful[meaningful.len() - 2..].join("-");
            cleaned = if last_two.len() >= 6 {
                last_two
            } else if meaningful.len() >= 3 {
                meaningful[meaningful.len() - 3..].join("-")
            } else {
                cleaned
            };
        }
    }
    let cleaned = cleaned.trim_matches(|ch| ch == '/' || ch == '\\' || ch == '-');
    if cleaned.is_empty() {
        project.to_string()
    } else {
        cleaned.to_string()
    }
}

fn is_windows_users_path(project: &str) -> bool {
    let bytes = project.as_bytes();
    (bytes.len() >= 10
        && bytes[1] == b':'
        && bytes[2] == b'\\'
        && bytes[3..].starts_with(b"Users\\"))
        || project.starts_with("\\Users\\")
}

pub fn short_model_name(model: &str) -> String {
    let model = model
        .strip_prefix("anthropic/claude-")
        .or_else(|| model.strip_prefix("claude-"))
        .unwrap_or(model);
    let parts = model.split('-').collect::<Vec<_>>();
    if parts.len() >= 3 && parts.last().is_some_and(|part| part.len() == 8) {
        return parts[..parts.len() - 1].join("-");
    }
    model.to_string()
}

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

    #[test]
    fn formats_users_project_paths_without_dropping_project_name() {
        let aliases = HashMap::new();

        assert_eq!(
            format_project_name("/Users/alice/ccusage", &aliases),
            "ccusage"
        );
        assert_eq!(
            format_project_name("/Users/alice/Development/ccusage", &aliases),
            "ccusage"
        );
        assert_eq!(
            format_project_name("-Users-alice-ccusage", &aliases),
            "ccusage"
        );
    }
}