spec_driven_docs/domain/
projection.rs1use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::domain::profile::{DocsRoot, ProfileId};
12
13pub const DECLARATION_PATH: &str = "instance/projection.toml";
15
16#[derive(Debug, Error, PartialEq, Eq)]
18pub enum DeclarationError {
19 #[error("{DECLARATION_PATH} does not parse: {0}")]
21 Malformed(String),
22
23 #[error("{DECLARATION_PATH} is inconsistent: {0}")]
25 Inconsistent(String),
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct Projection {
35 pub source: String,
37 pub destination: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct ProfileDeclaration {
45 pub id: ProfileId,
47 pub docs_root: DocsRoot,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
53#[serde(deny_unknown_fields)]
54pub struct SentinelDeclaration {
55 pub rule: String,
57 pub source: String,
59 pub destination: String,
61 pub declares: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct Declaration {
69 #[serde(default)]
71 pub canon_templates: Vec<String>,
72 pub profiles: Vec<ProfileDeclaration>,
74 #[serde(default)]
76 pub managed: Vec<Projection>,
77 #[serde(default)]
79 pub adopted: Vec<Projection>,
80 #[serde(default)]
82 pub sentinels: Vec<SentinelDeclaration>,
83}
84
85impl Declaration {
86 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 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 #[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 #[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 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}