csusage-adapter-zcode 0.0.2

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

use crate::{Result, cli::SharedArgs, debug_log};

pub(crate) const ZCODE_HOME_ENV: &str = "ZCODE_HOME";
pub(crate) const ZCODE_DB_RELATIVE_PATH: &str = "cli/db/db.sqlite";

fn paths(shared: &SharedArgs) -> Result<Vec<PathBuf>> {
    if let Some(raw) = env::var_os(ZCODE_HOME_ENV) {
        let configured = configured_roots(raw);
        if !configured.is_empty() {
            return Ok(unique_roots(configured, shared, true));
        }
    }

    let Some(home) = crate::home::home_dir() else {
        debug_log(shared, "Unable to resolve the default ZCode home");
        return Ok(Vec::new());
    };
    Ok(unique_roots([home.join(".zcode")], shared, false))
}

pub(super) fn db_paths(shared: &SharedArgs) -> Result<Vec<PathBuf>> {
    let mut db_paths = Vec::new();
    let mut seen = HashSet::new();
    for root in paths(shared)? {
        let db_path = root.join(ZCODE_DB_RELATIVE_PATH);
        let canonical = match fs::canonicalize(&db_path) {
            Ok(path) if path.is_file() => path,
            Ok(path) => {
                debug_log(
                    shared,
                    format!("ZCode database is not a file: {}", path.display()),
                );
                continue;
            }
            Err(error) => {
                debug_log(
                    shared,
                    format!(
                        "Unable to access ZCode database {}: {error}",
                        db_path.display()
                    ),
                );
                continue;
            }
        };
        if seen.insert(canonical.clone()) {
            db_paths.push(canonical);
        }
    }
    Ok(db_paths)
}

fn configured_roots(raw: std::ffi::OsString) -> Vec<PathBuf> {
    match raw.into_string() {
        Ok(value) => value
            .split(',')
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(PathBuf::from)
            .collect(),
        Err(raw) => vec![PathBuf::from(raw)],
    }
}

fn unique_roots(
    roots: impl IntoIterator<Item = PathBuf>,
    shared: &SharedArgs,
    configured: bool,
) -> Vec<PathBuf> {
    let mut resolved = Vec::new();
    let mut seen = HashSet::new();
    for root in roots {
        let Some(root) = canonical_directory(&root, shared, configured) else {
            continue;
        };
        if seen.insert(root.clone()) {
            resolved.push(root);
        }
    }
    resolved
}

fn canonical_directory(path: &Path, shared: &SharedArgs, configured: bool) -> Option<PathBuf> {
    let metadata = match fs::metadata(path) {
        Ok(metadata) => metadata,
        Err(error) => {
            if configured {
                debug_log(
                    shared,
                    format!(
                        "Ignoring configured ZCODE_HOME root {}: {error}",
                        path.display()
                    ),
                );
            }
            return None;
        }
    };
    if !metadata.is_dir() {
        if configured {
            debug_log(
                shared,
                format!(
                    "Ignoring configured ZCODE_HOME root {}: not a directory",
                    path.display()
                ),
            );
        }
        return None;
    }
    match fs::canonicalize(path) {
        Ok(path) => Some(path),
        Err(error) => {
            if configured {
                debug_log(
                    shared,
                    format!(
                        "Ignoring configured ZCODE_HOME root {}: {error}",
                        path.display()
                    ),
                );
            }
            None
        }
    }
}

#[cfg(test)]
mod tests {
    use std::ffi::OsString;

    use csusage_test_support::{EnvVarsGuard, fs_fixture};

    use super::*;

    fn shared() -> SharedArgs {
        SharedArgs {
            debug: true,
            ..SharedArgs::default()
        }
    }

    #[test]
    fn discovers_and_deduplicates_configured_roots() {
        let fixture = fs_fixture!({});
        let first = fixture.create_dir_all("first");
        let second = fixture.create_dir_all("second");
        let raw = format!(
            " {}, {}, {} ",
            first.display(),
            second.display(),
            first.display()
        );
        let _guard = EnvVarsGuard::set_many([
            (ZCODE_HOME_ENV, Some(OsString::from(raw))),
            ("HOME", None),
            ("USERPROFILE", None),
            ("HOMEDRIVE", None),
            ("HOMEPATH", None),
        ]);

        assert_eq!(
            paths(&shared()).unwrap(),
            vec![
                first.canonicalize().unwrap(),
                second.canonicalize().unwrap()
            ]
        );
    }

    #[test]
    fn invalid_configured_roots_do_not_fall_back_to_default() {
        let fixture = fs_fixture!({
            ".zcode/cli/db/db.sqlite": "not a database",
        });
        let _guard = EnvVarsGuard::set_many([
            (
                ZCODE_HOME_ENV,
                Some(fixture.path("missing").into_os_string()),
            ),
            ("HOME", Some(fixture.root().as_os_str().to_os_string())),
            ("USERPROFILE", None),
            ("HOMEDRIVE", None),
            ("HOMEPATH", None),
        ]);

        assert!(paths(&shared()).unwrap().is_empty());
    }

    #[test]
    fn empty_configured_value_uses_default_home() {
        let fixture = fs_fixture!({
            ".zcode/cli/db/db.sqlite": "not a database",
        });
        let _guard = EnvVarsGuard::set_many([
            (ZCODE_HOME_ENV, Some(OsString::new())),
            ("HOME", Some(fixture.root().as_os_str().to_os_string())),
            ("USERPROFILE", None),
            ("HOMEDRIVE", None),
            ("HOMEPATH", None),
        ]);

        assert_eq!(
            paths(&shared()).unwrap(),
            vec![fixture.path(".zcode").canonicalize().unwrap()]
        );
    }
}