1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct RunConfig {
15 #[serde(default)]
17 pub default_profile: Option<String>,
18 #[serde(default, rename = "profile")]
20 pub profiles: FxHashMap<String, RunProfile>,
21 #[serde(default)]
23 pub workflow: FxHashMap<String, String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct RunProfile {
29 #[serde(default)]
31 pub surfaces: Vec<String>,
32 #[serde(default)]
34 pub run_args: Vec<String>,
35 #[serde(default)]
37 pub since: Option<String>,
38 #[serde(default)]
40 pub merge_base: Option<bool>,
41}
42
43impl RunConfig {
44 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 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
150pub 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}