spec_driven_docs/release/
legacy.rs1use std::collections::BTreeMap;
17
18use serde::Deserialize;
19
20use crate::domain::ownership::Sha256;
21use crate::domain::projection::{DECLARATION_PATH, Declaration};
22use crate::error::AppError;
23
24pub static INDEX_TOML: &str = include_str!("../../release-compat/index.toml");
26
27static DESCRIPTORS: &[(&str, &str)] = &[
29 (
30 "0.6.6",
31 include_str!("../../release-compat/legacy/0.6.6.toml"),
32 ),
33 (
34 "0.7.0",
35 include_str!("../../release-compat/legacy/0.7.0.toml"),
36 ),
37 (
38 "0.7.1",
39 include_str!("../../release-compat/legacy/0.7.1.toml"),
40 ),
41 (
42 "0.7.2",
43 include_str!("../../release-compat/legacy/0.7.2.toml"),
44 ),
45 (
46 "0.8.0",
47 include_str!("../../release-compat/legacy/0.8.0.toml"),
48 ),
49 (
50 "0.8.1",
51 include_str!("../../release-compat/legacy/0.8.1.toml"),
52 ),
53];
54
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct CatalogEntry {
59 pub version: String,
61 pub checksum: String,
63 pub eligible: bool,
65 #[serde(default)]
67 pub reason: Option<String>,
68 #[serde(default)]
70 pub descriptor: Option<String>,
71 #[serde(default)]
73 pub descriptor_sha256: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct CatalogIndex {
80 pub schema: u32,
82 pub audited_on: String,
84 pub capability_floor: String,
86 pub releases: Vec<CatalogEntry>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
92#[serde(deny_unknown_fields)]
93pub struct Descriptor {
94 pub version: String,
96 pub payload_roots: Vec<String>,
98 pub compatibility: crate::plan::compatibility::Compatibility,
100 pub projection: Declaration,
102}
103
104#[derive(Debug, Clone)]
106pub struct Adapted {
107 pub payload_schema: u32,
109 pub descriptor_sha256: Sha256,
111 pub metadata: BTreeMap<String, Vec<u8>>,
113}
114
115pub const PRE_SCHEMA: u32 = 0;
117
118#[derive(Debug, Clone)]
120pub struct LegacyCatalog {
121 index: CatalogIndex,
122 descriptors: BTreeMap<String, String>,
123}
124
125impl LegacyCatalog {
126 #[expect(
135 clippy::expect_used,
136 reason = "the catalog is compiled in; a parse failure is a build defect the canon suite catches first"
137 )]
138 #[must_use]
139 pub fn embedded() -> Self {
140 Self {
141 index: toml::from_str(INDEX_TOML).expect("the embedded release catalog parses"),
142 descriptors: DESCRIPTORS
143 .iter()
144 .map(|(version, text)| ((*version).to_string(), (*text).to_string()))
145 .collect(),
146 }
147 }
148
149 #[must_use]
151 pub fn entry(&self, version: &str) -> Option<&CatalogEntry> {
152 self.index
153 .releases
154 .iter()
155 .find(|entry| entry.version == version)
156 }
157
158 #[must_use]
160 pub const fn index(&self) -> &CatalogIndex {
161 &self.index
162 }
163
164 #[must_use]
166 pub const fn descriptors(&self) -> &BTreeMap<String, String> {
167 &self.descriptors
168 }
169
170 pub fn descriptor(&self, version: &str) -> Result<(Descriptor, Sha256), AppError> {
177 let entry = self.entry(version).ok_or_else(|| {
178 AppError::Refused(format!(
179 "{version} is not in the release catalog, and it declares no projection of its own, so this engine cannot say what it lands"
180 ))
181 })?;
182 if !entry.eligible {
183 let reason = entry.reason.as_deref().unwrap_or("it was not audited");
184 return Err(AppError::Refused(format!(
185 "{version} is unavailable as a destination: {reason}. The lowest release this engine reads is {}",
186 self.index.capability_floor
187 )));
188 }
189 let name = entry.descriptor.as_deref().ok_or_else(|| {
190 AppError::Refused(format!("{version} is eligible and names no descriptor"))
191 })?;
192 let text = self
193 .descriptors
194 .get(version)
195 .ok_or_else(|| AppError::Refused(format!("this engine does not carry {name}")))?;
196 let digest = Sha256::of(text.as_bytes());
197 let recorded = entry.descriptor_sha256.as_deref().unwrap_or_default();
198 if digest.as_str() != recorded {
199 return Err(AppError::Refused(format!(
200 "the descriptor for {version} hashes to {digest} and the catalog records {recorded}"
201 )));
202 }
203 let held: Descriptor = toml::from_str(text)
204 .map_err(|source| AppError::Refused(format!("{name} does not parse: {source}")))?;
205 if held.version != version {
206 return Err(AppError::Refused(format!(
207 "{name} describes {} and the catalog files it under {version}",
208 held.version
209 )));
210 }
211 Ok((held, digest))
212 }
213
214 pub fn adapt(
222 &self,
223 version: &str,
224 files: &BTreeMap<String, Vec<u8>>,
225 ) -> Result<Adapted, AppError> {
226 let (descriptor, descriptor_sha256) = self.descriptor(version)?;
227 for root in &descriptor.payload_roots {
228 let prefix = format!("{root}/");
229 if !files.keys().any(|path| path.starts_with(&prefix)) {
230 return Err(AppError::Refused(format!(
231 "the descriptor for {version} names the root {root}, and the published archive carries no file under it"
232 )));
233 }
234 }
235 for entry in descriptor
236 .projection
237 .managed
238 .iter()
239 .chain(&descriptor.projection.adopted)
240 {
241 if !files.contains_key(&entry.source) {
242 return Err(AppError::Refused(format!(
243 "the descriptor for {version} projects {}, and the published archive does not carry it",
244 entry.source
245 )));
246 }
247 }
248 let rendered = toml::to_string_pretty(&descriptor.projection).map_err(|source| {
249 AppError::Refused(format!(
250 "the descriptor for {version} did not render as a declaration: {source}"
251 ))
252 })?;
253 let compatibility =
257 toml::to_string_pretty(&descriptor.compatibility).map_err(|source| {
258 AppError::Refused(format!(
259 "the descriptor for {version} did not render its compatibility: {source}"
260 ))
261 })?;
262 Ok(Adapted {
263 payload_schema: PRE_SCHEMA,
264 descriptor_sha256,
265 metadata: BTreeMap::from([
266 (DECLARATION_PATH.to_string(), rendered.into_bytes()),
267 (
268 crate::plan::compatibility::DECLARATION_PATH.to_string(),
269 compatibility.into_bytes(),
270 ),
271 ]),
272 })
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 #![allow(
279 clippy::unwrap_used,
280 reason = "a test panics as its failure signal, not as control flow"
281 )]
282
283 use super::*;
284
285 #[test]
286 fn every_eligible_release_has_one_descriptor_and_no_other_does() {
287 let catalog = LegacyCatalog::embedded();
288 for entry in &catalog.index().releases {
289 if entry.eligible {
290 assert!(
291 entry.descriptor.is_some(),
292 "{} names no descriptor",
293 entry.version
294 );
295 assert!(
296 catalog.descriptors().contains_key(&entry.version),
297 "{} is eligible and not carried",
298 entry.version
299 );
300 } else {
301 assert!(
302 entry.descriptor.is_none(),
303 "{} is ineligible and named one",
304 entry.version
305 );
306 assert!(
307 !catalog.descriptors().contains_key(&entry.version),
308 "{} is ineligible and carried",
309 entry.version
310 );
311 assert!(
312 entry.reason.is_some(),
313 "{} refuses without a reason",
314 entry.version
315 );
316 }
317 }
318 assert_eq!(
319 catalog.descriptors().len(),
320 catalog
321 .index()
322 .releases
323 .iter()
324 .filter(|e| e.eligible)
325 .count()
326 );
327 }
328
329 #[test]
330 fn no_version_appears_twice() {
331 let catalog = LegacyCatalog::embedded();
332 let mut seen: Vec<&str> = Vec::new();
333 for entry in &catalog.index().releases {
334 assert!(
335 !seen.contains(&entry.version.as_str()),
336 "{} twice",
337 entry.version
338 );
339 seen.push(&entry.version);
340 }
341 }
342
343 #[test]
344 fn release_0_6_5_is_unavailable_and_0_6_6_is_the_capability_floor() {
345 let catalog = LegacyCatalog::embedded();
346 assert_eq!(catalog.index().capability_floor, "0.6.6");
347 let error = catalog.descriptor("0.6.5").unwrap_err();
348 assert!(error.to_string().contains("unavailable"), "{error}");
349 assert!(error.to_string().contains("instance/seeds"), "{error}");
350 assert!(catalog.descriptor("0.6.6").is_ok());
351 }
352
353 #[test]
354 fn an_uncataloged_version_is_never_a_candidate() {
355 let error = LegacyCatalog::embedded().descriptor("9.9.9").unwrap_err();
356 assert!(
357 error.to_string().contains("not in the release catalog"),
358 "{error}"
359 );
360 }
361
362 #[test]
363 fn every_descriptor_matches_the_digest_the_index_records() {
364 let catalog = LegacyCatalog::embedded();
365 for (version, text) in catalog.descriptors() {
366 let entry = catalog.entry(version).unwrap();
367 assert_eq!(
368 entry.descriptor_sha256.as_deref(),
369 Some(Sha256::of(text.as_bytes()).as_str()),
370 "{version} descriptor digest drifted"
371 );
372 }
373 }
374
375 #[test]
376 fn an_overlay_needs_the_archive_to_carry_what_it_projects() {
377 let catalog = LegacyCatalog::embedded();
378 let error = catalog.adapt("0.6.6", &BTreeMap::new()).unwrap_err();
379 assert!(
380 error.to_string().contains("carries no file under it"),
381 "{error}"
382 );
383 }
384
385 #[test]
386 fn every_descriptor_carries_a_compatibility_section() {
387 let catalog = LegacyCatalog::embedded();
388 for version in catalog.descriptors().keys() {
389 let (descriptor, _) = catalog.descriptor(version).unwrap();
390 assert_eq!(
391 descriptor.compatibility.schema,
392 crate::plan::compatibility::SCHEMA
393 );
394 assert_eq!(
395 descriptor.compatibility.minimum_engine.to_string(),
396 *version
397 );
398 }
399 }
400
401 #[test]
402 fn an_overlay_renders_a_declaration_this_engine_reads() {
403 let catalog = LegacyCatalog::embedded();
404 let (descriptor, _) = catalog.descriptor("0.8.0").unwrap();
405 let mut files: BTreeMap<String, Vec<u8>> = descriptor
408 .projection
409 .managed
410 .iter()
411 .chain(&descriptor.projection.adopted)
412 .map(|entry| (entry.source.clone(), b"x".to_vec()))
413 .collect();
414 for root in &descriptor.payload_roots {
415 files.insert(format!("{root}/present.md"), b"x".to_vec());
416 }
417 let adapted = catalog.adapt("0.8.0", &files).unwrap();
418 assert_eq!(adapted.payload_schema, PRE_SCHEMA);
419 let rendered = adapted.metadata.get(DECLARATION_PATH).unwrap();
420 let read = Declaration::parse(rendered).unwrap();
421 assert_eq!(read, descriptor.projection);
422 }
423}