Skip to main content

bsdkrun_sdk/
ci.rs

1//! CI workflows defined in code instead of YAML.
2//!
3//! The builder produces exactly the file `bsdkrun ci` (and tangled's spindle)
4//! consumes — [`CiWorkflow::yaml`] is that file, [`CiWorkflow::save`] commits
5//! it to `.tangled/workflows/`, and [`CiWorkflow::run`] executes it in a
6//! microVM without a file ever touching the repository:
7//!
8//! ```no_run
9//! use bsdkrun_sdk::ci;
10//!
11//! ci::workflow("test")
12//!     .on_push(["main"])
13//!     .deps(["rustc", "cargo"])
14//!     .env("CARGO_INCREMENTAL", "0")
15//!     .step("check", "cargo check")
16//!     .step("test", "cargo test")
17//!     .run()?;
18//! # Ok::<(), bsdkrun_sdk::Error>(())
19//! ```
20//!
21//! Code is the source of truth and YAML the wire format, in that order —
22//! which is why `save` writes a generated-file header: a hand-edit there will
23//! be overwritten by the next `save`.
24
25use std::collections::BTreeMap;
26use std::fmt::Write as _;
27use std::path::{Path, PathBuf};
28
29use crate::error::{Error, Result};
30
31/// Start a CI workflow definition.
32pub 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/// A workflow under construction. See the module docs for the shape.
46#[derive(Debug, Clone)]
47pub struct CiWorkflow {
48    name: String,
49    engine: String,
50    when: Vec<(Vec<String>, Vec<String>)>,
51    // BTreeMaps for deterministic output: the emitted YAML is committed and
52    // diffed, so its ordering must not depend on hash seeds.
53    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    /// Override the engine (`nixery` by default).
69    pub fn engine(mut self, engine: impl Into<String>) -> Self {
70        self.engine = engine.into();
71        self
72    }
73
74    /// Add a push trigger for the given branches.
75    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    /// Add a pull_request trigger targeting the given branches.
88    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    /// Add nixpkgs dependencies — the toolchain the steps run against.
101    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    /// Add dependencies from a custom registry (a flake reference).
114    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    /// Set a workflow-level environment variable.
127    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    /// Append a step. Steps run serially, in one VM, from the workspace root.
133    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    /// Append a step with step-scoped environment variables.
143    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    /// Set the clone depth (default 1).
158    pub fn clone_depth(mut self, depth: u32) -> Self {
159        self.clone_depth = Some(depth);
160        self
161    }
162
163    /// Skip the checkout entirely.
164    pub fn skip_clone(mut self) -> Self {
165        self.clone_skip = true;
166        self
167    }
168
169    /// The file name [`CiWorkflow::save`] writes: `<name>.yml`.
170    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    /// Render the workflow file.
179    ///
180    /// Scalars are emitted as JSON strings — valid YAML by construction —
181    /// and commands as literal blocks when safe, so the SDK needs no YAML
182    /// dependency.
183    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    /// Write the workflow into `<repo>/.tangled/workflows/`, where spindle
249    /// and `bsdkrun ci` both discover it. Returns the path.
250    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    /// Execute the workflow in a microVM against the current directory,
265    /// streaming output. The YAML never touches the repository — it goes to
266    /// a temp file and `bsdkrun ci run -f`.
267    pub fn run(&self) -> Result<()> {
268        self.run_in::<&str>(None)
269    }
270
271    /// [`CiWorkflow::run`] against an explicit repository directory.
272    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
302/// A literal block when it round-trips byte-for-byte; a JSON string when it
303/// cannot (trailing spaces, carriage returns) — never a silent alteration.
304fn 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
317/// A JSON string literal, which is a valid YAML scalar by construction.
318fn 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    // The YAML this builder emits is consumed by tangled's own workflow
343    // parser (inside `bsdkrun ci`), so these tests pin the emitted shape —
344    // a change here is a change to what spindle would receive.
345
346    #[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        // Trailing spaces do not survive a literal block scalar; the emitter
377        // must switch representation rather than silently altering the
378        // command.
379        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}