1use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(deny_unknown_fields, default)]
19pub struct DevEnvironment {
20 pub run: String,
23 #[serde(skip_serializing_if = "GithubSetup::is_empty")]
25 pub github: GithubSetup,
26 #[serde(skip_serializing_if = "GitlabSetup::is_empty")]
28 pub gitlab: GitlabSetup,
29 #[serde(skip_serializing_if = "RkRoute::is_environment")]
32 pub rk: RkRoute,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RkRoute {
41 #[default]
43 Environment,
44 Installer,
47}
48
49impl RkRoute {
50 #[must_use]
52 pub fn is_environment(&self) -> bool {
53 *self == Self::Environment
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
59#[serde(deny_unknown_fields, default)]
60pub struct GithubSetup {
61 #[serde(skip_serializing_if = "Vec::is_empty")]
63 pub setup: Vec<ActionStep>,
64 #[serde(skip_serializing_if = "Vec::is_empty")]
68 pub cache: Vec<ActionStep>,
69}
70
71impl GithubSetup {
72 const fn is_empty(&self) -> bool {
73 self.setup.is_empty() && self.cache.is_empty()
74 }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
79#[serde(deny_unknown_fields, default)]
80pub struct GitlabSetup {
81 #[serde(skip_serializing_if = "String::is_empty")]
83 pub image: String,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub before_script: Vec<String>,
87}
88
89impl GitlabSetup {
90 const fn is_empty(&self) -> bool {
91 self.image.is_empty() && self.before_script.is_empty()
92 }
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(deny_unknown_fields, default)]
98pub struct ActionStep {
99 pub uses: String,
101 #[serde(skip_serializing_if = "String::is_empty")]
103 pub tag: String,
104 #[serde(rename = "with", skip_serializing_if = "BTreeMap::is_empty")]
106 pub inputs: BTreeMap<String, Input>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
111#[serde(untagged)]
112pub enum Input {
113 Bool(bool),
115 Integer(i64),
117 Text(String),
119}
120
121impl Input {
122 #[must_use]
125 pub fn yaml(&self) -> String {
126 match self {
127 Self::Bool(value) => value.to_string(),
128 Self::Integer(value) => value.to_string(),
129 Self::Text(value) => serde_json::Value::String(value.clone()).to_string(),
130 }
131 }
132}
133
134impl DevEnvironment {
135 #[must_use]
138 pub fn answered(self) -> Option<Self> {
139 (!self.run.is_empty()).then_some(self)
140 }
141
142 pub fn check(&self) -> Result<(), String> {
147 if self.run.is_empty() {
148 if self.rk == RkRoute::Installer {
149 return Err("dev_environment.rk is \"installer\" while dev_environment.run is empty; the route of rk applies only beside a recorded run prefix".to_owned());
150 }
151 if self.github.is_empty() && self.gitlab.is_empty() {
152 return Ok(());
153 }
154 return Err("dev_environment.run is empty while the table names a setup; an empty run states that the project gives no answer".to_owned());
155 }
156 check_run(&self.run)?;
157 for (list, steps) in [("setup", &self.github.setup), ("cache", &self.github.cache)] {
158 for (at, step) in steps.iter().enumerate() {
159 check_step(&format!("dev_environment.github.{list}[{at}]"), step)?;
160 }
161 }
162 if !self.gitlab.image.is_empty() && !is_image(&self.gitlab.image) {
163 return Err(format!(
164 "dev_environment.gitlab.image is {:?}, which is no image reference; write one such as nixos/nix:2.35.2",
165 self.gitlab.image
166 ));
167 }
168 for (at, line) in self.gitlab.before_script.iter().enumerate() {
169 one_line(&format!("dev_environment.gitlab.before_script[{at}]"), line)?;
170 }
171 Ok(())
172 }
173
174 pub fn check_forge(&self, forge: Option<&str>) -> Result<(), String> {
180 match forge {
181 Some("github") if self.github.setup.is_empty() => Err(
182 "dev_environment.run is set and dev_environment.github.setup names no step; a GitHub runner needs the steps that put the environment on it".to_owned(),
183 ),
184 Some("gitlab") if self.gitlab.image.is_empty() => Err(
185 "dev_environment.run is set and dev_environment.gitlab.image is empty; a GitLab job needs the image that carries the environment".to_owned(),
186 ),
187 _ => Ok(()),
188 }
189 }
190}
191
192fn check_run(run: &str) -> Result<(), String> {
196 one_line("dev_environment.run", run)?;
197 let starts = run
198 .chars()
199 .next()
200 .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_'));
201 if !starts || run.trim() != run || run.contains(" #") || run.contains(": ") {
202 return Err(format!(
203 "dev_environment.run is {run:?}; a prefix starts with a letter, a digit, '.', '/', or '_', has no surrounding space, and carries no ' #' and no ': '"
204 ));
205 }
206 Ok(())
207}
208
209fn check_step(key: &str, step: &ActionStep) -> Result<(), String> {
210 if !is_pinned(&step.uses) {
211 return Err(format!(
212 "{key}.uses is {:?}, which is not pinned to a full commit SHA; write owner/repo@<40 hex digits>",
213 step.uses
214 ));
215 }
216 if !step.tag.is_empty()
217 && !step
218 .tag
219 .chars()
220 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
221 {
222 return Err(format!(
223 "{key}.tag is {:?}; a tag is letters, digits, '.', '_', '-', and '/'",
224 step.tag
225 ));
226 }
227 for (name, value) in &step.inputs {
228 let named = !name.is_empty()
229 && name
230 .chars()
231 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
232 if !named {
233 return Err(format!(
234 "{key}.with names {name:?}; an input name is letters, digits, '_', and '-'"
235 ));
236 }
237 if let Input::Text(text) = value {
238 one_line(&format!("{key}.with.{name}"), text)?;
239 }
240 }
241 Ok(())
242}
243
244fn is_pinned(uses: &str) -> bool {
246 let Some((action, commit)) = uses.rsplit_once('@') else {
247 return false;
248 };
249 let mut parts = action.split('/');
250 let owner = parts.next().unwrap_or_default();
251 let repo = parts.next().unwrap_or_default();
252 let word = |part: &str| {
253 !part.is_empty()
254 && part
255 .chars()
256 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
257 };
258 word(owner)
259 && word(repo)
260 && parts.all(word)
261 && commit.len() == 40
262 && commit
263 .chars()
264 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
265}
266
267fn is_image(image: &str) -> bool {
268 image
269 .chars()
270 .next()
271 .is_some_and(|c| c.is_ascii_alphanumeric())
272 && image
273 .chars()
274 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@'))
275}
276
277fn one_line(key: &str, value: &str) -> Result<(), String> {
278 if value.trim().is_empty() || value.chars().any(char::is_control) {
279 return Err(format!(
280 "{key} is {value:?}; it is one non-empty line with no control character"
281 ));
282 }
283 Ok(())
284}
285
286#[cfg(test)]
287mod tests {
288 use super::{ActionStep, DevEnvironment, GithubSetup, GitlabSetup, Input, RkRoute};
289
290 const SHA: &str = "90bb610b90bf290cad97484ba341453bd1cbefea";
291
292 fn answer(uses: &str) -> DevEnvironment {
293 DevEnvironment {
294 run: "nix develop --command".to_owned(),
295 github: GithubSetup {
296 setup: vec![ActionStep {
297 uses: uses.to_owned(),
298 tag: "v19".to_owned(),
299 inputs: [("use-flakehub".to_owned(), Input::Bool(false))].into(),
300 }],
301 cache: Vec::new(),
302 },
303 gitlab: GitlabSetup::default(),
304 rk: RkRoute::Environment,
305 }
306 }
307
308 #[test]
309 fn a_pinned_answer_is_accepted() {
310 let uses = format!("DeterminateSystems/nix-installer-action@{SHA}");
311 assert_eq!(answer(&uses).check(), Ok(()));
312 assert_eq!(answer(&uses).check_forge(Some("github")), Ok(()));
313 }
314
315 #[test]
316 fn an_unpinned_setup_reference_refuses_by_name() {
317 for uses in [
318 "DeterminateSystems/nix-installer-action@v19",
319 "DeterminateSystems/nix-installer-action",
320 "nix-installer-action@90bb610b90bf290cad97484ba341453bd1cbefea",
321 "DeterminateSystems/nix-installer-action@90BB610B90BF290CAD97484BA341453BD1CBEFEA",
322 ] {
323 let refusal = answer(uses).check().expect_err(uses);
324 assert!(
325 refusal.contains("dev_environment.github.setup[0].uses")
326 && refusal.contains("full commit SHA"),
327 "{refusal}"
328 );
329 }
330 }
331
332 #[test]
333 fn a_run_that_breaks_its_line_refuses() {
334 for run in [
335 "",
336 " nix develop",
337 "nix develop # here",
338 "a: b",
339 "nix\ndevelop",
340 "-x",
341 ] {
342 let mut held = answer(&format!("o/r@{SHA}"));
343 held.run = run.to_owned();
344 assert!(held.check().is_err(), "{run:?}");
345 }
346 }
347
348 #[test]
349 fn a_run_with_no_forge_setup_refuses_on_that_forge() {
350 let mut held = answer(&format!("o/r@{SHA}"));
351 held.github.setup.clear();
352 assert!(
353 held.check_forge(Some("github"))
354 .is_err_and(|refusal| refusal.contains("dev_environment.github.setup"))
355 );
356 assert!(
357 held.check_forge(Some("gitlab"))
358 .is_err_and(|refusal| refusal.contains("dev_environment.gitlab.image"))
359 );
360 assert_eq!(held.check_forge(None), Ok(()));
361 }
362
363 #[test]
364 fn an_empty_run_states_no_answer() {
365 assert_eq!(DevEnvironment::default().check(), Ok(()));
366 assert_eq!(DevEnvironment::default().answered(), None);
367 let mut held = answer(&format!("o/r@{SHA}"));
368 held.run.clear();
369 assert!(held.check().is_err());
370 }
371}