1use std::collections::BTreeMap;
26use std::fmt::Write as _;
27use std::path::{Path, PathBuf};
28
29use crate::error::{Error, Result};
30
31pub fn workflow(name: impl Into<String>) -> CiWorkflow {
33 CiWorkflow {
34 name: name.into(),
35 engine: "nixery".to_string(),
36 when: Vec::new(),
37 deps: BTreeMap::new(),
38 env: BTreeMap::new(),
39 steps: Vec::new(),
40 clone_depth: None,
41 clone_skip: false,
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct CiWorkflow {
48 name: String,
49 engine: String,
50 when: Vec<(Vec<String>, Vec<String>)>,
51 deps: BTreeMap<String, Vec<String>>,
54 env: BTreeMap<String, String>,
55 steps: Vec<CiStep>,
56 clone_depth: Option<u32>,
57 clone_skip: bool,
58}
59
60#[derive(Debug, Clone)]
61struct CiStep {
62 name: String,
63 command: String,
64 env: BTreeMap<String, String>,
65}
66
67impl CiWorkflow {
68 pub fn engine(mut self, engine: impl Into<String>) -> Self {
70 self.engine = engine.into();
71 self
72 }
73
74 pub fn on_push<I, S>(mut self, branches: I) -> Self
76 where
77 I: IntoIterator<Item = S>,
78 S: Into<String>,
79 {
80 self.when.push((
81 vec!["push".into()],
82 branches.into_iter().map(Into::into).collect(),
83 ));
84 self
85 }
86
87 pub fn on_pull_request<I, S>(mut self, branches: I) -> Self
89 where
90 I: IntoIterator<Item = S>,
91 S: Into<String>,
92 {
93 self.when.push((
94 vec!["pull_request".into()],
95 branches.into_iter().map(Into::into).collect(),
96 ));
97 self
98 }
99
100 pub fn deps<I, S>(mut self, packages: I) -> Self
102 where
103 I: IntoIterator<Item = S>,
104 S: Into<String>,
105 {
106 self.deps
107 .entry("nixpkgs".into())
108 .or_default()
109 .extend(packages.into_iter().map(Into::into));
110 self
111 }
112
113 pub fn deps_from<I, S>(mut self, registry: impl Into<String>, packages: I) -> Self
115 where
116 I: IntoIterator<Item = S>,
117 S: Into<String>,
118 {
119 self.deps
120 .entry(registry.into())
121 .or_default()
122 .extend(packages.into_iter().map(Into::into));
123 self
124 }
125
126 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
128 self.env.insert(key.into(), value.into());
129 self
130 }
131
132 pub fn step(mut self, name: impl Into<String>, command: impl Into<String>) -> Self {
134 self.steps.push(CiStep {
135 name: name.into(),
136 command: command.into(),
137 env: BTreeMap::new(),
138 });
139 self
140 }
141
142 pub fn step_env(
144 mut self,
145 name: impl Into<String>,
146 command: impl Into<String>,
147 env: impl IntoIterator<Item = (String, String)>,
148 ) -> Self {
149 self.steps.push(CiStep {
150 name: name.into(),
151 command: command.into(),
152 env: env.into_iter().collect(),
153 });
154 self
155 }
156
157 pub fn clone_depth(mut self, depth: u32) -> Self {
159 self.clone_depth = Some(depth);
160 self
161 }
162
163 pub fn skip_clone(mut self) -> Self {
165 self.clone_skip = true;
166 self
167 }
168
169 pub fn file_name(&self) -> String {
171 if self.name.ends_with(".yml") || self.name.ends_with(".yaml") {
172 self.name.clone()
173 } else {
174 format!("{}.yml", self.name)
175 }
176 }
177
178 pub fn yaml(&self) -> String {
184 let mut out = String::new();
185
186 if !self.when.is_empty() {
187 out.push_str("when:\n");
188 for (events, branches) in &self.when {
189 let evs: Vec<String> = events.iter().map(|e| json_str(e)).collect();
190 let _ = writeln!(out, " - event: [{}]", evs.join(", "));
191 match branches.len() {
192 0 => {}
193 1 => {
194 let _ = writeln!(out, " branch: {}", json_str(&branches[0]));
195 }
196 _ => {
197 let bs: Vec<String> = branches.iter().map(|b| json_str(b)).collect();
198 let _ = writeln!(out, " branch: [{}]", bs.join(", "));
199 }
200 }
201 }
202 out.push('\n');
203 }
204
205 let _ = writeln!(out, "engine: {}", self.engine);
206
207 if !self.deps.is_empty() {
208 out.push_str("\ndependencies:\n");
209 for (reg, pkgs) in &self.deps {
210 let _ = writeln!(out, " {}:", json_str(reg));
211 for p in pkgs {
212 let _ = writeln!(out, " - {}", json_str(p));
213 }
214 }
215 }
216
217 if !self.env.is_empty() {
218 out.push_str("\nenvironment:\n");
219 for (k, v) in &self.env {
220 let _ = writeln!(out, " {k}: {}", json_str(v));
221 }
222 }
223
224 if self.clone_skip || self.clone_depth.is_some() {
225 out.push_str("\nclone:\n");
226 if self.clone_skip {
227 out.push_str(" skip: true\n");
228 }
229 if let Some(d) = self.clone_depth {
230 let _ = writeln!(out, " depth: {d}");
231 }
232 }
233
234 out.push_str("\nsteps:\n");
235 for s in &self.steps {
236 let _ = writeln!(out, " - name: {}", json_str(&s.name));
237 write_command(&mut out, &s.command);
238 if !s.env.is_empty() {
239 out.push_str(" environment:\n");
240 for (k, v) in &s.env {
241 let _ = writeln!(out, " {k}: {}", json_str(v));
242 }
243 }
244 }
245 out
246 }
247
248 pub fn save(&self, repo: impl AsRef<Path>) -> Result<PathBuf> {
251 let dir = repo.as_ref().join(".tangled").join("workflows");
252 std::fs::create_dir_all(&dir)?;
253 let path = dir.join(self.file_name());
254 std::fs::write(
255 &path,
256 format!(
257 "# Generated by the bsdkrun SDK — edit the code that save()d it instead.\n{}",
258 self.yaml()
259 ),
260 )?;
261 Ok(path)
262 }
263
264 pub fn run(&self) -> Result<()> {
268 self.run_in::<&str>(None)
269 }
270
271 pub fn run_in<P: AsRef<Path>>(&self, dir: Option<P>) -> Result<()> {
273 let tmp = std::env::temp_dir().join(format!("bsdkrun-ci-{}", std::process::id()));
274 std::fs::create_dir_all(&tmp)?;
275 let file = tmp.join(self.file_name());
276 std::fs::write(&file, self.yaml())?;
277
278 let mut args: Vec<String> = vec![
279 "ci".into(),
280 "run".into(),
281 "-f".into(),
282 file.display().to_string(),
283 ];
284 if let Some(d) = &dir {
285 args.push("-w".into());
286 args.push(d.as_ref().display().to_string());
287 }
288 let code = crate::process::spawn(&args)?;
289 let _ = std::fs::remove_dir_all(&tmp);
290 if code != 0 {
291 return Err(Error::CommandFailed {
292 command: format!("bsdkrun ci run ({})", self.name),
293 exit_code: code,
294 stdout: String::new(),
295 stderr: format!("workflow {} failed", self.name),
296 });
297 }
298 Ok(())
299 }
300}
301
302fn write_command(out: &mut String, cmd: &str) {
305 let block_safe =
306 !cmd.is_empty() && !cmd.contains('\r') && cmd.lines().all(|l| l == l.trim_end_matches(' '));
307 if !block_safe {
308 let _ = writeln!(out, " command: {}", json_str(cmd));
309 return;
310 }
311 out.push_str(" command: |\n");
312 for line in cmd.trim_end_matches('\n').lines() {
313 let _ = writeln!(out, " {line}");
314 }
315}
316
317fn json_str(s: &str) -> String {
319 let mut out = String::with_capacity(s.len() + 2);
320 out.push('"');
321 for c in s.chars() {
322 match c {
323 '"' => out.push_str("\\\""),
324 '\\' => out.push_str("\\\\"),
325 '\n' => out.push_str("\\n"),
326 '\r' => out.push_str("\\r"),
327 '\t' => out.push_str("\\t"),
328 c if (c as u32) < 0x20 => {
329 let _ = write!(out, "\\u{:04x}", c as u32);
330 }
331 c => out.push(c),
332 }
333 }
334 out.push('"');
335 out
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
347 fn renders_the_full_workflow_shape() {
348 let y = workflow("test")
349 .on_push(["main"])
350 .on_pull_request(["main", "develop"])
351 .deps(["rustc", "cargo"])
352 .deps_from("github:nix-community/fenix/abc123", ["stable.default"])
353 .env("CARGO_INCREMENTAL", "0")
354 .clone_depth(100)
355 .step("check", "cargo check")
356 .step_env(
357 "test",
358 "cargo test",
359 [("RUST_BACKTRACE".to_string(), "1".to_string())],
360 )
361 .yaml();
362
363 assert!(y.contains(" - event: [\"push\"]\n branch: \"main\""));
364 assert!(y.contains("branch: [\"main\", \"develop\"]"));
365 assert!(y.contains("engine: nixery"));
366 assert!(y.contains("\"nixpkgs\":\n - \"rustc\"\n - \"cargo\""));
367 assert!(y.contains("\"github:nix-community/fenix/abc123\":"));
368 assert!(y.contains("CARGO_INCREMENTAL: \"0\""));
369 assert!(y.contains("depth: 100"));
370 assert!(y.contains("- name: \"check\"\n command: |\n cargo check"));
371 assert!(y.contains("environment:\n RUST_BACKTRACE: \"1\""));
372 }
373
374 #[test]
375 fn block_unsafe_commands_fall_back_to_json() {
376 let y = workflow("edge").step("tricky", "echo 'a' \necho b").yaml();
380 assert!(y.contains("command: \"echo 'a' \\necho b\""), "{y}");
381 }
382
383 #[test]
384 fn file_names_get_the_yml_suffix() {
385 assert_eq!(workflow("build").file_name(), "build.yml");
386 assert_eq!(workflow("build.yaml").file_name(), "build.yaml");
387 }
388}