1#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use crate::{
6 GenerationPlan, PlanError,
7 execute::{ExecuteError, generate_tree, guarded, read_optional_file},
8 plan,
9};
10use boxology_contract::BoxId;
11use boxology_generator::GeneratedTree;
12use boxology_manifest::RelativePath;
13use boxology_workspace::{Completion, SelectedSchema, Workspace};
14use std::{fmt, fs, io, path::Path};
15
16type Rule = (&'static str, &'static str, &'static str);
17const COMPARE_SOURCE: &str = "specs/s5-manifest-and-validation.md D6; boxology-details/08-rust-build-topology.md workspace operations and validation baseline step 2";
18const COMPARE_TEXT: &str = "a checked-in derived artifact must be byte-identical to regeneration; regenerate the accountable package with boxology generate --package <id>";
19const REGENERATION: Rule = ("BXW0083", COMPARE_TEXT, COMPARE_SOURCE);
20
21#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
23pub enum DifferenceKind {
24 Missing,
26 Differing,
28 Stale,
30}
31impl DifferenceKind {
32 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Missing => "missing",
36 Self::Differing => "differing",
37 Self::Stale => "stale",
38 }
39 }
40}
41
42#[derive(Debug, Eq, PartialEq)]
44pub struct CompareDifference {
45 package: BoxId,
46 path: RelativePath,
47 kind: DifferenceKind,
48}
49
50impl CompareDifference {
51 pub fn package(&self) -> &BoxId {
53 &self.package
54 }
55
56 pub fn path(&self) -> &RelativePath {
58 &self.path
59 }
60
61 pub fn kind(&self) -> DifferenceKind {
63 self.kind
64 }
65
66 pub fn code(&self) -> &'static str {
68 REGENERATION.0
69 }
70
71 pub fn detail(&self) -> &'static str {
73 REGENERATION.1
74 }
75
76 pub fn repair_command(&self) -> String {
78 format!("boxology generate --package {}", self.package.as_str())
79 }
80
81 pub fn rule_source(&self) -> &'static str {
83 COMPARE_SOURCE
84 }
85}
86
87#[derive(Debug)]
89pub enum CompareStepError {
90 Plan(PlanError),
92 Execute(ExecuteError),
94}
95
96impl From<PlanError> for CompareStepError {
97 fn from(error: PlanError) -> Self {
98 Self::Plan(error)
99 }
100}
101
102impl From<ExecuteError> for CompareStepError {
103 fn from(error: ExecuteError) -> Self {
104 Self::Execute(error)
105 }
106}
107
108impl fmt::Display for CompareStepError {
109 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110 match self {
111 Self::Plan(error) => error.fmt(formatter),
112 Self::Execute(error) => error.fmt(formatter),
113 }
114 }
115}
116
117impl std::error::Error for CompareStepError {
118 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
119 match self {
120 Self::Plan(error) => Some(error),
121 Self::Execute(error) => Some(error),
122 }
123 }
124}
125
126pub fn compare_step(
136 root: &Path,
137 workspace: &Workspace,
138) -> Result<Vec<CompareDifference>, CompareStepError> {
139 let plans = plan(workspace, None)?;
140 compare_plans(root, workspace, &plans)
141}
142
143pub fn compare_plans(
148 root: &Path,
149 workspace: &Workspace,
150 plans: &[GenerationPlan],
151) -> Result<Vec<CompareDifference>, CompareStepError> {
152 let mut differences = Vec::new();
153 for plan in plans {
154 let package_root = plan.package_root().map_or("", RelativePath::as_str);
155 let package_dir = guarded(root, package_root, false)?;
156 guarded(&package_dir, plan.crate_root().as_str(), true)?;
157 let (package_dir, tree) = generate_tree(root, plan)?;
158 let generated = generated_paths(&tree);
159 for (path, file) in generated.iter().zip(tree.files()) {
160 match checked_in(&package_dir, path) {
161 CheckedIn::Missing => differences.push(difference(
162 plan.package_id(),
163 path.clone(),
164 DifferenceKind::Missing,
165 )),
166 CheckedIn::Unreadable => differences.push(difference(
167 plan.package_id(),
168 path.clone(),
169 DifferenceKind::Differing,
170 )),
171 CheckedIn::Bytes(bytes) if bytes != file.bytes() => differences.push(difference(
172 plan.package_id(),
173 path.clone(),
174 DifferenceKind::Differing,
175 )),
176 CheckedIn::Bytes(_) => {}
177 }
178 }
179 for classification in workspace.classifications().iter().filter(|classification| {
180 classification.package() == plan.package_id()
181 && classification.derived_output() == Some(plan.derived_output_id())
182 }) {
183 let Some(path) = package_relative(plan, classification.path()) else {
184 continue;
185 };
186 if !generated.contains(&path) {
187 differences.push(difference(plan.package_id(), path, DifferenceKind::Stale));
188 }
189 }
190 }
191 differences.sort_by(|left, right| {
192 left.package
193 .cmp(&right.package)
194 .then_with(|| left.path.cmp(&right.path))
195 });
196 Ok(differences)
197}
198
199fn package_relative(plan: &GenerationPlan, path: &RelativePath) -> Option<RelativePath> {
200 let Some(root) = plan.package_root() else {
201 return Some(path.clone());
202 };
203 let relative = path
204 .as_str()
205 .strip_prefix(root.as_str())?
206 .strip_prefix('/')?;
207 RelativePath::new(relative.to_owned()).ok()
208}
209
210pub fn composition_step(
215 root: &Path,
216 workspace: &Workspace,
217 plans: &[GenerationPlan],
218) -> Result<Completion, ExecuteError> {
219 let schemas = plans
220 .iter()
221 .map(|plan| {
222 read_optional_file(root, plan.schema_path()).map(|bytes| {
223 SelectedSchema::new(plan.package_id().clone(), plan.schema_path().clone(), bytes)
224 })
225 })
226 .collect::<Result<Vec<_>, _>>()?;
227 Ok(match workspace.check_compositions(&schemas) {
228 Ok(()) => Completion::Passed,
229 Err(findings) => Completion::Failed(findings),
230 })
231}
232
233fn generated_paths(tree: &GeneratedTree) -> Vec<RelativePath> {
234 tree.files()
235 .iter()
236 .map(|file| {
237 RelativePath::new(file.path().to_owned())
238 .expect("generator outputs are valid relative paths")
239 })
240 .collect()
241}
242
243fn difference(package: &BoxId, path: RelativePath, kind: DifferenceKind) -> CompareDifference {
244 CompareDifference {
245 package: package.clone(),
246 path,
247 kind,
248 }
249}
250
251enum CheckedIn {
252 Missing,
253 Unreadable,
254 Bytes(Vec<u8>),
255}
256
257fn checked_in(package_dir: &Path, path: &RelativePath) -> CheckedIn {
258 let location = package_dir.join(path.as_str());
259 match fs::symlink_metadata(&location) {
260 Err(error) if error.kind() == io::ErrorKind::NotFound => CheckedIn::Missing,
261 Err(_) => CheckedIn::Unreadable,
262 Ok(_) => match guarded(package_dir, path.as_str(), true)
263 .ok()
264 .and_then(|path| fs::read(path).ok())
265 {
266 Some(bytes) => CheckedIn::Bytes(bytes),
267 None => CheckedIn::Unreadable,
268 },
269 }
270}