Skip to main content

boxology_cli_core/
generate.rs

1//! Pure selection and assembly of a contract-generation plan.
2#![deny(missing_docs)]
3#![forbid(unsafe_code)]
4use boxology_contract::BoxId;
5use boxology_manifest::{CrateRole, GlobPattern, RelativePath};
6use boxology_workspace::{Package, Workspace};
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10type Rule = (&'static str, &'static str, &'static str);
11const SOURCE: &str = "specs/s5-manifest-and-validation.md D5";
12const CONTRACT_GENERATOR: &str = "boxology-contract";
13const CARGO_GENERATOR: &str = "cargo";
14const UNKNOWN_GENERATOR_TEXT: &str =
15    "only the boxology-contract generator is supported by generate";
16const UNKNOWN_PACKAGE_TEXT: &str = "the requested package must be a discovered workspace package";
17const NO_CANDIDATE_TEXT: &str = "the selected package must declare a contract-generation output";
18const IMPLEMENTATION_ROOT_TEXT: &str =
19    "a generation candidate must declare exactly one box-implementation crate";
20const DUPLICATE_OUTPUTS_TEXT: &str =
21    "a package must declare at most one contract-generation output";
22const UNKNOWN_IMPORT_TEXT: &str = "a declared import must name a discovered workspace package";
23const NO_IMPORT_CANDIDATE_TEXT: &str =
24    "an imported package must declare a contract-generation output";
25const IMPORT_CYCLE_TEXT: &str = "generation candidates must not form an import cycle";
26const UNKNOWN_GENERATOR: Rule = ("BXW0064", UNKNOWN_GENERATOR_TEXT, SOURCE);
27const UNKNOWN_PACKAGE: Rule = ("BXW0065", UNKNOWN_PACKAGE_TEXT, SOURCE);
28const NO_CANDIDATE: Rule = ("BXW0066", NO_CANDIDATE_TEXT, SOURCE);
29const IMPLEMENTATION_ROOT: Rule = ("BXW0067", IMPLEMENTATION_ROOT_TEXT, SOURCE);
30const DUPLICATE_OUTPUTS: Rule = ("BXW0069", DUPLICATE_OUTPUTS_TEXT, SOURCE);
31const UNKNOWN_IMPORT: Rule = ("BXW0084", UNKNOWN_IMPORT_TEXT, SOURCE);
32const NO_IMPORT_CANDIDATE: Rule = ("BXW0085", NO_IMPORT_CANDIDATE_TEXT, SOURCE);
33const IMPORT_CYCLE: Rule = ("BXW0086", IMPORT_CYCLE_TEXT, SOURCE);
34const SCHEMA: &str = "generated/schema.json";
35
36/// One declared import resolved to the imported package's checked-in schema.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct ResolvedImport {
39    package: BoxId,
40    schema: RelativePath,
41}
42impl ResolvedImport {
43    /// Returns the imported package identity.
44    pub fn package(&self) -> &BoxId {
45        &self.package
46    }
47    /// Returns the workspace-relative path of the imported package's schema.
48    pub fn schema(&self) -> &RelativePath {
49        &self.schema
50    }
51}
52
53/// The pure inputs needed by the next generation-execution slice.
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct GenerationPlan {
56    package: BoxId,
57    manifest_path: RelativePath,
58    package_root: Option<RelativePath>,
59    derived_output: BoxId,
60    crate_root: RelativePath,
61    schema_path: RelativePath,
62    inputs: Vec<RelativePath>,
63    imports: Vec<ResolvedImport>,
64    outputs: Vec<GlobPattern>,
65}
66impl GenerationPlan {
67    /// Returns the selected package identity.
68    pub fn package_id(&self) -> &BoxId {
69        &self.package
70    }
71    /// Returns the workspace-relative package manifest path.
72    pub fn manifest_path(&self) -> &RelativePath {
73        &self.manifest_path
74    }
75    /// Returns the package root, or `None` for the workspace-root package.
76    pub fn package_root(&self) -> Option<&RelativePath> {
77        self.package_root.as_ref()
78    }
79    /// Returns the selected derived-output identity.
80    pub fn derived_output_id(&self) -> &BoxId {
81        &self.derived_output
82    }
83    /// Returns the exact package-relative implementation crate root.
84    pub fn crate_root(&self) -> &RelativePath {
85        &self.crate_root
86    }
87    /// Returns the canonical workspace-relative checked-in schema path.
88    pub fn schema_path(&self) -> &RelativePath {
89        &self.schema_path
90    }
91    /// Returns matching package-relative non-derived inputs in stable classification order.
92    pub fn inputs(&self) -> &[RelativePath] {
93        &self.inputs
94    }
95    /// Returns declared imports resolved in manifest declaration order.
96    pub fn imports(&self) -> &[ResolvedImport] {
97        &self.imports
98    }
99    /// Returns the selected output's declared patterns in declaration order.
100    pub fn outputs(&self) -> &[GlobPattern] {
101        &self.outputs
102    }
103}
104/// A stable planning failure with a validated logical path and no filesystem payload.
105#[derive(Debug, Eq, PartialEq)]
106pub struct PlanError {
107    code: &'static str,
108    path: RelativePath,
109    detail: &'static str,
110    source: &'static str,
111}
112impl PlanError {
113    /// Returns the stable `BXW####` code.
114    pub fn code(&self) -> &'static str {
115        self.code
116    }
117    /// Returns the validated workspace-relative location of the failure.
118    pub fn path(&self) -> &RelativePath {
119        &self.path
120    }
121    /// Returns the stable rule detail.
122    pub fn detail(&self) -> &'static str {
123        self.detail
124    }
125    /// Returns the normative source of the planning rule.
126    pub fn source(&self) -> &'static str {
127        self.source
128    }
129
130    /// Renders canonical `boxology.plan-error@1` JSON.
131    pub fn render_json(&self) -> String {
132        let quote = |value| serde_json::to_string(value).expect("a string always serializes");
133        format!(
134            "{{\n  \"schema\": \"boxology.plan-error@1\",\n  \"code\": {},\n  \"path\": {},\n  \"detail\": {},\n  \"source\": {}\n}}\n",
135            quote(self.code),
136            quote(self.path.as_str()),
137            quote(self.detail),
138            quote(self.source),
139        )
140    }
141
142    /// Returns whether this is the invocation-level unknown-package failure.
143    pub fn is_unknown_package(&self) -> bool {
144        self.code == UNKNOWN_PACKAGE.0
145    }
146}
147impl fmt::Display for PlanError {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(
150            formatter,
151            "{} {:?}: {}",
152            self.code,
153            self.path.as_str(),
154            self.detail
155        )
156    }
157}
158impl std::error::Error for PlanError {}
159/// Selects contract-generator candidates and assembles their pure plans in import-dependency order.
160pub fn plan(
161    workspace: &Workspace,
162    selection: Option<&BoxId>,
163) -> Result<Vec<GenerationPlan>, PlanError> {
164    let selected = selection
165        .map(|id| {
166            workspace
167                .packages()
168                .iter()
169                .find(|package| package.id() == id)
170                .ok_or_else(|| failure(UNKNOWN_PACKAGE, request_path()))
171        })
172        .transpose()?;
173    let mut plans = Vec::new();
174    for package in workspace.packages() {
175        if selected.is_some_and(|wanted| wanted.id() != package.id()) {
176            continue;
177        }
178        let candidates = contract_outputs(package)?;
179        if candidates.is_empty() {
180            if selected.is_some() {
181                return Err(failure(NO_CANDIDATE, package.manifest_path().clone()));
182            }
183            continue;
184        }
185        if candidates.len() > 1 {
186            return Err(failure(DUPLICATE_OUTPUTS, package.manifest_path().clone()));
187        }
188        plans.push(assemble(workspace, package, candidates[0])?);
189    }
190    order_plans(plans)
191}
192/// Orders plans so each import target precedes its importers; package-id breaks ties.
193fn order_plans(plans: Vec<GenerationPlan>) -> Result<Vec<GenerationPlan>, PlanError> {
194    let mut by_id: BTreeMap<BoxId, GenerationPlan> = plans
195        .into_iter()
196        .map(|plan| (plan.package_id().clone(), plan))
197        .collect();
198    let ids: BTreeSet<_> = by_id.keys().cloned().collect();
199    let mut indegree: BTreeMap<BoxId, usize> = ids.iter().map(|id| (id.clone(), 0)).collect();
200    let mut dependents: BTreeMap<BoxId, Vec<BoxId>> = BTreeMap::new();
201    for (id, plan) in &by_id {
202        for import in plan.imports() {
203            let target = import.package();
204            if !ids.contains(target) {
205                continue;
206            }
207            *indegree.get_mut(id).expect("indegree covers every plan") += 1;
208            dependents
209                .entry(target.clone())
210                .or_default()
211                .push(id.clone());
212        }
213    }
214    let mut ready: BTreeSet<BoxId> = indegree
215        .iter()
216        .filter(|(_, degree)| **degree == 0)
217        .map(|(id, _)| id.clone())
218        .collect();
219    let mut ordered = Vec::with_capacity(by_id.len());
220    while let Some(id) = ready.pop_first() {
221        if let Some(next) = dependents.get(&id) {
222            for dependent in next {
223                let degree = indegree
224                    .get_mut(dependent)
225                    .expect("dependents are plan identities");
226                *degree -= 1;
227                if *degree == 0 {
228                    ready.insert(dependent.clone());
229                }
230            }
231        }
232        ordered.push(by_id.remove(&id).expect("ready identity is a plan"));
233    }
234    if let Some((_, plan)) = by_id.into_iter().next() {
235        return Err(failure(IMPORT_CYCLE, plan.manifest_path().clone()));
236    }
237    Ok(ordered)
238}
239fn contract_outputs(
240    package: &Package,
241) -> Result<Vec<&boxology_manifest::DerivedOutput>, PlanError> {
242    let mut candidates = Vec::new();
243    for output in package.manifest().derived() {
244        if output.generator() == CARGO_GENERATOR {
245            continue;
246        }
247        if output.generator() == CONTRACT_GENERATOR {
248            candidates.push(output);
249        } else {
250            return Err(failure(UNKNOWN_GENERATOR, package.manifest_path().clone()));
251        }
252    }
253    Ok(candidates)
254}
255fn assemble(
256    workspace: &Workspace,
257    package: &Package,
258    output: &boxology_manifest::DerivedOutput,
259) -> Result<GenerationPlan, PlanError> {
260    let implementations: Vec<_> = package
261        .manifest()
262        .crates()
263        .iter()
264        .filter(|entry| entry.role() == CrateRole::BoxImplementation)
265        .collect();
266    if implementations.len() != 1 {
267        return Err(failure(
268            IMPLEMENTATION_ROOT,
269            package.manifest_path().clone(),
270        ));
271    }
272    let imports = package
273        .manifest()
274        .imports()
275        .iter()
276        .map(|import| {
277            let Some(target) = workspace
278                .packages()
279                .iter()
280                .find(|target| target.id() == import.package())
281            else {
282                return Err(failure(UNKNOWN_IMPORT, package.manifest_path().clone()));
283            };
284            let candidates = contract_outputs(target)?;
285            if candidates.is_empty() {
286                return Err(failure(
287                    NO_IMPORT_CANDIDATE,
288                    package.manifest_path().clone(),
289                ));
290            }
291            if candidates.len() > 1 {
292                return Err(failure(DUPLICATE_OUTPUTS, target.manifest_path().clone()));
293            }
294            Ok(ResolvedImport {
295                package: import.package().clone(),
296                schema: schema_path(target),
297            })
298        })
299        .collect::<Result<Vec<_>, PlanError>>()?;
300    let raw_root = implementations[0].path().nested().map_or_else(
301        || "src/lib.rs".to_owned(),
302        |path| format!("{}/src/lib.rs", path.as_str()),
303    );
304    let Some(crate_root) = RelativePath::new(raw_root).ok() else {
305        return Err(failure(
306            IMPLEMENTATION_ROOT,
307            package.manifest_path().clone(),
308        ));
309    };
310    let inputs = workspace
311        .classifications()
312        .iter()
313        .filter(|classification| {
314            classification.package() == package.id() && classification.derived_output().is_none()
315        })
316        .filter_map(|classification| {
317            let path = package.relative(classification.path())?;
318            output
319                .inputs()
320                .iter()
321                .any(|input| input.matches(&path))
322                .then_some(path)
323        })
324        .collect();
325    let schema_path = schema_path(package);
326    Ok(GenerationPlan {
327        package: package.id().clone(),
328        manifest_path: package.manifest_path().clone(),
329        package_root: package.root().cloned(),
330        derived_output: output.id().clone(),
331        crate_root,
332        schema_path,
333        inputs,
334        imports,
335        outputs: output.outputs().to_vec(),
336    })
337}
338
339fn schema_path(package: &Package) -> RelativePath {
340    let path = package.root().map_or_else(
341        || SCHEMA.to_owned(),
342        |root| format!("{}/{}", root.as_str(), SCHEMA),
343    );
344    RelativePath::new(path).expect("fixed schema path is valid")
345}
346fn request_path() -> RelativePath {
347    RelativePath::new("<request>").expect("static request path is valid")
348}
349
350fn failure(rule: Rule, path: RelativePath) -> PlanError {
351    PlanError {
352        code: rule.0,
353        path,
354        detail: rule.1,
355        source: rule.2,
356    }
357}