cargo-rail 0.12.0

Graph-aware testing, dependency unification, and crate extraction for Rust monorepos
Documentation
//! Presentation layer for change detection
//!
//! Layer 3 of the change detection system:
//! - Layer 1: `classify.rs` - Pure file classification (ChangeKind)
//! - Layer 2: `workspace::change_analyzer` - Graph + cargo + proc-macro aware (ImpactReport)
//! - Layer 3: This module - Presentation choices (docs-only, infra, custom categories)
//!
//! This layer applies user-configurable rules (from ChangeDetectionConfig) to classify
//! changed files for presentation to the user or CI systems.

use crate::change_detection::classify::{ChangeKind, classify_file};
use crate::config::ChangeDetectionConfig;
use glob::Pattern;
use rustc_hash::FxHashMap;
use std::path::Path;

/// Presentation-level classification of changed files
///
/// Used for determining special display modes (docs-only, rebuild-all)
/// and matching custom categories from configuration.
#[derive(Debug, Clone, Default)]
pub struct ChangeClassification {
  /// True if only documentation files changed (no tests required)
  pub docs_only: bool,
  /// True if infrastructure files changed (requires full rebuild)
  pub rebuild_all: bool,
  /// Infrastructure files that triggered rebuild_all
  pub infrastructure_files: Vec<String>,
  /// Custom category matches: category name -> matched files
  pub custom_categories: FxHashMap<String, Vec<String>>,
}

impl ChangeClassification {
  /// Check if this classification indicates no testing is required
  pub fn skip_tests(&self) -> bool {
    self.docs_only
  }

  /// Check if this classification indicates a full rebuild is needed
  pub fn needs_full_rebuild(&self) -> bool {
    self.rebuild_all
  }

  /// Get all custom category names that have matches
  pub fn matched_categories(&self) -> Vec<&str> {
    self.custom_categories.keys().map(|s| s.as_str()).collect()
  }
}

/// Classifier for presentation-layer change detection
///
/// Applies configuration rules to classify changes for display/CI integration.
pub struct ChangeClassifier {
  /// Compiled infrastructure patterns
  infra_patterns: Vec<Pattern>,
  /// Compiled custom category patterns: (category_name, patterns)
  custom_patterns: Vec<(String, Vec<Pattern>)>,
}

impl ChangeClassifier {
  /// Create a new classifier from configuration
  pub fn new(config: &ChangeDetectionConfig) -> Self {
    // Compile infrastructure patterns
    let infra_patterns: Vec<Pattern> = config
      .infrastructure
      .iter()
      .filter_map(|p| Pattern::new(p).ok())
      .collect();

    // Compile custom category patterns
    let custom_patterns: Vec<(String, Vec<Pattern>)> = config
      .custom
      .iter()
      .map(|(category, patterns)| {
        let compiled: Vec<Pattern> = patterns.iter().filter_map(|p| Pattern::new(p).ok()).collect();
        (category.clone(), compiled)
      })
      .collect();

    Self {
      infra_patterns,
      custom_patterns,
    }
  }

  /// Create a classifier with default configuration
  pub fn default_config() -> Self {
    Self::new(&ChangeDetectionConfig::default())
  }

