cargo-rail 0.22.2

Cargo-native Rust workspace engine for affected CI, Rust source-surface analysis, compiler reuse, dependency coherence, exact-SHA releases, and split/sync
Documentation
//! Affected crate analysis
//!
//! Given a set of changed files, determine:
//! - Which crates directly contain those files
//! - Which crates transitively depend on the changed crates
//! - Minimal set of crates that need testing

use super::core::WorkspaceGraph;
use crate::error::RailResult;
use std::collections::HashSet;
use std::path::Path;

/// Set of affected crates from file changes.
#[derive(Debug, Clone)]
pub struct AffectedSet {
    /// Crates directly containing changed files
    pub direct: HashSet<String>,

    /// Transitive dependents of changed crates
    pub dependents: HashSet<String>,

    /// Minimal test set (direct + dependents)
    pub test_targets: HashSet<String>,
}

/// Complete affected analysis.
#[derive(Debug, Clone)]
pub struct AffectedAnalysis {
    /// Files that changed
    pub changed_files: Vec<String>,

    /// Impact set
    pub impact: AffectedSet,
}

/// Analyze which crates are affected by file changes.
///
/// Algorithm:
/// 1. Map files → owning crates through the immutable longest-prefix index
/// 2. Single reverse traversal from all direct crates to find dependents (O(V+E))
/// 3. Union direct crates + dependents for test targets
///
/// Complexity is O(n × d + V + E), where n is the file count, d is the maximum
/// path depth, V is the vertex count, and E is the edge count.
pub fn analyze(graph: &WorkspaceGraph, changed_files: &[impl AsRef<Path>]) -> RailResult<AffectedAnalysis> {
    if changed_files.is_empty() {
        return Ok(AffectedAnalysis {
            changed_files: vec![],
            impact: AffectedSet {
                direct: HashSet::new(),
                dependents: HashSet::new(),
                test_targets: HashSet::new(),
            },
        });
    }

    // Map files → exact packages through the immutable ownership index.
    let direct_package_ids = graph.files_to_package_ids(changed_files);

    if direct_package_ids.is_empty() {
        // No workspace crates affected (e.g., README, LICENSE, etc.)
        return Ok(AffectedAnalysis {
            changed_files: changed_files.iter().map(|p| p.as_ref().display().to_string()).collect(),
            impact: AffectedSet {
                direct: HashSet::new(),
                dependents: HashSet::new(),
                test_targets: HashSet::new(),
            },
        });
    }

    let direct_crates: HashSet<String> = direct_package_ids
        .iter()
        .filter_map(|package_id| graph.package(package_id).map(|package| package.name.clone()))
        .collect();

    // Get all transitive dependents in a single traversal
    // This is O(V+E) regardless of how many direct crates there are,
    // vs O(N × (V+E)) if we called transitive_dependents() for each crate
    let all_dependents = graph.transitive_dependents_of_ids(&direct_package_ids)?;

    // Build test targets (direct + dependents)
    let mut test_targets = direct_crates.clone();
    test_targets.extend(all_dependents.clone());

    Ok(AffectedAnalysis {
        changed_files: changed_files.iter().map(|p| p.as_ref().display().to_string()).collect(),
        impact: AffectedSet {
            direct: direct_crates,
            dependents: all_dependents,
            test_targets,
        },
    })
}