1#![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#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct CommandSpec {
30 program: String,
31 args: Vec<String>,
32}
33
34impl CommandSpec {
35 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 pub fn args(&self) -> &[String] {
48 &self.args
49 }
50
51 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#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct CapturedOutput {
63 success: bool,
64 stdout: Vec<u8>,
65 stderr: Vec<u8>,
66}
67
68impl CapturedOutput {
69 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 pub fn success(&self) -> bool {
80 self.success
81 }
82
83 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#[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#[derive(Debug, Eq, PartialEq)]
105pub struct ToolStep {
106 completion: Completion,
107 output: Option<Vec<u8>>,
108}
109
110impl ToolStep {
111 pub fn into_parts(self) -> (Completion, Option<Vec<u8>>) {
113 (self.completion, self.output)
114 }
115}
116
117pub 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
131pub type CommandRunner = dyn Fn(&Path, &CommandSpec) -> Result<CapturedOutput, SpawnError>;
133
134pub 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
150pub fn fmt_spec(workspace: &Workspace) -> Option<CommandSpec> {
152 let packages = fmt_packages(workspace);
153 if packages.is_empty() {
154 return None;
155 }
156 #[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
171pub 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
187pub fn test_spec() -> CommandSpec {
189 CommandSpec::new("cargo", ["test", "--workspace", "--all-features"])
190}
191
192pub fn lock_spec() -> CommandSpec {
194 CommandSpec::new("cargo", ["metadata", "--format-version", "1", "--locked"])
195}
196
197pub 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
212pub fn run_clippy_step(runner: &CommandRunner, root: &Path) -> Result<ToolStep, SpawnError> {
214 run_tool_step(runner, root, &clippy_spec(), CLIPPY, "Cargo.toml")
215}
216
217pub fn run_test_step(runner: &CommandRunner, root: &Path) -> Result<ToolStep, SpawnError> {
219 run_tool_step(runner, root, &test_spec(), TESTS, "Cargo.toml")
220}
221
222pub 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#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct QualityCommand {
230 package: BoxId,
231 manifest_path: RelativePath,
232 spec: CommandSpec,
233}
234
235impl QualityCommand {
236 pub fn package(&self) -> &BoxId {
238 &self.package
239 }
240
241 pub fn manifest_path(&self) -> &RelativePath {
243 &self.manifest_path
244 }
245
246 pub fn spec(&self) -> &CommandSpec {
248 &self.spec
249 }
250}
251
252pub fn quality_specs(workspace: &Workspace) -> Vec<QualityCommand> {
254 #[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
283pub 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 #[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}