Skip to main content

boxology_cli_core/
runner.rs

1//! Trusted command runner for `boxology check` lock, fmt, Clippy, test, and quality steps.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use boxology_contract::BoxId;
6use boxology_manifest::RelativePath;
7use boxology_workspace::{Completion, Entry, Finding, Findings, Workspace};
8use std::{fmt, path::Path, process::Command};
9
10type Rule = (&'static str, &'static str, &'static str);
11const TOOL_SOURCE: &str = "boxology-details/08-rust-build-topology.md workspace operations and validation baseline; specs/s5-manifest-and-validation.md D6";
12const LOCK_SOURCE: &str = "boxology-details/08-rust-build-topology.md workspace operations and validation baseline step 4; specs/s5-manifest-and-validation.md D6";
13const QUALITY_SOURCE: &str = "boxology-details/08-rust-build-topology.md workspace operations and validation baseline step 8; specs/s5-manifest-and-validation.md D6";
14const FMT_TEXT: &str = "formatting check failed";
15const CLIPPY_TEXT: &str = "clippy check failed";
16const TESTS_TEXT: &str = "test check failed";
17const LOCK_TEXT: &str = "cargo graph and lockfile freshness check failed";
18const INVOKE_TEXT: &str = "a trusted check command could not be executed";
19const QUALITY_TEXT: &str = "a declared quality command failed";
20const FMT: Rule = ("BXW0093", FMT_TEXT, TOOL_SOURCE);
21const CLIPPY: Rule = ("BXW0094", CLIPPY_TEXT, TOOL_SOURCE);
22const TESTS: Rule = ("BXW0095", TESTS_TEXT, TOOL_SOURCE);
23const INVOKE: Rule = ("BXW0096", INVOKE_TEXT, TOOL_SOURCE);
24const LOCK: Rule = ("BXW0097", LOCK_TEXT, LOCK_SOURCE);
25const QUALITY: Rule = ("BXW0107", QUALITY_TEXT, QUALITY_SOURCE);
26
27/// Injectable trusted command: program name plus argv after the program.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct CommandSpec {
30    program: String,
31    args: Vec<String>,
32}
33
34impl CommandSpec {
35    /// Builds a specification from a program name and argument list.
36    pub fn new(
37        program: impl Into<String>,
38        args: impl IntoIterator<Item = impl Into<String>>,
39    ) -> Self {
40        Self {
41            program: program.into(),
42            args: args.into_iter().map(Into::into).collect(),
43        }
44    }
45
46    /// Returns the argument vector.
47    pub fn args(&self) -> &[String] {
48        &self.args
49    }
50
51    /// Renders the exact command line used in findings and tests.
52    pub fn render(&self) -> String {
53        std::iter::once(self.program.as_str())
54            .chain(self.args.iter().map(String::as_str))
55            .collect::<Vec<_>>()
56            .join(" ")
57    }
58}
59
60/// Captured stdout and stderr from one trusted command.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct CapturedOutput {
63    success: bool,
64    stdout: Vec<u8>,
65    stderr: Vec<u8>,
66}
67
68impl CapturedOutput {
69    /// Builds a captured result without spawning a process.
70    pub fn new(success: bool, stdout: impl Into<Vec<u8>>, stderr: impl Into<Vec<u8>>) -> Self {
71        Self {
72            success,
73            stdout: stdout.into(),
74            stderr: stderr.into(),
75        }
76    }
77
78    /// Reports whether the process exited successfully.
79    pub fn success(&self) -> bool {
80        self.success
81    }
82
83    /// Concatenates stdout then stderr for human rendering after findings.
84    pub fn combined(&self) -> Vec<u8> {
85        let mut bytes = self.stdout.clone();
86        bytes.extend_from_slice(&self.stderr);
87        bytes
88    }
89}
90
91/// The trusted command executable could not be started.
92#[derive(Debug, Eq, PartialEq)]
93pub struct SpawnError;
94
95impl fmt::Display for SpawnError {
96    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(formatter, "{} Cargo.toml: {}", INVOKE.0, INVOKE.1)
98    }
99}
100
101impl std::error::Error for SpawnError {}
102
103/// Outcome of one external check step: completion plus optional captured tool text.
104#[derive(Debug, Eq, PartialEq)]
105pub struct ToolStep {
106    completion: Completion,
107    output: Option<Vec<u8>>,
108}
109
110impl ToolStep {
111    /// Splits into completion and owned captured output.
112    pub fn into_parts(self) -> (Completion, Option<Vec<u8>>) {
113        (self.completion, self.output)
114    }
115}
116
117/// Runs `spec` at `root` through the host process API.
118pub fn run_command(root: &Path, spec: &CommandSpec) -> Result<CapturedOutput, SpawnError> {
119    let output = Command::new(&spec.program)
120        .args(&spec.args)
121        .current_dir(root)
122        .output()
123        .map_err(|_| SpawnError)?;
124    Ok(CapturedOutput::new(
125        output.status.success(),
126        output.stdout,
127        output.stderr,
128    ))
129}
130
131/// Injectable runner used by tool steps and tests.
132pub type CommandRunner = dyn Fn(&Path, &CommandSpec) -> Result<CapturedOutput, SpawnError>;
133
134/// Cargo package names selected for formatting: non-derived owned crate manifests only.
135pub fn fmt_packages(workspace: &Workspace) -> Vec<String> {
136    workspace
137        .cargo_members()
138        .iter()
139        .filter(|member| {
140            workspace
141                .classifications()
142                .iter()
143                .find(|classified| classified.path() == member.manifest_path())
144                .is_some_and(|classified| classified.derived_output().is_none())
145        })
146        .map(|member| member.cargo_package().to_owned())
147        .collect()
148}
149
150/// Builds `cargo fmt --check -p …` over the manifest-derived hand-authored selection.
151pub fn fmt_spec(workspace: &Workspace) -> Option<CommandSpec> {
152    let packages = fmt_packages(workspace);
153    if packages.is_empty() {
154        return None;
155    }
156    // Surface lock forbids `vec!`; keep an explicit push loop instead.
157    #[allow(clippy::vec_init_then_push)]
158    let args = {
159        let mut args = Vec::new();
160        args.push("fmt".to_owned());
161        args.push("--check".to_owned());
162        for package in packages {
163            args.push("-p".to_owned());
164            args.push(package);
165        }
166        args
167    };
168    Some(CommandSpec::new("cargo", args))
169}
170
171/// Builds denied-warning workspace Clippy matching the 08 baseline.
172pub fn clippy_spec() -> CommandSpec {
173    CommandSpec::new(
174        "cargo",
175        [
176            "clippy",
177            "--workspace",
178            "--all-targets",
179            "--all-features",
180            "--",
181            "-D",
182            "warnings",
183        ],
184    )
185}
186
187/// Builds workspace tests matching the 08 baseline.
188pub fn test_spec() -> CommandSpec {
189    CommandSpec::new("cargo", ["test", "--workspace", "--all-features"])
190}
191
192/// Builds full locked Cargo metadata matching the 08 baseline step-4 freshness check.
193pub fn lock_spec() -> CommandSpec {
194    CommandSpec::new("cargo", ["metadata", "--format-version", "1", "--locked"])
195}
196
197/// Runs formatting, or returns passed when the manifest-derived selection is empty.
198pub fn run_fmt_step(
199    runner: &CommandRunner,
200    root: &Path,
201    workspace: &Workspace,
202) -> Result<ToolStep, SpawnError> {
203    match fmt_spec(workspace) {
204        Some(spec) => run_tool_step(runner, root, &spec, FMT, "Cargo.toml"),
205        None => Ok(ToolStep {
206            completion: Completion::Passed,
207            output: None,
208        }),
209    }
210}
211
212/// Runs Clippy through the injectable runner.
213pub fn run_clippy_step(runner: &CommandRunner, root: &Path) -> Result<ToolStep, SpawnError> {
214    run_tool_step(runner, root, &clippy_spec(), CLIPPY, "Cargo.toml")
215}
216
217/// Runs workspace tests through the injectable runner.
218pub fn run_test_step(runner: &CommandRunner, root: &Path) -> Result<ToolStep, SpawnError> {
219    run_tool_step(runner, root, &test_spec(), TESTS, "Cargo.toml")
220}
221
222/// Runs full locked Cargo resolution through the injectable runner.
223pub fn run_lock_step(runner: &CommandRunner, root: &Path) -> Result<ToolStep, SpawnError> {
224    run_tool_step(runner, root, &lock_spec(), LOCK, "Cargo.lock")
225}
226
227/// One declared quality command bound to its package and manifest path.
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct QualityCommand {
230    package: BoxId,
231    manifest_path: RelativePath,
232    spec: CommandSpec,
233}
234
235impl QualityCommand {
236    /// Returns the package that declared the command.
237    pub fn package(&self) -> &BoxId {
238        &self.package
239    }
240
241    /// Returns the package manifest path used for findings.
242    pub fn manifest_path(&self) -> &RelativePath {
243        &self.manifest_path
244    }
245
246    /// Returns the split command specification.
247    pub fn spec(&self) -> &CommandSpec {
248        &self.spec
249    }
250}
251
252/// Flattens declared quality commands in package-id then declaration order.
253pub fn quality_specs(workspace: &Workspace) -> Vec<QualityCommand> {
254    // Surface lock forbids `vec!`; keep an explicit push loop instead.
255    #[allow(clippy::vec_init_then_push)]
256    {
257        let mut commands = Vec::new();
258        for package in workspace.packages() {
259            for command in package.manifest().quality_commands() {
260                let mut tokens = command.split_ascii_whitespace();
261                let program = tokens
262                    .next()
263                    .expect("manifest validation guarantees a nonblank quality command");
264                #[allow(clippy::vec_init_then_push)]
265                let args = {
266                    let mut args = Vec::new();
267                    for token in tokens {
268                        args.push(token.to_owned());
269                    }
270                    args
271                };
272                commands.push(QualityCommand {
273                    package: package.id().clone(),
274                    manifest_path: package.manifest_path().clone(),
275                    spec: CommandSpec::new(program, args),
276                });
277            }
278        }
279        commands
280    }
281}
282
283/// Runs every declared quality command, continuing after nonzero exits.
284pub fn run_quality_step(
285    runner: &CommandRunner,
286    root: &Path,
287    workspace: &Workspace,
288) -> Result<ToolStep, SpawnError> {
289    let commands = quality_specs(workspace);
290    if commands.is_empty() {
291        return Ok(ToolStep {
292            completion: Completion::Passed,
293            output: None,
294        });
295    }
296    #[allow(clippy::vec_init_then_push)]
297    let mut entries = Vec::new();
298    let mut output: Option<Vec<u8>> = None;
299    for command in &commands {
300        let captured = runner(root, command.spec())?;
301        if captured.success() {
302            continue;
303        }
304        let finding = Finding::external(
305            QUALITY.0,
306            QUALITY.1,
307            QUALITY.2,
308            command.manifest_path().clone(),
309            Some(command.package().clone()),
310            format!("command=\"{}\"", command.spec().render()),
311        );
312        entries.push(Entry::Workspace(finding));
313        let buffer = output.get_or_insert_with(Vec::new);
314        if !buffer.is_empty() && buffer.last() != Some(&b'\n') {
315            buffer.push(b'\n');
316        }
317        buffer.extend_from_slice(format!("command=\"{}\"\n", command.spec().render()).as_bytes());
318        buffer.extend_from_slice(&captured.combined());
319    }
320    match Findings::new(entries) {
321        None => Ok(ToolStep {
322            completion: Completion::Passed,
323            output: None,
324        }),
325        Some(findings) => Ok(ToolStep {
326            completion: Completion::Failed(findings),
327            output,
328        }),
329    }
330}
331
332fn run_tool_step(
333    runner: &CommandRunner,
334    root: &Path,
335    spec: &CommandSpec,
336    rule: Rule,
337    finding_path: &str,
338) -> Result<ToolStep, SpawnError> {
339    let captured = runner(root, spec)?;
340    if captured.success() {
341        return Ok(ToolStep {
342            completion: Completion::Passed,
343            output: None,
344        });
345    }
346    let finding = Finding::external(
347        rule.0,
348        rule.1,
349        rule.2,
350        RelativePath::new(finding_path).expect("tool finding path is a valid relative path"),
351        None,
352        format!("command=\"{}\"", spec.render()),
353    );
354    // Surface lock forbids `vec!`; clippy's vec_init_then_push is accepted for that reason.
355    #[allow(clippy::vec_init_then_push)]
356    let findings = {
357        let mut entries = Vec::new();
358        entries.push(Entry::Workspace(finding));
359        Findings::new(entries).expect("one finding is nonempty")
360    };
361    Ok(ToolStep {
362        completion: Completion::Failed(findings),
363        output: Some(captured.combined()),
364    })
365}