Skip to main content

sui_spec/
cli.rs

1//! Typed builders for `sui` and `nix` CLI invocations.
2//!
3//! Two consumers today ([`crate::probe::Probe`] and
4//! [`crate::rebuild::RebuildProbe`]), with a third already in the
5//! roadmap (any future `Probe`-shaped domain).  Extracting the
6//! canonical invocation surface here means:
7//!
8//! 1. The `--extra-experimental-features "nix-command flakes"`
9//!    incantation lives in exactly one place.  When cppnix changes
10//!    its CLI conventions, one edit covers every probe.
11//! 2. The sui side gets a symmetric typed border — invocations
12//!    always reach `sui` via the same builders, so the differential
13//!    sweep is comparing apples to apples by construction.
14//! 3. A new probe domain implements its `ParityCheck::sui_invocation`
15//!    / `nix_invocation` by composing these primitives, not by
16//!    hand-writing `Command::args(...)`.
17//!
18//! NO SHELL — every argument is added via typed [`Command`] APIs.
19//! No shell escaping concerns because nothing ever goes through a
20//! shell.
21
22use std::path::Path;
23use std::process::Command;
24
25/// Canonical experimental-features set required by every modern nix
26/// CLI invocation that touches flakes or new commands.  Kept as a
27/// const so it's textually obvious in the generated argv.
28pub const NIX_EXPERIMENTAL_FEATURES: &str = "nix-command flakes";
29
30/// Build a `nix` invocation for `subcommand` with the canonical
31/// experimental-features prelude already in place.  Caller adds
32/// any subcommand-specific args after.
33#[must_use]
34fn nix_base(nix_bin: &Path, subcommand: &[&str]) -> Command {
35    let mut cmd = Command::new(nix_bin);
36    cmd.args(subcommand);
37    cmd.args(["--extra-experimental-features", NIX_EXPERIMENTAL_FEATURES]);
38    cmd
39}
40
41/// Build a `sui` invocation for `subcommand`.  Sui doesn't need an
42/// experimental-features prelude (flakes + new CLI are default), so
43/// the base just hangs the subcommand off the binary.
44#[must_use]
45fn sui_base(sui_bin: &Path, subcommand: &[&str]) -> Command {
46    let mut cmd = Command::new(sui_bin);
47    cmd.args(subcommand);
48    cmd
49}
50
51/// Canonical nix CLI invocations.  Every builder returns a
52/// pre-configured [`Command`] ready for `spawn()` (with whatever
53/// stdio + env the caller layers on).
54pub mod nix_cli {
55    use super::*;
56
57    /// `nix eval --impure --json --expr <expr>` — evaluate a literal
58    /// expression and emit a JSON value on stdout.
59    #[must_use]
60    pub fn eval_expr(nix_bin: &Path, expr: &str) -> Command {
61        let mut cmd = nix_base(nix_bin, &["eval", "--impure", "--json"]);
62        cmd.args(["--expr", expr]);
63        cmd
64    }
65
66    /// `nix eval --impure --json <installable>` — evaluate an
67    /// installable (a flake-attribute reference).
68    #[must_use]
69    pub fn eval_installable(nix_bin: &Path, installable: &str) -> Command {
70        let mut cmd = nix_base(nix_bin, &["eval", "--impure", "--json"]);
71        cmd.arg(installable);
72        cmd
73    }
74
75    /// `nix flake show --json <flake-ref>` — print the flake's
76    /// output inventory.
77    #[must_use]
78    pub fn flake_show(nix_bin: &Path, flake_ref: &str) -> Command {
79        let mut cmd = nix_base(nix_bin, &["flake", "show", "--json"]);
80        cmd.arg(flake_ref);
81        cmd
82    }
83
84    /// `nix flake check <flake-ref>` — type-check the flake.
85    #[must_use]
86    pub fn flake_check(nix_bin: &Path, flake_ref: &str) -> Command {
87        let mut cmd = nix_base(nix_bin, &["flake", "check"]);
88        cmd.arg(flake_ref);
89        cmd
90    }
91
92    /// `nix build --dry-run --print-out-paths --no-link <installable>`
93    /// — print the would-be-built out paths without realising
94    /// derivations.
95    #[must_use]
96    pub fn build_dry_run(nix_bin: &Path, installable: &str) -> Command {
97        let mut cmd = nix_base(
98            nix_bin,
99            &["build", "--dry-run", "--print-out-paths", "--no-link"],
100        );
101        cmd.arg(installable);
102        cmd
103    }
104}
105
106/// Canonical sui CLI invocations — symmetric with [`nix_cli`].
107pub mod sui_cli {
108    use super::*;
109
110    /// `sui eval --json <expr>` — evaluate a literal expression as
111    /// positional installable (sui treats expressions and installables
112    /// uniformly through this path).
113    #[must_use]
114    pub fn eval_expr(sui_bin: &Path, expr: &str) -> Command {
115        let mut cmd = sui_base(sui_bin, &["eval", "--json"]);
116        cmd.arg(expr);
117        cmd
118    }
119
120    /// `sui eval --json <installable>` — evaluate a flake-attribute
121    /// installable.
122    #[must_use]
123    pub fn eval_installable(sui_bin: &Path, installable: &str) -> Command {
124        let mut cmd = sui_base(sui_bin, &["eval", "--json"]);
125        cmd.arg(installable);
126        cmd
127    }
128
129    /// `sui eval --impure --json --expr <expr>` — explicit `--expr`
130    /// for cases where the input might otherwise be parsed as a
131    /// path-style installable.
132    #[must_use]
133    pub fn eval_expr_explicit(sui_bin: &Path, expr: &str) -> Command {
134        let mut cmd = sui_base(sui_bin, &["eval", "--impure", "--json"]);
135        cmd.args(["--expr", expr]);
136        cmd
137    }
138
139    /// `sui flake show --json <flake-ref>`.
140    #[must_use]
141    pub fn flake_show(sui_bin: &Path, flake_ref: &str) -> Command {
142        let mut cmd = sui_base(sui_bin, &["flake", "show", "--json"]);
143        cmd.arg(flake_ref);
144        cmd
145    }
146
147    /// `sui flake check <flake-ref>`.
148    #[must_use]
149    pub fn flake_check(sui_bin: &Path, flake_ref: &str) -> Command {
150        let mut cmd = sui_base(sui_bin, &["flake", "check"]);
151        cmd.arg(flake_ref);
152        cmd
153    }
154
155    /// `sui build --dry-run --print-out-paths --no-link <installable>`.
156    #[must_use]
157    pub fn build_dry_run(sui_bin: &Path, installable: &str) -> Command {
158        let mut cmd = sui_base(
159            sui_bin,
160            &["build", "--dry-run", "--print-out-paths", "--no-link"],
161        );
162        cmd.arg(installable);
163        cmd
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::exec::command_argv;
171    use std::path::PathBuf;
172
173    fn nix_path() -> PathBuf {
174        PathBuf::from("/usr/local/bin/nix")
175    }
176
177    fn sui_path() -> PathBuf {
178        PathBuf::from("/usr/local/bin/sui")
179    }
180
181    #[test]
182    fn nix_eval_expr_includes_experimental_features() {
183        let cmd = nix_cli::eval_expr(&nix_path(), "1 + 2");
184        let argv = command_argv(&cmd);
185        assert_eq!(argv[0], "/usr/local/bin/nix");
186        assert!(argv.iter().any(|a| a == "--extra-experimental-features"));
187        assert!(argv.iter().any(|a| a == NIX_EXPERIMENTAL_FEATURES));
188        assert!(argv.iter().any(|a| a == "--expr"));
189        assert!(argv.iter().any(|a| a == "1 + 2"));
190        assert!(argv.iter().any(|a| a == "--impure"));
191        assert!(argv.iter().any(|a| a == "--json"));
192    }
193
194    #[test]
195    fn nix_flake_show_includes_experimental_features_and_ref() {
196        let cmd = nix_cli::flake_show(&nix_path(), "path:/tmp/myflake");
197        let argv = command_argv(&cmd);
198        assert!(argv.windows(2).any(|w| w == ["flake", "show"]));
199        assert!(argv.iter().any(|a| a == "--json"));
200        assert!(argv.iter().any(|a| a == "--extra-experimental-features"));
201        assert!(argv.iter().any(|a| a == "path:/tmp/myflake"));
202    }
203
204    #[test]
205    fn nix_build_dry_run_carries_all_flags() {
206        let cmd = nix_cli::build_dry_run(&nix_path(), "path:/tmp/f#x.y");
207        let argv = command_argv(&cmd);
208        for required in ["--dry-run", "--print-out-paths", "--no-link"] {
209            assert!(
210                argv.iter().any(|a| a == required),
211                "build_dry_run missing {required}; argv: {argv:?}",
212            );
213        }
214    }
215
216    #[test]
217    fn sui_eval_expr_is_positional() {
218        let cmd = sui_cli::eval_expr(&sui_path(), "1 + 2");
219        let argv = command_argv(&cmd);
220        assert_eq!(argv[0], "/usr/local/bin/sui");
221        assert_eq!(argv[1], "eval");
222        assert_eq!(argv[2], "--json");
223        assert_eq!(argv[3], "1 + 2");
224        // sui doesn't need experimental-features
225        assert!(!argv.iter().any(|a| a == "--extra-experimental-features"));
226    }
227
228    #[test]
229    fn sui_eval_expr_explicit_uses_dash_dash_expr() {
230        let cmd = sui_cli::eval_expr_explicit(&sui_path(), "1 + 2");
231        let argv = command_argv(&cmd);
232        assert!(argv.iter().any(|a| a == "--expr"));
233        assert!(argv.iter().any(|a| a == "1 + 2"));
234        assert!(argv.iter().any(|a| a == "--impure"));
235    }
236
237    #[test]
238    fn sui_flake_show_and_check() {
239        let show = sui_cli::flake_show(&sui_path(), "path:/tmp/f");
240        let check = sui_cli::flake_check(&sui_path(), "path:/tmp/f");
241        let show_argv = command_argv(&show);
242        let check_argv = command_argv(&check);
243        assert!(show_argv.windows(2).any(|w| w == ["flake", "show"]));
244        assert!(check_argv.windows(2).any(|w| w == ["flake", "check"]));
245        assert!(show_argv.iter().any(|a| a == "--json"));
246        assert!(!check_argv.iter().any(|a| a == "--json"));  // check is exit-code only
247    }
248
249    #[test]
250    fn symmetric_signatures() {
251        // For every nix_cli builder there's a sui_cli sibling with
252        // the same shape — this property is what lets a ParityCheck
253        // impl ride on a single dispatch.  We test by argv length
254        // parity for canonical inputs.
255        let p = "path:/tmp/f";
256        let n = nix_cli::flake_show(&nix_path(), p);
257        let s = sui_cli::flake_show(&sui_path(), p);
258        // Both must include the flake ref and `--json`.
259        let na = command_argv(&n);
260        let sa = command_argv(&s);
261        assert!(na.iter().any(|a| a == p) && sa.iter().any(|a| a == p));
262        assert!(na.iter().any(|a| a == "--json") && sa.iter().any(|a| a == "--json"));
263    }
264}