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}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(deny_unknown_fields, default)]
34pub struct GithubSetup {
35 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub setup: Vec<ActionStep>,
38 #[serde(skip_serializing_if = "Vec::is_empty")]
42 pub cache: Vec<ActionStep>,
43}
44
45impl GithubSetup {
46 const fn is_empty(&self) -> bool {
47 self.setup.is_empty() && self.cache.is_empty()
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
53#[serde(deny_unknown_fields, default)]
54pub struct GitlabSetup {
55 #[serde(skip_serializing_if = "String::is_empty")]
57 pub image: String,
58 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub before_script: Vec<String>,
61}
62
63impl GitlabSetup {
64 const fn is_empty(&self) -> bool {
65 self.image.is_empty() && self.before_script.is_empty()
66 }
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
71#[serde(deny_unknown_fields, default)]
72pub struct ActionStep {
73 pub uses: String,
75 #[serde(skip_serializing_if = "String::is_empty")]
77 pub tag: String,
78 #[serde(rename = "with", skip_serializing_if = "BTreeMap::is_empty")]
80 pub inputs: BTreeMap<String, Input>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
85#[serde(untagged)]
86pub enum Input {
87 Bool(bool),
89 Integer(i64),
91 Text(String),
93}
94
95impl Input {
96 #[must_use]
99 pub fn yaml(&self) -> String {
100 match self {
101 Self::Bool(value) => value.to_string(),
102 Self::Integer(value) => value.to_string(),
103 Self::Text(value) => serde_json::Value::String(value.clone()).to_string(),
104 }
105 }
106}
107
108impl DevEnvironment {
109 #[must_use]
112 pub fn answered(self) -> Option<Self> {
113 (!self.run.is_empty()).then_some(self)
114 }
115
116 pub fn check(&self) -> Result<(), String> {
121 if self.run.is_empty() {
122 if self.github.is_empty() && self.gitlab.is_empty() {
123 return Ok(());
124 }
125 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());
126 }
127 check_run(&self.run)?;
128 for (list, steps) in [("setup", &self.github.setup), ("cache", &self.github.cache)] {
129 for (at, step) in steps.iter().enumerate() {
130 check_step(&format!("dev_environment.github.{list}[{at}]"), step)?;
131 }
132 }
133 if !self.gitlab.image.is_empty() && !is_image(&self.gitlab.image) {
134 return Err(format!(
135 "dev_environment.gitlab.image is {:?}, which is no image reference; write one such as nixos/nix:2.35.2",
136 self.gitlab.image
137 ));
138 }
139 for (at, line) in self.gitlab.before_script.iter().enumerate() {
140 one_line(&format!("dev_environment.gitlab.before_script[{at}]"), line)?;
141 }
142 Ok(())
143 }
144
145 pub fn check_forge(&self, forge: Option<&str>) -> Result<(), String> {
151 match forge {
152 Some("github") if self.github.setup.is_empty() => Err(
153 "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(),
154 ),
155 Some("gitlab") if self.gitlab.image.is_empty() => Err(
156 "dev_environment.run is set and dev_environment.gitlab.image is empty; a GitLab job needs the image that carries the environment".to_owned(),
157 ),
158 _ => Ok(()),
159 }
160 }
161}
162
163fn check_run(run: &str) -> Result<(), String> {
167 one_line("dev_environment.run", run)?;
168 let starts = run
169 .chars()
170 .next()
171 .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_'));
172 if !starts || run.trim() != run || run.contains(" #") || run.contains(": ") {
173 return Err(format!(
174 "dev_environment.run is {run:?}; a prefix starts with a letter, a digit, '.', '/', or '_', has no surrounding space, and carries no ' #' and no ': '"
175 ));
176 }
177 Ok(())
178}
179
180fn check_step(key: &str, step: &ActionStep) -> Result<(), String> {
181 if !is_pinned(&step.uses) {
182 return Err(format!(
183 "{key}.uses is {:?}, which is not pinned to a full commit SHA; write owner/repo@<40 hex digits>",
184 step.uses
185 ));
186 }
187 if !step.tag.is_empty()
188 && !step
189 .tag
190 .chars()
191 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
192 {
193 return Err(format!(
194 "{key}.tag is {:?}; a tag is letters, digits, '.', '_', '-', and '/'",
195 step.tag
196 ));
197 }
198 for (name, value) in &step.inputs {
199 let named = !name.is_empty()
200 && name
201 .chars()
202 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
203 if !named {
204 return Err(format!(
205 "{key}.with names {name:?}; an input name is letters, digits, '_', and '-'"
206 ));
207 }
208 if let Input::Text(text) = value {
209 one_line(&format!("{key}.with.{name}"), text)?;
210 }
211 }
212 Ok(())
213}
214
215fn is_pinned(uses: &str) -> bool {
217 let Some((action, commit)) = uses.rsplit_once('@') else {
218 return false;
219 };
220 let mut parts = action.split('/');
221 let owner = parts.next().unwrap_or_default();
222 let repo = parts.next().unwrap_or_default();
223 let word = |part: &str| {
224 !part.is_empty()
225 && part
226 .chars()
227 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
228 };
229 word(owner)
230 && word(repo)
231 && parts.all(word)
232 && commit.len() == 40
233 && commit
234 .chars()
235 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
236}
237
238fn is_image(image: &str) -> bool {
239 image
240 .chars()
241 .next()
242 .is_some_and(|c| c.is_ascii_alphanumeric())
243 && image
244 .chars()
245 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@'))
246}
247
248fn one_line(key: &str, value: &str) -> Result<(), String> {
249 if value.trim().is_empty() || value.chars().any(char::is_control) {
250 return Err(format!(
251 "{key} is {value:?}; it is one non-empty line with no control character"
252 ));
253 }
254 Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259 use super::{ActionStep, DevEnvironment, GithubSetup, GitlabSetup, Input};
260
261 const SHA: &str = "90bb610b90bf290cad97484ba341453bd1cbefea";
262
263 fn answer(uses: &str) -> DevEnvironment {
264 DevEnvironment {
265 run: "nix develop --command".to_owned(),
266 github: GithubSetup {
267 setup: vec![ActionStep {
268 uses: uses.to_owned(),
269 tag: "v19".to_owned(),
270 inputs: [("use-flakehub".to_owned(), Input::Bool(false))].into(),
271 }],
272 cache: Vec::new(),
273 },
274 gitlab: GitlabSetup::default(),
275 }
276 }
277
278 #[test]
279 fn a_pinned_answer_is_accepted() {
280 let uses = format!("DeterminateSystems/nix-installer-action@{SHA}");
281 assert_eq!(answer(&uses).check(), Ok(()));
282 assert_eq!(answer(&uses).check_forge(Some("github")), Ok(()));
283 }
284
285 #[test]
286 fn an_unpinned_setup_reference_refuses_by_name() {
287 for uses in [
288 "DeterminateSystems/nix-installer-action@v19",
289 "DeterminateSystems/nix-installer-action",
290 "nix-installer-action@90bb610b90bf290cad97484ba341453bd1cbefea",
291 "DeterminateSystems/nix-installer-action@90BB610B90BF290CAD97484BA341453BD1CBEFEA",
292 ] {
293 let refusal = answer(uses).check().expect_err(uses);
294 assert!(
295 refusal.contains("dev_environment.github.setup[0].uses")
296 && refusal.contains("full commit SHA"),
297 "{refusal}"
298 );
299 }
300 }
301
302 #[test]
303 fn a_run_that_breaks_its_line_refuses() {
304 for run in [
305 "",
306 " nix develop",
307 "nix develop # here",
308 "a: b",
309 "nix\ndevelop",
310 "-x",
311 ] {
312 let mut held = answer(&format!("o/r@{SHA}"));
313 held.run = run.to_owned();
314 assert!(held.check().is_err(), "{run:?}");
315 }
316 }
317
318 #[test]
319 fn a_run_with_no_forge_setup_refuses_on_that_forge() {
320 let mut held = answer(&format!("o/r@{SHA}"));
321 held.github.setup.clear();
322 assert!(
323 held.check_forge(Some("github"))
324 .is_err_and(|refusal| refusal.contains("dev_environment.github.setup"))
325 );
326 assert!(
327 held.check_forge(Some("gitlab"))
328 .is_err_and(|refusal| refusal.contains("dev_environment.gitlab.image"))
329 );
330 assert_eq!(held.check_forge(None), Ok(()));
331 }
332
333 #[test]
334 fn an_empty_run_states_no_answer() {
335 assert_eq!(DevEnvironment::default().check(), Ok(()));
336 assert_eq!(DevEnvironment::default().answered(), None);
337 let mut held = answer(&format!("o/r@{SHA}"));
338 held.run.clear();
339 assert!(held.check().is_err());
340 }
341}