Skip to main content

boxology_cli_core/
compare.rs

1//! Compare checked-in derived artifacts with an in-memory regeneration.
2#![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/// Why one checked-in derived artifact does not match regeneration.
22#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
23pub enum DifferenceKind {
24    /// No checked-in file exists at a regenerated path.
25    Missing,
26    /// Checked-in bytes differ, or the checked-in path cannot be read.
27    Differing,
28    /// A classified derived-output file has no regenerated counterpart.
29    Stale,
30}
31impl DifferenceKind {
32    /// Returns the stable human and report-payload identifier.
33    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/// One package-relative difference between checked-in and regenerated derived output.
43#[derive(Debug, Eq, PartialEq)]
44pub struct CompareDifference {
45    package: BoxId,
46    path: RelativePath,
47    kind: DifferenceKind,
48}
49
50impl CompareDifference {
51    /// Returns the accountable package identity.
52    pub fn package(&self) -> &BoxId {
53        &self.package
54    }
55
56    /// Returns the package-relative differing path.
57    pub fn path(&self) -> &RelativePath {
58        &self.path
59    }
60
61    /// Returns why the path differs from regeneration.
62    pub fn kind(&self) -> DifferenceKind {
63        self.kind
64    }
65
66    /// Returns the stable compare code.
67    pub fn code(&self) -> &'static str {
68        REGENERATION.0
69    }
70
71    /// Returns the stable rule detail.
72    pub fn detail(&self) -> &'static str {
73        REGENERATION.1
74    }
75
76    /// Returns the exact command that repairs this package's generated output.
77    pub fn repair_command(&self) -> String {
78        format!("boxology generate --package {}", self.package.as_str())
79    }
80
81    /// Returns the normative source of the byte-identity comparison rule.
82    pub fn rule_source(&self) -> &'static str {
83        COMPARE_SOURCE
84    }
85}
86
87/// A planning or generation failure while comparing derived output.
88#[derive(Debug)]
89pub enum CompareStepError {
90    /// The workspace could not produce a generation plan.
91    Plan(PlanError),
92    /// The generation inputs or generator could not produce an in-memory tree.
93    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
126/// Compares every contract-generation plan with its checked-in derived files.
127///
128/// Regeneration is kept in memory. A checked-in path is read through the same ancestor guard used
129/// by execution; an existing path that cannot be read fails closed as [`DifferenceKind::Differing`].
130///
131/// # Errors
132/// Returns [`CompareStepError::Plan`] for planning failures or [`CompareStepError::Execute`] for
133/// generation failures. Filesystem read failures for classified artifacts are differences rather
134/// than ingestion errors.
135pub 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
143/// Compares already-planned contract generations with their checked-in derived files.
144///
145/// This is the check command's reuse seam: composition validation and regeneration comparison
146/// consume the same plans, so canonical schema paths cannot drift between the two steps.
147pub 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
210/// Reads every plan-authoritative checked-in schema and validates all compositions.
211///
212/// A missing schema remains ordinary validation data. Existing paths are read only after the
213/// execution boundary's ancestor guard accepts them; guard and read failures remain fatal.
214pub 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}