Skip to main content

cargo_rail/config/
change_detection.rs

1//! Change detection configuration.
2//!
3//! This config is consumed by planner file classification and custom surfaces.
4
5use crate::error::ConfigError;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Confidence profile for planner safety behavior.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11#[serde(rename_all = "snake_case")]
12pub enum ConfidenceProfile {
13  /// Most conservative behavior; expands package-scoped execution.
14  Strict,
15  /// Default trade-off between safety and speed.
16  #[default]
17  Balanced,
18  /// Fastest behavior; minimizes conservative expansion.
19  Fast,
20}
21
22/// Configuration for planner change detection.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ChangeDetectionConfig {
25  /// Glob patterns for infrastructure files that trigger rebuild_all
26  /// Default: [".github/**", "scripts/**", "justfile", "Makefile", ...]
27  #[serde(default = "default_infrastructure_patterns")]
28  pub infrastructure: Vec<String>,
29
30  /// Custom path patterns and their categories
31  /// Example: verify = ["verify/**/*.rs"] for Stateright verification models
32  #[serde(default)]
33  pub custom: HashMap<String, Vec<String>>,
34
35  /// When true, unclassified crate-owned files conservatively enable build+test surfaces.
36  ///
37  /// Set to false to keep aggressive behavior for unknown file kinds.
38  #[serde(default = "default_conservative_unclassified_owner_fallback")]
39  pub conservative_unclassified_owner_fallback: bool,
40
41  /// Confidence profile used by planner unless explicitly overridden by CLI.
42  #[serde(default)]
43  pub confidence_profile: ConfidenceProfile,
44
45  /// Optional confidence profile override for bot-authored pull requests.
46  ///
47  /// When set, planner applies this profile only in bot-authored PR contexts.
48  #[serde(default)]
49  pub bot_pr_confidence_profile: Option<ConfidenceProfile>,
50}
51
52impl Default for ChangeDetectionConfig {
53  fn default() -> Self {
54    Self {
55      infrastructure: default_infrastructure_patterns(),
56      custom: HashMap::new(),
57      conservative_unclassified_owner_fallback: default_conservative_unclassified_owner_fallback(),
58      confidence_profile: ConfidenceProfile::default(),
59      bot_pr_confidence_profile: None,
60    }
61  }
62}
63
64impl ChangeDetectionConfig {
65  /// Validate all glob patterns in the configuration
66  pub fn validate(&self) -> Result<(), ConfigError> {
67    // Validate infrastructure patterns
68    for pattern in &self.infrastructure {
69      if let Err(e) = glob::Pattern::new(pattern) {
70        return Err(ConfigError::InvalidGlobPattern {
71          pattern: pattern.clone(),
72          message: e.to_string(),
73        });
74      }
75    }
76
77    // Validate custom category names + patterns
78    for (category, patterns) in &self.custom {
79      if !is_valid_custom_category(category) {
80        return Err(ConfigError::InvalidValue {
81          field: format!("change-detection.custom.{}", category),
82          message: "invalid category name; use ASCII letters, digits, '_' or '-' (cannot start with 'custom:')"
83            .to_string(),
84        });
85      }
86
87      for pattern in patterns {
88        if let Err(e) = glob::Pattern::new(pattern) {
89          return Err(ConfigError::InvalidGlobPattern {
90            pattern: format!("{}: {}", category, pattern),
91            message: e.to_string(),
92          });
93        }
94      }
95    }
96
97    Ok(())
98  }
99}
100
101fn default_infrastructure_patterns() -> Vec<String> {
102  vec![
103    ".github/**".to_string(),
104    "scripts/**".to_string(),
105    "justfile".to_string(),
106    "Justfile".to_string(),
107    "Makefile".to_string(),
108    "makefile".to_string(),
109    "GNUmakefile".to_string(),
110    "*.sh".to_string(),
111    "Taskfile.yml".to_string(),
112    "Taskfile.yaml".to_string(),
113    ".pre-commit-config.yaml".to_string(),
114    "deny.toml".to_string(),
115    "cliff.toml".to_string(),
116    "release.toml".to_string(),
117    "release-plz.toml".to_string(),
118  ]
119}
120
121fn default_conservative_unclassified_owner_fallback() -> bool {
122  true
123}
124
125fn is_valid_custom_category(category: &str) -> bool {
126  if category.is_empty() || category.starts_with("custom:") {
127    return false;
128  }
129
130  category
131    .bytes()
132    .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
133}
134
135#[cfg(test)]
136mod tests {
137  use super::{ChangeDetectionConfig, ConfidenceProfile};
138  use std::collections::HashMap;
139
140  #[test]
141  fn test_validate_accepts_valid_custom_category_names() {
142    let mut custom = HashMap::new();
143    custom.insert("verify_models".to_string(), vec!["verify/**".to_string()]);
144    custom.insert("bench-extended".to_string(), vec!["perf/**".to_string()]);
145    let cfg = ChangeDetectionConfig {
146      infrastructure: vec![".github/**".to_string()],
147      custom,
148      conservative_unclassified_owner_fallback: true,
149      confidence_profile: ConfidenceProfile::Balanced,
150      bot_pr_confidence_profile: None,
151    };
152    assert!(cfg.validate().is_ok());
153  }
154
155  #[test]
156  fn test_validate_rejects_invalid_custom_category_names() {
157    let mut custom = HashMap::new();
158    custom.insert("custom:verify".to_string(), vec!["verify/**".to_string()]);
159    let cfg = ChangeDetectionConfig {
160      infrastructure: vec![".github/**".to_string()],
161      custom,
162      conservative_unclassified_owner_fallback: true,
163      confidence_profile: ConfidenceProfile::Balanced,
164      bot_pr_confidence_profile: None,
165    };
166    assert!(cfg.validate().is_err());
167  }
168}