pmat 3.30.1

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP)
Documentation
// dependency_checks_cache.rs — included by dependency_checks.rs
// DependencyCache impl, Cargo.lock parsing, transitive dependency counting

/// Where CB-081 keeps its own state for a given project.
///
/// #939: `pmat comply check` is an auditor, and an auditor must not write to
/// the tree it audits. CB-081 used to drop `.pmat/deps-cache.json` and
/// `.pmat/metrics/dependencies.json` into the project as a side effect of
/// reading it, so a second `comply check` on an unchanged tree saw a different
/// tree: `CB-1332: Cache Staleness` flipped Skip -> Pass and the pass count
/// moved 25 -> 26 with no edit in between. On a repo with no `.pmat/` at all,
/// merely being scored created one.
///
/// The state now lives in the user's cache directory, keyed by the project
/// path, so scoring is read-only. Nothing here is a project artifact: it is a
/// cache and a trend log, both regenerable.
///
/// One rule, one place — every CB-081 read and write goes through this.
pub(super) fn cb081_state_dir(project_path: &Path) -> std::path::PathBuf {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let canonical = fs::canonicalize(project_path).unwrap_or_else(|_| project_path.to_path_buf());
    let mut hasher = DefaultHasher::new();
    canonical.hash(&mut hasher);
    let name = canonical
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("project");
    let key = format!("{name}-{:016x}", hasher.finish());

    let base = dirs::cache_dir()
        .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
        .unwrap_or_else(std::env::temp_dir);

    base.join("paiml-mcp-agent-toolkit")
        .join("comply")
        .join("cb081")
        .join(key)
}

impl DependencyCache {
    /// Check if cache is valid (Cargo.lock unchanged)
    fn is_valid(&self, cargo_lock_path: &Path) -> bool {
        if let Ok(metadata) = fs::metadata(cargo_lock_path) {
            if let Ok(modified) = metadata.modified() {
                let mtime = modified
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_secs())
                    .unwrap_or(0);
                return mtime == self.cargo_lock_mtime;
            }
        }
        false
    }

    /// Load cache from the out-of-project state directory.
    fn load(project_path: &Path) -> Option<Self> {
        let cache_path = cb081_state_dir(project_path).join("deps-cache.json");
        fs::read_to_string(&cache_path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
    }

    /// Save cache to the out-of-project state directory (never into the audited
    /// project — see [`cb081_state_dir`]).
    fn save(&self, project_path: &Path) {
        let cache_path = cb081_state_dir(project_path).join("deps-cache.json");
        if let Some(parent) = cache_path.parent() {
            let _ = fs::create_dir_all(parent);
        }
        if let Ok(json) = serde_json::to_string_pretty(self) {
            let _ = fs::write(&cache_path, json);
        }
    }
}

/// Parse Cargo.lock once and return both transitive count and duplicates
pub(super) fn parse_cargo_lock(cargo_lock_path: &Path) -> (usize, Vec<DuplicateCrate>) {
    let content = match fs::read_to_string(cargo_lock_path) {
        Ok(c) => c,
        Err(_) => return (0, Vec::new()),
    };

    let mut crate_versions: HashMap<String, Vec<String>> = HashMap::new();
    let mut current_name: Option<String> = None;
    let mut current_version: Option<String> = None;
    let mut package_count = 0;

    for line in content.lines() {
        let trimmed = line.trim();

        if trimmed == "[[package]]" {
            package_count += 1;
            // Save previous package if complete
            if let (Some(name), Some(version)) = (current_name.take(), current_version.take()) {
                crate_versions.entry(name).or_default().push(version);
            }
        } else if let Some(name) = trimmed.strip_prefix("name = \"") {
            current_name = name.strip_suffix('"').map(|s| s.to_string());
        } else if let Some(version) = trimmed.strip_prefix("version = \"") {
            current_version = version.strip_suffix('"').map(|s| s.to_string());
        }
    }

    // Don't forget the last package
    if let (Some(name), Some(version)) = (current_name, current_version) {
        crate_versions.entry(name).or_default().push(version);
    }

    // Filter to only duplicates (>1 version)
    let duplicates: Vec<DuplicateCrate> = crate_versions
        .into_iter()
        .filter(|(_, versions)| versions.len() > 1)
        .map(|(name, mut versions)| {
            versions.sort();
            versions.dedup();
            DuplicateCrate { name, versions }
        })
        .filter(|d| d.versions.len() > 1)
        .collect();

    (package_count, duplicates)
}

/// Count production-only transitive dependencies using `cargo tree -e no-dev`.
///
/// Returns `None` when cargo tree is unavailable, fails, or would have to
/// change the lockfile.
///
/// `--locked` is not optional here (#939). Without it, `cargo tree` *resolves*:
/// on a project whose `Cargo.lock` is incomplete or stale it rewrites the
/// lockfile in place — measured at 45 bytes -> 1,790 bytes on a one-dependency
/// fixture, after fetching from the network — so merely scoring a repository
/// edited a tracked file in it. The caller of this function already refuses to
/// run at all when there is no `Cargo.lock` for exactly that reason; a lockfile
/// that exists deserves the same protection. With `--locked`, cargo exits 101
/// rather than writing, and the count falls back to the total from
/// `Cargo.lock`.
pub(super) fn count_production_transitive(project_path: &Path) -> Option<usize> {
    let output = std::process::Command::new("cargo")
        .args(["tree", "-e", "no-dev", "--prefix=none", "--locked"])
        .current_dir(project_path)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut unique_packages: HashSet<String> = HashSet::new();
    for line in stdout.lines() {
        let trimmed = line.trim();
        if !trimmed.is_empty() {
            // cargo tree --prefix=none outputs "crate_name v1.2.3"
            if let Some(name) = trimmed.split_whitespace().next() {
                unique_packages.insert(name.to_string());
            }
        }
    }

    Some(unique_packages.len())
}

/// Get dependency analysis with O(1) caching (issue #148 fix)
pub(super) fn get_cached_dependency_analysis(
    project_path: &Path,
    cargo_lock_path: &Path,
) -> (usize, Option<usize>, Vec<DuplicateCrate>) {
    // Try to use cached results first
    if let Some(cache) = DependencyCache::load(project_path) {
        if cache.is_valid(cargo_lock_path) {
            return (
                cache.transitive_count,
                cache.prod_transitive_count,
                cache.duplicate_crates,
            );
        }
    }

    // Cache miss or invalid - parse Cargo.lock
    let (transitive_count, duplicate_crates) = parse_cargo_lock(cargo_lock_path);

    // Get production-only count via cargo tree
    let prod_transitive_count = count_production_transitive(project_path);

    // Save to cache
    let mtime = fs::metadata(cargo_lock_path)
        .and_then(|m| m.modified())
        .map(|t| {
            t.duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0)
        })
        .unwrap_or(0);

    let cache = DependencyCache {
        cargo_lock_mtime: mtime,
        transitive_count,
        prod_transitive_count,
        duplicate_crates: duplicate_crates.clone(),
    };
    cache.save(project_path);

    (transitive_count, prod_transitive_count, duplicate_crates)
}