cargo-rail 0.20.1

Cargo-native Rust workspace engine for affected CI, verified compiler reuse, dependency coherence, exact-SHA releases, and crate 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,
    },
  })
}