Skip to main content

boxology_cli_core/
execute.rs

1//! Materialize one validated generation plan through the pure generator and atomic writer.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4
5use crate::GenerationPlan;
6use boxology_generator::{GeneratedTree, OUTPUTS};
7use boxology_generator_model::Diagnostics;
8use boxology_generator_model::GenerationRequest;
9use boxology_generator_writer::WriteError;
10use boxology_manifest::RelativePath;
11use std::{
12    fmt, fs, io,
13    path::{Path, PathBuf},
14};
15
16type Rule = (&'static str, &'static str, &'static str);
17const SOURCE: &str = "specs/s5-manifest-and-validation.md D5";
18const INFERRED_SOURCE: &str = "specs/s5-manifest-and-validation.md D5 (inferred)";
19const INPUT_TEXT: &str = "a generation input must be a readable regular file";
20const GENERATOR_TEXT: &str = "the contract generator returned diagnostics";
21const WRITER_TEXT: &str = "the generated tree could not be written";
22const COVERAGE_TEXT: &str = "a generator output is not covered by a declared output pattern";
23const SCHEMA_TEXT: &str = "the checked-in schema document must be a readable regular file";
24const INPUT: Rule = ("BXW0070", INPUT_TEXT, SOURCE);
25const GENERATOR: Rule = ("BXW0071", GENERATOR_TEXT, SOURCE);
26const WRITER: Rule = ("BXW0072", WRITER_TEXT, SOURCE);
27// This is an inference: D5 supplies the fixed generator output set and the plan supplies the
28// manifest output patterns, but does not state the implication that every fixed output is covered.
29const COVERAGE: Rule = ("BXW0073", COVERAGE_TEXT, INFERRED_SOURCE);
30// This is an inference: D5 requires reading the checked-in schema as classification base, and the
31// CLI's ingestion refuses symlinks and non-regular files with the same strength as BXW0070.
32const SCHEMA_FILE: Rule = ("BXW0076", SCHEMA_TEXT, INFERRED_SOURCE);
33
34/// The files changed by one accepted execution, in deterministic logical-path order.
35#[derive(Debug, Eq, PartialEq)]
36pub struct Outcome {
37    written: Vec<String>,
38    removed: Vec<String>,
39    base_schema: Option<Vec<u8>>,
40    submitted_schema: Vec<u8>,
41}
42
43impl Outcome {
44    /// Returns generated logical paths created or replaced by this execution.
45    pub fn written(&self) -> &[String] {
46        &self.written
47    }
48
49    /// Returns stale declared logical paths removed by this execution.
50    pub fn removed(&self) -> &[String] {
51        &self.removed
52    }
53
54    /// Reports whether no generated file was written and no stale file was removed.
55    pub fn is_unchanged(&self) -> bool {
56        self.written.is_empty() && self.removed.is_empty()
57    }
58
59    /// Returns the pre-write checked-in `generated/schema.json` bytes, when present.
60    pub fn base_schema(&self) -> Option<&[u8]> {
61        self.base_schema.as_deref()
62    }
63
64    /// Returns the regenerated `generated/schema.json` bytes from the in-memory tree.
65    pub fn submitted_schema(&self) -> &[u8] {
66        &self.submitted_schema
67    }
68}
69
70/// A stable execution failure with an optional verbatim generator or writer cause.
71#[derive(Debug)]
72pub struct ExecuteError {
73    code: &'static str,
74    location: PathBuf,
75    detail: &'static str,
76    cause: Cause,
77}
78
79#[derive(Debug)]
80enum Cause {
81    Input,
82    Generator(Diagnostics),
83    Writer(WriteError),
84    Coverage,
85    Schema,
86}
87
88impl ExecuteError {
89    /// Returns the stable `BXW####` code.
90    pub fn code(&self) -> &'static str {
91        self.code
92    }
93
94    /// Returns the filesystem location associated with the failure.
95    pub fn location(&self) -> &Path {
96        &self.location
97    }
98
99    /// Returns the filesystem location; this alias matches the earlier CLI error surfaces.
100    pub fn path(&self) -> &Path {
101        self.location()
102    }
103
104    /// Returns the stable static rule detail.
105    pub fn detail(&self) -> &'static str {
106        self.detail
107    }
108
109    /// Returns the generator diagnostics without changing their order or rendering.
110    pub fn diagnostics(&self) -> Option<&Diagnostics> {
111        match &self.cause {
112            Cause::Generator(diagnostics) => Some(diagnostics),
113            Cause::Input | Cause::Writer(_) | Cause::Coverage | Cause::Schema => None,
114        }
115    }
116
117    /// Returns the writer error without changing its payload or rendering.
118    pub fn write_error(&self) -> Option<&WriteError> {
119        match &self.cause {
120            Cause::Writer(error) => Some(error),
121            Cause::Input | Cause::Generator(_) | Cause::Coverage | Cause::Schema => None,
122        }
123    }
124
125    fn input(path: PathBuf) -> Self {
126        Self {
127            code: INPUT.0,
128            location: path,
129            detail: INPUT.1,
130            cause: Cause::Input,
131        }
132    }
133
134    fn generator(path: PathBuf, diagnostics: Diagnostics) -> Self {
135        Self {
136            code: GENERATOR.0,
137            location: path,
138            detail: GENERATOR.1,
139            cause: Cause::Generator(diagnostics),
140        }
141    }
142
143    fn writer(path: PathBuf, error: WriteError) -> Self {
144        Self {
145            code: WRITER.0,
146            location: path,
147            detail: WRITER.1,
148            cause: Cause::Writer(error),
149        }
150    }
151
152    fn coverage(path: PathBuf) -> Self {
153        Self {
154            code: COVERAGE.0,
155            location: path,
156            detail: COVERAGE.1,
157            cause: Cause::Coverage,
158        }
159    }
160
161    fn schema(path: PathBuf) -> Self {
162        Self {
163            code: SCHEMA_FILE.0,
164            location: path,
165            detail: SCHEMA_FILE.1,
166            cause: Cause::Schema,
167        }
168    }
169}
170
171impl fmt::Display for ExecuteError {
172    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(
174            formatter,
175            "{} {:?}: {}",
176            self.code, self.location, self.detail
177        )?;
178        match &self.cause {
179            Cause::Generator(diagnostics) => write!(formatter, ": {diagnostics}"),
180            Cause::Writer(error) => write!(formatter, ": {error}"),
181            Cause::Input | Cause::Coverage | Cause::Schema => Ok(()),
182        }
183    }
184}
185
186impl std::error::Error for ExecuteError {
187    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
188        match &self.cause {
189            Cause::Writer(error) => Some(error),
190            Cause::Input | Cause::Generator(_) | Cause::Coverage | Cause::Schema => None,
191        }
192    }
193}
194
195/// Lazy sequential multi-plan execution seam used by `boxology generate`.
196///
197/// Each plan is fully executed — live imports are read, the tree is generated, and declared
198/// outputs are written — before the next plan begins. Callers that need import-before-importer
199/// convergence must supply plans in that dependency order.
200///
201/// The iterator is terminal after its first execution error: once an `Err` is yielded, every later
202/// `next` call returns `None` and no later plan is executed or written.
203#[derive(Debug)]
204pub struct ExecutePlans<'a, I> {
205    root: &'a Path,
206    plans: I,
207    terminal: bool,
208}
209
210impl<'a, I> Iterator for ExecutePlans<'a, I>
211where
212    I: Iterator<Item = &'a GenerationPlan>,
213{
214    type Item = Result<(&'a GenerationPlan, Outcome), ExecuteError>;
215
216    fn next(&mut self) -> Option<Self::Item> {
217        if self.terminal {
218            return None;
219        }
220        let plan = self.plans.next()?;
221        match execute(self.root, plan) {
222            Ok(outcome) => Some(Ok((plan, outcome))),
223            Err(error) => {
224                self.terminal = true;
225                Some(Err(error))
226            }
227        }
228    }
229}
230
231/// Returns a lazy executor that runs `plans` in iterator order against `root`.
232///
233/// This is the canonical multi-plan sequential seam: each plan observes import bytes left on disk
234/// by earlier writes in the same pass. Production `boxology generate` and one-pass convergence
235/// proofs both drive this iterator so generate-all-before-write refactors cannot slip past them.
236/// After the first execution error the iterator is terminal and yields `None` forever, so later
237/// plans are neither executed nor written.
238pub fn execute_plans<'a, I>(root: &'a Path, plans: I) -> ExecutePlans<'a, I::IntoIter>
239where
240    I: IntoIterator<Item = &'a GenerationPlan>,
241{
242    ExecutePlans {
243        root,
244        plans: plans.into_iter(),
245        terminal: false,
246    }
247}
248
249/// Executes one plan against `root`, reading package inputs, generating its tree, and writing it.
250///
251/// Inputs are read in sorted logical-path order after refusing symlinks in every input ancestor;
252/// resolved imports follow in manifest declaration order. The package directory is `root` joined
253/// with the plan's validated package root.
254/// Pre-write capture records the checked-in schema bytes beside the regenerated tree bytes.
255///
256/// # Errors
257/// Returns `BXW0070` for a missing, unreadable, or non-regular input; `BXW0071` with generator
258/// diagnostics; `BXW0072` with the writer error; `BXW0073` when a fixed generator output is not
259/// covered by the plan's declared patterns; or `BXW0076` when the checked-in schema is present but
260/// not a readable regular file.
261pub fn execute(root: &Path, plan: &GenerationPlan) -> Result<Outcome, ExecuteError> {
262    let package_root = plan.package_root().map_or("", RelativePath::as_str);
263    let (package_dir, tree) = generate_tree(root, plan)?;
264    let submitted_schema = tree
265        .files()
266        .iter()
267        .find(|file| file.path() == package_schema_path(plan))
268        .expect("generator outputs include schema.json")
269        .bytes()
270        .to_vec();
271    let base_schema = read_base_schema(root, plan)?;
272    guarded(root, package_root, false)?;
273    let changes = boxology_generator_writer::write(&package_dir, &tree, plan.outputs())
274        .map_err(|error| ExecuteError::writer(package_dir, error))?;
275    let mut written = changes.written;
276    let mut removed = changes.removed;
277    written.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
278    removed.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
279    Ok(Outcome {
280        written,
281        removed,
282        base_schema,
283        submitted_schema,
284    })
285}
286
287pub(crate) fn generate_tree(
288    root: &Path,
289    plan: &GenerationPlan,
290) -> Result<(PathBuf, GeneratedTree), ExecuteError> {
291    let package_root = plan.package_root().map_or("", RelativePath::as_str);
292    let package_dir = guarded(root, package_root, false)?;
293    let mut input_paths = plan.inputs().to_vec();
294    input_paths.sort_unstable();
295    let guarded_inputs = input_paths
296        .into_iter()
297        .map(|input| guarded(&package_dir, input.as_str(), true).map(|path| (input, path)))
298        .collect::<Result<Vec<_>, ExecuteError>>()?;
299    let mut inputs = guarded_inputs
300        .into_iter()
301        .map(|(input, path)| {
302            let bytes = fs::read(&path).map_err(|_| ExecuteError::input(path))?;
303            Ok((input.as_str().to_owned(), bytes))
304        })
305        .collect::<Result<Vec<_>, ExecuteError>>()?;
306    let raw_imports = plan
307        .imports()
308        .iter()
309        .map(|import| {
310            (
311                import.package().clone(),
312                import.schema().as_str().to_owned(),
313            )
314        })
315        .collect::<Vec<_>>();
316    for import in plan.imports() {
317        let schema = import.schema().clone();
318        let path = guarded(root, schema.as_str(), true)?;
319        let bytes = fs::read(&path).map_err(|_| ExecuteError::input(path))?;
320        inputs.push((schema.as_str().to_owned(), bytes));
321    }
322    let request = GenerationRequest::new(
323        plan.package_id().clone(),
324        plan.crate_root().as_str().to_owned(),
325        inputs,
326        raw_imports,
327        OUTPUTS.iter().map(|path| (*path).to_owned()).collect(),
328    )
329    .map_err(|diagnostics| {
330        ExecuteError::generator(package_dir.join(plan.crate_root().as_str()), diagnostics)
331    })?;
332    let tree = boxology_generator::generate(request).map_err(|diagnostics| {
333        ExecuteError::generator(package_dir.join(plan.crate_root().as_str()), diagnostics)
334    })?;
335    for output in OUTPUTS {
336        let output =
337            RelativePath::new(output.to_owned()).expect("generator outputs are valid paths");
338        if !plan
339            .outputs()
340            .iter()
341            .any(|pattern| pattern.matches(&output))
342        {
343            return Err(ExecuteError::coverage(package_dir.join(output.as_str())));
344        }
345    }
346    Ok((package_dir, tree))
347}
348
349fn read_base_schema(root: &Path, plan: &GenerationPlan) -> Result<Option<Vec<u8>>, ExecuteError> {
350    read_optional_file(root, plan.schema_path())
351}
352
353pub(crate) fn missing_schema(root: &Path, plan: &GenerationPlan) -> ExecuteError {
354    ExecuteError::schema(root.join(plan.schema_path().as_str()))
355}
356
357pub(crate) fn read_optional_file(
358    root: &Path,
359    logical: &RelativePath,
360) -> Result<Option<Vec<u8>>, ExecuteError> {
361    let location = root.join(logical.as_str());
362    match fs::symlink_metadata(&location) {
363        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
364        Err(_) | Ok(_) => {
365            let path = guarded(root, logical.as_str(), true)
366                .map_err(|error| ExecuteError::schema(error.location().to_path_buf()))?;
367            let bytes = fs::read(&path).map_err(|_| ExecuteError::schema(path))?;
368            Ok(Some(bytes))
369        }
370    }
371}
372
373fn package_schema_path(plan: &GenerationPlan) -> &str {
374    plan.package_root().map_or_else(
375        || plan.schema_path().as_str(),
376        |root| {
377            plan.schema_path()
378                .as_str()
379                .strip_prefix(root.as_str())
380                .and_then(|path| path.strip_prefix('/'))
381                .expect("the plan's schema is inside its package root")
382        },
383    )
384}
385
386pub(crate) fn guarded(root: &Path, relative: &str, file: bool) -> Result<PathBuf, ExecuteError> {
387    let mut path = root.to_owned();
388    let mut parts = relative
389        .split('/')
390        .filter(|part| !part.is_empty())
391        .peekable();
392    loop {
393        let metadata =
394            fs::symlink_metadata(&path).map_err(|_| ExecuteError::input(path.clone()))?;
395        let accepted = if parts.peek().is_none() && file {
396            metadata.is_file()
397        } else {
398            metadata.is_dir()
399        };
400        if metadata.file_type().is_symlink() || !accepted {
401            return Err(ExecuteError::input(path));
402        }
403        let Some(part) = parts.next() else {
404            return Ok(path);
405        };
406        path.push(part);
407    }
408}