spec_driven_docs/domain/
projection.rs1use serde::{Deserialize, Serialize};
16use thiserror::Error;
17
18use crate::domain::profile::{DocsRoot, ProfileId};
19
20pub const PAYLOAD_SCHEMA: u32 = 1;
22
23pub const OLDEST_PAYLOAD_SCHEMA: u32 = 1;
25
26pub const DECLARATION_PATH: &str = "instance/projection.toml";
28
29#[derive(Debug, Error, PartialEq, Eq)]
31pub enum DeclarationError {
32 #[error("{DECLARATION_PATH} does not parse: {0}")]
34 Malformed(String),
35
36 #[error(
38 "{DECLARATION_PATH} declares payload schema {found}, and this engine decodes {OLDEST_PAYLOAD_SCHEMA} to {PAYLOAD_SCHEMA}; install {advice}"
39 )]
40 UnsupportedSchema {
41 found: u32,
43 advice: String,
45 },
46
47 #[error("{DECLARATION_PATH} is inconsistent: {0}")]
49 Inconsistent(String),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(deny_unknown_fields)]
58pub struct Projection {
59 pub source: String,
61 pub destination: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct ProfileDeclaration {
69 pub id: ProfileId,
71 pub docs_root: DocsRoot,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
77#[serde(deny_unknown_fields)]
78pub struct SentinelDeclaration {
79 pub rule: String,
81 pub source: String,
83 pub destination: String,
85 pub declares: String,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct Declaration {
93 pub payload_schema: u32,
95 #[serde(default)]
97 pub canon_templates: Vec<String>,
98 pub profiles: Vec<ProfileDeclaration>,
100 #[serde(default)]
102 pub managed: Vec<Projection>,
103 #[serde(default)]
105 pub adopted: Vec<Projection>,
106 #[serde(default)]
108 pub sentinels: Vec<SentinelDeclaration>,
109}
110
111impl Declaration {
112 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 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 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 #[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 #[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 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}