  /// Classify a list of changed file paths
  ///
  /// Produces a [`ChangeClassification`] with docs-only/full-rebuild signals,
  /// triggering infrastructure files, and matched custom categories.
  pub fn classify<P: AsRef<Path>>(&self, files: &[P]) -> ChangeClassification {
    let mut result = ChangeClassification::default();
    let mut has_non_doc_changes = false;

    for file in files {
      let path = file.as_ref();
      let path_str = path.to_string_lossy();

      // Infrastructure and custom categories are additive. A path can both
      // force rebuild_all and participate in custom CI routing.
      if self.is_infrastructure_file(&path_str) {
        result.rebuild_all = true;
        result.infrastructure_files.push(path_str.to_string());
        has_non_doc_changes = true;
      }

      // Check custom categories
      for (category, patterns) in &self.custom_patterns {
        for pattern in patterns {
          if pattern.matches(&path_str) {
            result
              .custom_categories
              .entry(category.clone())
              .or_default()
              .push(path_str.to_string());
            break; // Only match once per category per file
          }
        }
      }

      // Check file classification for docs-only detection
      let kind = classify_file(path);
      match kind {
        ChangeKind::Documentation => {
          // Still potentially docs_only
        }
        _ => {
          has_non_doc_changes = true;
        }
      }
    }

    // Set docs_only if we have changes and they're all docs
    result.docs_only = !files.is_empty() && !has_non_doc_changes;

    result
  }

  /// Check if a file path matches infrastructure patterns
  fn is_infrastructure_file(&self, path_str: &str) -> bool {
    self.infra_patterns.iter().any(|p| p.matches(path_str))
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use std::path::PathBuf;

  fn default_classifier() -> ChangeClassifier {
    ChangeClassifier::default_config()
  }

  #[test]
  fn test_docs_only() {
    let classifier = default_classifier();

    let files = vec![PathBuf::from("README.md"), PathBuf::from("docs/guide.md")];
    let result = classifier.classify(&files);

    assert!(result.docs_only, "Should be docs-only");
    assert!(!result.rebuild_all, "Should not require rebuild");
    assert!(result.infrastructure_files.is_empty());
  }

  #[test]
  fn test_docs_only_with_source() {
    let classifier = default_classifier();

    let files = vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")];
    let result = classifier.classify(&files);

    assert!(!result.docs_only, "Should not be docs-only with source file");
  }

  #[test]
  fn test_infrastructure_files() {
    let classifier = default_classifier();

    let files = vec![PathBuf::from(".github/workflows/ci.yml")];
    let result = classifier.classify(&files);

    assert!(result.rebuild_all, "Should require rebuild for CI changes");
    assert_eq!(result.infrastructure_files.len(), 1);
  }

  #[test]
  fn test_custom_categories() {
    let mut config = ChangeDetectionConfig::default();
    config
      .custom
      .insert("verify".to_string(), vec!["verify/**/*.rs".to_string()]);

    let classifier = ChangeClassifier::new(&config);

    let files = vec![PathBuf::from("verify/tests/model.rs")];
    let result = classifier.classify(&files);

    assert!(result.custom_categories.contains_key("verify"));
    assert_eq!(result.custom_categories["verify"].len(), 1);
  }

  #[test]
  fn test_infrastructure_file_can_also_match_custom_category() {
    let mut config = ChangeDetectionConfig {
      infrastructure: vec![".github/**".to_string()],
      ..Default::default()
    };
    config
      .custom
      .insert("merge_validation".to_string(), vec![".github/workflows/**".to_string()]);

    let classifier = ChangeClassifier::new(&config);

    let files = vec![PathBuf::from(".github/workflows/ci.yml")];
    let result = classifier.classify(&files);

    assert!(result.rebuild_all, "workflow changes should still require rebuild_all");
    assert_eq!(
      result.infrastructure_files,
      vec![".github/workflows/ci.yml".to_string()]
    );
    assert!(
      result.custom_categories.contains_key("merge_validation"),
      "workflow changes should also match custom merge-validation routing"
    );
  }

  #[test]
  fn test_empty_files() {
    let classifier = default_classifier();

    let files: Vec<PathBuf> = vec![];
    let result = classifier.classify(&files);

    assert!(!result.docs_only, "Empty file list should not be docs-only");
    assert!(!result.rebuild_all);
  }

  #[test]
  fn test_justfile_is_infrastructure() {
    let classifier = default_classifier();

    let files = vec![PathBuf::from("justfile")];
    let result = classifier.classify(&files);

    assert!(result.rebuild_all, "justfile should trigger rebuild_all");
  }
}