Skip to main content

spec_driven_docs/domain/
projection.rs

1//! What this release lands, read as data rather than compiled in.
2//!
3//! The release declares its own projection in `instance/projection.toml`,
4//! which the binary carries. Selection stays in data because the
5//! declaration is simpler to read, to diff, and to review than the Rust
6//! constants it replaced.
7
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::domain::profile::{DocsRoot, ProfileId};
12
13/// Where the declaration sits inside the payload.
14pub const DECLARATION_PATH: &str = "instance/projection.toml";
15
16/// A declaration this engine cannot read.
17#[derive(Debug, Error, PartialEq, Eq)]
18pub enum DeclarationError {
19    /// The bytes are not the declaration's shape.
20    #[error("{DECLARATION_PATH} does not parse: {0}")]
21    Malformed(String),
22
23    /// The declaration is internally inconsistent.
24    #[error("{DECLARATION_PATH} is inconsistent: {0}")]
25    Inconsistent(String),
26}
27
28/// One payload projection: an embedded source and its instance destination.
29///
30/// An adopted destination may carry a `{docs_root}` placeholder, resolved
31/// per profile by [`crate::domain::profile::resolve_destination`].
32#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct Projection {
35    /// The payload path, as the bundle names it.
36    pub source: String,
37    /// The destination, relative to the instance root.
38    pub destination: String,
39}
40
41/// One profile the release offers.
42#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct ProfileDeclaration {
45    /// The profile's kebab-case name.
46    pub id: ProfileId,
47    /// Where that profile keeps the documents the gates read.
48    pub docs_root: DocsRoot,
49}
50
51/// One sentinel: the rule, and the adopted specification that owns it.
52#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
53#[serde(deny_unknown_fields)]
54pub struct SentinelDeclaration {
55    /// The rule an instance's specifications must define.
56    pub rule: String,
57    /// The payload specification that carries it.
58    pub source: String,
59    /// Where an instance holds that specification, templated.
60    pub destination: String,
61    /// The declaration it authorizes, for the note that names it.
62    pub declares: String,
63}
64
65/// Everything one release declares about what it lands.
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct Declaration {
69    /// The template copies the canon keeps in its own documentation tree.
70    #[serde(default)]
71    pub canon_templates: Vec<String>,
72    /// Every profile the release offers.
73    pub profiles: Vec<ProfileDeclaration>,
74    /// Byte projections the canon keeps owning.
75    #[serde(default)]
76    pub managed: Vec<Projection>,
77    /// Seeds the instance owns from the moment they land.
78    #[serde(default)]
79    pub adopted: Vec<Projection>,
80    /// One sentinel per feature a project can declare.
81    #[serde(default)]
82    pub sentinels: Vec<SentinelDeclaration>,
83}
84
85impl Declaration {
86    /// Read a declaration, refusing a schema this engine does not decode.
87    ///
88    /// # Errors
89    ///
90    /// [`DeclarationError`] when the bytes do not parse or when the
91    /// declaration contradicts itself.
92    pub fn parse(bytes: &[u8]) -> Result<Self, DeclarationError> {
93        let text = std::str::from_utf8(bytes)
94            .map_err(|source| DeclarationError::Malformed(source.to_string()))?;
95        let held: Self = toml::from_str(text)
96            .map_err(|source| DeclarationError::Malformed(source.to_string()))?;
97        held.consistent()?;
98        Ok(held)
99    }
100
101    /// Whether the declaration says one thing.
102    fn consistent(&self) -> Result<(), DeclarationError> {
103        if self.profiles.is_empty() {
104            return Err(DeclarationError::Inconsistent(
105                "no profile is declared".to_string(),
106            ));
107        }
108        for entry in &self.managed {
109            if entry.destination.contains('{') {
110                return Err(DeclarationError::Inconsistent(format!(
111                    "the managed destination {} is templated, and only an adopted destination may be",
112                    entry.destination
113                )));
114            }
115        }
116        let mut seen: Vec<&str> = Vec::new();
117        for entry in self.managed.iter().chain(&self.adopted) {
118            if seen.contains(&entry.destination.as_str()) {
119                return Err(DeclarationError::Inconsistent(format!(
120                    "{} is projected twice",
121                    entry.destination
122                )));
123            }
124            seen.push(&entry.destination);
125        }
126        Ok(())
127    }
128
129    /// What one profile lands, as this release declares it.
130    #[must_use]
131    pub fn profile(&self, id: ProfileId) -> Option<crate::domain::profile::Profile<'_>> {
132        Some(crate::domain::profile::Profile {
133            id,
134            docs_root: self.docs_root(id)?,
135            managed: &self.managed,
136            adopted: &self.adopted,
137        })
138    }
139
140    /// The documentation root one profile takes, where it is declared.
141    #[must_use]
142    pub fn docs_root(&self, id: ProfileId) -> Option<DocsRoot> {
143        self.profiles
144            .iter()
145            .find(|profile| profile.id == id)
146            .map(|profile| profile.docs_root)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    #![allow(
153        clippy::unwrap_used,
154        reason = "a test panics as its failure signal, not as control flow"
155    )]
156
157    use super::*;
158
159    const MINIMAL: &str = r#"
160[[profiles]]
161id = "codebase"
162docs_root = "docs"
163"#;
164
165    #[test]
166    fn a_minimal_declaration_parses() {
167        let held = Declaration::parse(MINIMAL.as_bytes()).unwrap();
168        assert_eq!(held.docs_root(ProfileId::Codebase), Some(DocsRoot::Docs));
169        assert_eq!(held.docs_root(ProfileId::KnowledgeBase), None);
170    }
171
172    #[test]
173    fn a_declaration_with_no_profile_refuses() {
174        // Absent is a parse failure and empty is an inconsistency. Both
175        // refuse, because a release that offers no profile lands nothing.
176        assert!(matches!(
177            Declaration::parse(b"").unwrap_err(),
178            DeclarationError::Malformed(_)
179        ));
180        assert!(matches!(
181            Declaration::parse(b"profiles = []\n").unwrap_err(),
182            DeclarationError::Inconsistent(_)
183        ));
184    }
185
186    #[test]
187    fn a_templated_managed_destination_is_inconsistent() {
188        let text =
189            format!("{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"{{docs_root}}/a\"\n");
190        let error = Declaration::parse(text.as_bytes()).unwrap_err();
191        assert!(error.to_string().contains("is templated"), "{error}");
192    }
193
194    #[test]
195    fn one_destination_projected_twice_is_inconsistent() {
196        let text = format!(
197            "{MINIMAL}\n[[managed]]\nsource = \"a\"\ndestination = \"x\"\n[[adopted]]\nsource = \"b\"\ndestination = \"x\"\n"
198        );
199        let error = Declaration::parse(text.as_bytes()).unwrap_err();
200        assert!(error.to_string().contains("projected twice"), "{error}");
201    }
202
203    #[test]
204    fn an_unknown_field_refuses_rather_than_being_ignored() {
205        let text = format!("{MINIMAL}\nsomething_new = 1\n");
206        assert!(matches!(
207            Declaration::parse(text.as_bytes()).unwrap_err(),
208            DeclarationError::Malformed(_)
209        ));
210    }
211}