1#![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#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct ResolvedImport {
39 package: BoxId,
40 schema: RelativePath,
41}
42impl ResolvedImport {
43 pub fn package(&self) -> &BoxId {
45 &self.package
46 }
47 pub fn schema(&self) -> &RelativePath {
49 &self.schema
50 }
51}
52
53#[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 pub fn package_id(&self) -> &BoxId {
69 &self.package
70 }
71 pub fn manifest_path(&self) -> &RelativePath {
73 &self.manifest_path
74 }
75 pub fn package_root(&self) -> Option<&RelativePath> {
77 self.package_root.as_ref()
78 }
79 pub fn derived_output_id(&self) -> &BoxId {
81 &self.derived_output
82 }
83 pub fn crate_root(&self) -> &RelativePath {
85 &self.crate_root
86 }
87 pub fn schema_path(&self) -> &RelativePath {
89 &self.schema_path
90 }
91 pub fn inputs(&self) -> &[RelativePath] {
93 &self.inputs
94 }
95 pub fn imports(&self) -> &[ResolvedImport] {
97 &self.imports
98 }
99 pub fn outputs(&self) -> &[GlobPattern] {
101 &self.outputs
102 }
103}
104#[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 pub fn code(&self) -> &'static str {
115 self.code
116 }
117 pub fn path(&self) -> &RelativePath {
119 &self.path
120 }
121 pub fn detail(&self) -> &'static str {
123 self.detail
124 }
125 pub fn source(&self) -> &'static str {
127 self.source
128 }
129
130 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 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 {}
159pub 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}
192fn 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}