csusage-adapter-qwen 0.0.0

Coding agent CLI usage reports (ccusage fork with Claude Science support)
use std::{
    collections::HashSet,
    env,
    path::{Component, Path, PathBuf},
};

use crate::{Result, collect_files_with_extension};

const QWEN_DATA_DIR_ENV: &str = "QWEN_DATA_DIR";

fn paths() -> Result<Vec<PathBuf>> {
    let candidates = if let Ok(paths) = env::var(QWEN_DATA_DIR_ENV) {
        paths
            .split(',')
            .map(str::trim)
            .filter(|path| !path.is_empty())
            .map(PathBuf::from)
            .collect::<Vec<_>>()
    } else {
        crate::home::home_dir()
            .map(|home| vec![home.join(".qwen")])
            .unwrap_or_default()
    };
    let mut seen = HashSet::new();
    Ok(candidates
        .into_iter()
        .filter(|path| path.is_dir())
        .filter(|path| seen.insert(path.clone()))
        .collect())
}

pub(super) fn discover_chat_files() -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    for root in paths()? {
        let projects = root.join("projects");
        if !projects.is_dir() {
            continue;
        }
        let mut root_files = Vec::new();
        collect_files_with_extension(&projects, "jsonl", &mut root_files);
        root_files.retain(|file| is_chat_file(&projects, file));
        files.extend(root_files);
    }
    files.sort();
    Ok(files)
}

fn is_chat_file(projects: &Path, file: &Path) -> bool {
    let Ok(relative) = file.strip_prefix(projects) else {
        return false;
    };
    let parts = relative.components().collect::<Vec<_>>();
    matches!(
        parts.as_slice(),
        [Component::Normal(project), Component::Normal(chats), Component::Normal(file)]
            if !project.is_empty()
                && chats.to_str() == Some("chats")
                && file.to_string_lossy().ends_with(".jsonl")
    )
}

pub(super) fn project_from_file(file: &Path) -> Option<String> {
    let parts = file.components().collect::<Vec<_>>();
    for window in parts.windows(4).rev() {
        if let [
            Component::Normal(projects),
            Component::Normal(project),
            Component::Normal(chats),
            Component::Normal(_),
        ] = window
            && projects.to_str() == Some("projects")
            && chats.to_str() == Some("chats")
        {
            return Some(project.to_string_lossy().into_owned());
        }
    }
    None
}