Skip to main content

cargo_rail/config/
run.rs

1//! Run/execution profile configuration.
2
3use crate::error::ConfigError;
4use rustc_hash::FxHashMap;
5use serde::{Deserialize, Serialize};
6
7const BUILTIN_PROFILE_NAMES: &[&str] = &["local", "ci", "nightly"];
8const SUPPORTED_SURFACES: &[&str] = &["build", "test", "bench", "docs", "infra"];
9const ALLOWED_RUN_ARG_TOKENS: &[&str] = &["workspace_root", "base_ref", "cargo_args"];
10const ALLOWED_SINCE_TOKENS: &[&str] = &["workspace_root", "base_ref"];
11
12/// Executor profile configuration for `cargo rail run`.
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct RunConfig {
15  /// Optional default profile when `cargo rail run` is invoked without `--surface` or `--profile`.
16  #[serde(default)]
17  pub default_profile: Option<String>,
18  /// User-defined profiles keyed by profile name.
19  #[serde(default, rename = "profile")]
20  pub profiles: FxHashMap<String, RunProfile>,
21  /// Optional workflow-to-profile mapping (for CI wrappers and conventions).
22  #[serde(default)]
23  pub workflow: FxHashMap<String, String>,
24}
25
26/// A named run profile.
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct RunProfile {
29  /// Surfaces executed by this profile.
30  #[serde(default)]
31  pub surfaces: Vec<String>,
32  /// Arguments prepended to command-line `RUN_ARGS`.
33  #[serde(default)]
34  pub run_args: Vec<String>,
35  /// Optional baseline reference if CLI doesn't provide `--since`/`--merge-base`.
36  #[serde(default)]
37  pub since: Option<String>,
38  /// Optional merge-base behavior if CLI doesn't provide `--since`.
39  #[serde(default)]
40  pub merge_base: Option<bool>,
41}
42
43impl RunConfig {
44  /// Validate run profile configuration.
45  pub fn validate(&self) -> Result<(), ConfigError> {
46    if let Some(default_profile) = self.default_profile.as_deref()
47      && !self.profiles.contains_key(default_profile)
48      && !is_builtin_profile(default_profile)
49    {
50      return Err(ConfigError::InvalidField {
51        field: "run.default_profile".to_string(),
52        reason: format!(
53          "unknown profile '{}'; define [run.profile.{}] or use one of: {}",
54          default_profile,
55          default_profile,
56          BUILTIN_PROFILE_NAMES.join(", ")
57        ),
58      });
59    }
60
61    for (name, profile) in &self.profiles {
62      profile.validate(name)?;
63    }
64
65    for (workflow_name, profile_name) in &self.workflow {
66      if self.profiles.contains_key(profile_name) || is_builtin_profile(profile_name) {
67        continue;
68      }
69
70      return Err(ConfigError::InvalidField {
71        field: format!("run.workflow.{}", workflow_name),
72        reason: format!(
73          "unknown profile '{}'; define [run.profile.{}] or use one of: {}",
74          profile_name,
75          profile_name,
76          BUILTIN_PROFILE_NAMES.join(", ")
77        ),
78      });
79    }
80
81    Ok(())
82  }
83}
84
85impl RunProfile {
86  fn validate(&self, profile_name: &str) -> Result<(), ConfigError> {
87    if self.surfaces.is_empty() {
88      return Err(ConfigError::InvalidField {
89        field: format!("run.profile.{}.surfaces", profile_name),
90        reason: "must contain at least one surface".to_string(),
91      });
92    }
93
94    for surface in &self.surfaces {
95      // custom:* surfaces are plan OUTPUTS, not profile inputs
96      if surface.starts_with("custom:") {
97        return Err(ConfigError::InvalidField {
98          field: format!("run.profile.{}.surfaces", profile_name),
99          reason: format!(
100            "invalid surface '{}'\n\n\
101             Custom surfaces are plan OUTPUTS for CI gating, not profile inputs.\n\
102             Valid profile surfaces: {}\n\n\
103             To gate CI jobs on custom surfaces, extract from plan JSON output:\n  \
104             WORKLOADS=$(echo \"$PLAN_JSON\" | jq -r '.surfaces[\"custom:workloads\"]')",
105            surface,
106            SUPPORTED_SURFACES.join(", ")
107          ),
108        });
109      }
110
111      if !SUPPORTED_SURFACES.contains(&surface.as_str()) {
112        return Err(ConfigError::InvalidField {
113          field: format!("run.profile.{}.surfaces", profile_name),
114          reason: format!(
115            "unknown surface '{}'; supported surfaces: {}",
116            surface,
117            SUPPORTED_SURFACES.join(", ")
118          ),
119        });
120      }
121    }
122
123    if self.since.is_some() && self.merge_base == Some(true) {
124      return Err(ConfigError::InvalidField {
125        field: format!("run.profile.{}", profile_name),
126        reason: "`since` and `merge_base = true` are mutually exclusive".to_string(),
127      });
128    }
129
130    if let Some(since) = &self.since {
131      validate_tokens(
132        since,
133        ALLOWED_SINCE_TOKENS,
134        &format!("run.profile.{}.since", profile_name),
135      )?;
136    }
137
138    for (index, arg) in self.run_args.iter().enumerate() {
139      validate_tokens(
140        arg,
141        ALLOWED_RUN_ARG_TOKENS,
142        &format!("run.profile.{}.run_args[{}]", profile_name, index),
143      )?;
144    }
145
146    Ok(())
147  }
148}
149
150/// Returns true when name is one of the built-in profiles.
151pub fn is_builtin_profile(name: &str) -> bool {
152  BUILTIN_PROFILE_NAMES.contains(&name)
153}
154
155fn validate_tokens(value: &str, allowed: &[&str], field: &str) -> Result<(), ConfigError> {
156  for token in extract_tokens(value) {
157    if !allowed.contains(&token.as_str()) {
158      return Err(ConfigError::InvalidField {
159        field: field.to_string(),
160        reason: format!("unknown token '{{{}}}'; allowed tokens: {}", token, allowed.join(", ")),
161      });
162    }
163  }
164  Ok(())
165}
166
167fn extract_tokens(value: &str) -> Vec<String> {
168  let mut tokens = Vec::new();
169  let bytes = value.as_bytes();
170  let mut i = 0;
171  while i < bytes.len() {
172    if bytes[i] == b'{' {
173      let start = i + 1;
174      if let Some(end_rel) = bytes[start..].iter().position(|b| *b == b'}') {
175        let end = start + end_rel;
176        if end > start {
177          let token = &value[start..end];
178          if token
179            .chars()
180            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
181          {
182            tokens.push(token.to_string());
183          }
184        }
185        i = end + 1;
186        continue;
187      }
188    }
189    i += 1;
190  }
191  tokens
192}
193
194#[cfg(test)]
195mod tests {
196  use super::*;
197
198  #[test]
199  fn validate_rejects_empty_surfaces() {
200    let mut cfg = RunConfig::default();
201    cfg.profiles.insert("custom".to_string(), RunProfile::default());
202
203    let err = cfg.validate().expect_err("profile without surfaces should fail");
204    assert!(err.to_string().contains("must contain at least one surface"));
205  }
206
207  #[test]
208  fn validate_accepts_builtin_default_profile() {
209    let cfg = RunConfig {
210      default_profile: Some("local".to_string()),
211      ..RunConfig::default()
212    };
213
214    assert!(cfg.validate().is_ok());
215  }
216
217  #[test]
218  fn validate_rejects_unknown_default_profile() {
219    let cfg = RunConfig {
220      default_profile: Some("missing".to_string()),
221      ..RunConfig::default()
222    };
223
224    let err = cfg.validate().expect_err("unknown default profile should fail");
225    assert!(err.to_string().contains("unknown profile 'missing'"));
226  }
227
228  #[test]
229  fn validate_rejects_unknown_run_arg_token() {
230    let mut cfg = RunConfig::default();
231    cfg.profiles.insert(
232      "custom".to_string(),
233      RunProfile {
234        surfaces: vec!["test".to_string()],
235        run_args: vec!["--manifest-path".to_string(), "{unknown}".to_string()],
236        ..RunProfile::default()
237      },
238    );
239    let err = cfg
240      .validate()
241      .expect_err("unknown run_args token should fail validation");
242    assert!(err.to_string().contains("unknown token '{unknown}'"));
243  }
244
245  #[test]
246  fn validate_rejects_unknown_since_token() {
247    let mut cfg = RunConfig::default();
248    cfg.profiles.insert(
249      "custom".to_string(),
250      RunProfile {
251        surfaces: vec!["test".to_string()],
252        since: Some("{cargo_args}".to_string()),
253        ..RunProfile::default()
254      },
255    );
256    let err = cfg.validate().expect_err("unknown since token should fail validation");
257    assert!(err.to_string().contains("unknown token '{cargo_args}'"));
258  }
259
260  #[test]
261  fn validate_rejects_custom_surface_in_profile() {
262    let mut cfg = RunConfig::default();
263    cfg.profiles.insert(
264      "ci".to_string(),
265      RunProfile {
266        surfaces: vec!["custom:workloads".to_string()],
267        ..RunProfile::default()
268      },
269    );
270    let err = cfg.validate().expect_err("custom surface in profile should fail");
271    let msg = err.to_string();
272    assert!(msg.contains("invalid surface 'custom:workloads'"));
273    assert!(msg.contains("plan OUTPUTS"));
274  }
275
276  #[test]
277  fn validate_rejects_workflow_mapping_to_missing_profile() {
278    let mut cfg = RunConfig::default();
279    cfg.workflow.insert("commit".to_string(), "missing".to_string());
280    let err = cfg.validate().expect_err("missing profile mapping should fail");
281    assert!(err.to_string().contains("unknown profile 'missing'"));
282  }
283}