Skip to main content

spec_driven_docs/domain/
projection.rs

1//! What a release lands, read as data rather than compiled in.
2//!
3//! A release declares its own projection in `instance/projection.toml`.
4//! The engine reads the declaration of whichever release it was asked
5//! about, so a plan toward an older release lands exactly that release's
6//! set. While the projection lived in Rust constants, the only witness of
7//! what a release landed was that release's own binary, and a plan toward
8//! an intermediate version could only ever be an approximation.
9//!
10//! Parsing lives here, in the domain, rather than on the bundle boundary.
11//! The boundary answers with bytes; what those bytes mean is this layer's
12//! business, and a transport that understood the projection would be a
13//! second place to change when the projection grows.
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::domain::profile::{DocsRoot, ProfileId};
19
20/// The protocol version this engine writes.
21pub const PAYLOAD_SCHEMA: u32 = 1;
22
23/// The lowest protocol version this engine decodes.
24pub const OLDEST_PAYLOAD_SCHEMA: u32 = 1;
25
26/// Where the declaration sits inside a bundle.
27pub const DECLARATION_PATH: &str = "instance/projection.toml";
28
29/// A declaration this engine cannot read.
30#[derive(Debug, Error, PartialEq, Eq)]
31pub enum DeclarationError {
32    /// The bytes are not the declaration's shape.
33    #[error("{DECLARATION_PATH} does not parse: {0}")]
34    Malformed(String),
35
36    /// The declaration is written in a protocol this engine does not carry.
37    #[error(
38        "{DECLARATION_PATH} declares payload schema {found}, and this engine decodes {OLDEST_PAYLOAD_SCHEMA} to {PAYLOAD_SCHEMA}; install {advice}"
39    )]
40    UnsupportedSchema {
41        /// The schema the bundle declares.
42        found: u32,
43        /// Which engine to install instead.
44        advice: String,
45    },
46
47    /// The declaration is internally inconsistent.
48    #[error("{DECLARATION_PATH} is inconsistent: {0}")]
49    Inconsistent(String),
50}
51
52/// One payload projection: an embedded source and its instance destination.
53///
54/// An adopted destination may carry a `{docs_root}` placeholder, resolved
55/// per profile by [`crate::domain::profile::resolve_destination`].
56#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(deny_unknown_fields)]
58pub struct Projection {
59    /// The payload path, as the bundle names it.
60    pub source: String,
61    /// The destination, relative to the instance root.
62    pub destination: String,
63}
64
65/// One profile the release offers.
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct ProfileDeclaration {
69    /// The profile's kebab-case name.
70    pub id: ProfileId,
71    /// Where that profile keeps the documents the gates read.
72    pub docs_root: DocsRoot,
73}
74
75/// One sentinel: the rule, and the adopted specification that owns it.
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct SentinelDeclaration {
79    /// The rule an instance's specifications must define.
80    pub rule: String,
81    /// The payload specification that carries it.
82    pub source: String,
83    /// Where an instance holds that specification, templated.
84    pub destination: String,
85    /// The declaration it authorizes, for the note that names it.
86    pub declares: String,
87}
88
89/// Everything one release declares about what it lands.
90#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct Declaration {
93    /// The protocol version between this engine and the bundle.
94    pub payload_schema: u32,
95    /// The template copies the canon keeps in its own documentation tree.
96    #[serde(default)]
97    pub canon_templates: Vec<String>,
98    /// Every profile the release offers.
99    pub profiles: Vec<ProfileDeclaration>,
100    /// Byte projections the canon keeps owning.
101    #[serde(default)]
102    pub managed: Vec<Projection>,
103    /// Seeds the instance owns from the moment they land.
104    #[serde(default)]
105    pub adopted: Vec<Projection>,
106    /// One sentinel per feature a project can declare.
107    #[serde(default)]
108    pub sentinels: Vec<SentinelDeclaration>,
109}
110
111impl Declaration {
112    /// Read a declaration, refusing a schema this engine does not decode.
113    ///
114    /// # Errors
115    ///
116    /// [`DeclarationError`] when the bytes do not parse, when the schema is
117    /// outside the supported range, or when the declaration contradicts
118    /// itself.
119    pub fn parse(bytes: &[u8]) -> Result<Self, DeclarationError> {
120        let text = std::str::from_utf8(bytes)
121            .map_err(|source| DeclarationError::Malformed(source.to_string()))?;
122        let held: Self = toml::from_str(text)
123            .map_err(|source| DeclarationError::Malformed(source.to_string()))?;
124        held.supported()?;
125        held.consistent()?;
126        Ok(held)
127    }
128
129    /// Whether this engine carries a decoder for the declared schema.
130    fn supported(&self) -> Result<(), DeclarationError> {
131        if (OLDEST_PAYLOAD_SCHEMA..=PAYLOAD_SCHEMA).contains(&self.payload_schema) {
132            return Ok(());
133        }
134        let advice = if self.payload_schema > PAYLOAD_SCHEMA {
135            "a newer sdd".to_string()
136        } else {
137            format!(
138                "an sdd that still decodes payload schema {}",
139                self.payload_schema
140            )
141        };
142        Err(DeclarationError::UnsupportedSchema {
143            found: self.payload_schema,
144            advice,
145        })
146    }
147
148    /// Whether the declaration says one thing.
149    fn consistent(&self) -> Result<(), DeclarationError> {
150        if self.profiles.is_empty() {
151            return Err(DeclarationError::Inconsistent(
152                "no profile is declared".to_string(),
153            ));
154        }
155        for entry in &self.managed {
156            if entry.destination.contains('{') {
157                return Err(DeclarationError::Inconsistent(format!(
158                    "the managed destination {} is templated, and only an adopted destination may be",
159                    entry.destination
160                )));
161            }
162        }
163        let mut seen: Vec<&str> = Vec::new();
164        for entry in self.managed.iter().chain(&self.adopted) {
165            if seen.contains(&entry.destination.as_str()) {
166                return Err(DeclarationError::Inconsistent(format!(
167                    "{} is projected twice",
168                    entry.destination
169                )));
170            }
171            seen.push(&entry.destination);
172        }
173        Ok(())
174    }
175
176    /// What one profile lands, as this release declares it.
177    #[must_use]
178    pub fn profile(&self, id: ProfileId) -> Option<crate::domain::profile::Profile<'_>> {
179        Some(crate::domain::profile::Profile {
180            id,
181            docs_root: self.docs_root(id)?,
182            managed: &self.managed,
183            adopted: &self.adopted,
184        })
185    }
186
187    /// The documentation root one profile takes, where it is declared.
188    #[must_use]
189    pub fn docs_root(&self, id: ProfileId) -> Option<DocsRoot> {
190        self.profiles
191            .iter()
192            .find(|profile| profile.id == id)
193            .map(|profile| profile.docs_root)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    #![allow(
200        clippy::unwrap_used,
201        reason = "a test panics as its failure signal, not as control flow"
202    )]
203
204    use super::*;
205
206    const MINIMAL: &str = r#"
207payload_schema = 1
208[[profiles]]
209id = "codebase"
210docs_root = "docs"
211"#;
212
213    #[test]
214    fn a_minimal_declaration_parses() {
215        let held = Declaration::parse(MINIMAL.as_bytes()).unwrap();
216        assert_eq!(held.payload_schema, 1);
217        assert_eq!(held.docs_root(ProfileId::Codebase), Some(DocsRoot::Docs));
218        assert_eq!(held.docs_root(ProfileId::KnowledgeBase), None);
219    }
220
221    #[test]
222    fn a_newer_schema_names_an_engine_to_install() {
223        let text = MINIMAL.replace("payload_schema = 1", "payload_schema = 2");
224        let error = Declaration::parse(text.as_bytes()).unwrap_err();
225        assert_eq!(
226            error,
227            DeclarationError::UnsupportedSchema {
228                found: 2,
229                advice: "a newer sdd".to_string()
230            }
231        );
232        assert!(error.to_string().contains("a newer sdd"));
233    }
234
235    #[test]
236    fn a_retired_schema_names_the_engine_that_still_decodes_it() {
237        let text = MINIMAL.replace("payload_schema = 1", "payload_schema = 0");
238        let error = Declaration::parse(text.as_bytes()).unwrap_err();
239        assert!(error.to_string().contains("still decodes payload schema 0"));
240    }
241
242    #[test]
243    fn a_declaration_with_no_profile_refuses() {
244        // Absent is a parse failure and empty is an inconsistency. Both
245        // refuse, because a release that offers no profile lands nothing.
246        assert!(matches!(
247            Declaration::parse(b"payload_schema = 1\n").unwrap_err(),
248            DeclarationError::Malformed(_)
249        ));
250        assert!(matches!(
251            Declaration::parse(b"payload_schema = 1\nprofiles = []\n").unwrap_err(),
252            DeclarationError::Inconsistent(_)
253        ));
254    }
255
256    #[test]
257    fn a_templated_managed_destination_is_inconsistent() {
258        let text =
259            format!("{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"{{docs_root}}/a\"\n");
260        let error = Declaration::parse(text.as_bytes()).unwrap_err();
261        assert!(error.to_string().contains("is templated"), "{error}");
262    }
263
264    #[test]
265    fn one_destination_projected_twice_is_inconsistent() {
266        let text = format!(
267            "{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"x\"\n[[adopted]]\nsource = \"b\"\ndestination = \"x\"\n"
268        );
269        let error = Declaration::parse(text.as_bytes()).unwrap_err();
270        assert!(error.to_string().contains("projected twice"), "{error}");
271    }
272
273    #[test]
274    fn an_unknown_field_refuses_rather_than_being_ignored() {
275        let text = format!("{MINIMAL}\nsomething_new = 1\n");
276        assert!(matches!(
277            Declaration::parse(text.as_bytes()).unwrap_err(),
278            DeclarationError::Malformed(_)
279        ));
280    }
281}