onetaskgraph_core/template/input.rs
1// llmlint: ignore-file[code_lands_in_the_domain_that_owns_it] A part of the `template` module; why that module sits in this crate is stated once, at the head of its `mod.rs`.
2//! Which template to render: a file, or a loader document a caller supplies (contract C3b).
3//!
4//! A caller that holds its own layering of templates resolves it itself and states the result
5//! here — the entry to render, the directories and the `(name, source)` pairs its chain
6//! resolves over, and optionally the digest it expects. This product reads exactly that and
7//! never resolves a caller's layers or runs a caller's command: a caller depends on it, never
8//! the reverse, and a recorded `reference` is only ever a string it records and reports.
9
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14use super::{Sha256Digest, Template, TemplateError, TemplateLoader};
15
16/// A template loader document: exactly what to render, stated by a caller.
17///
18/// ```json
19/// {"reference": "<string>", "entry": "<name>", "search_path": ["<absolute dir>"],
20/// "templates": [{"name": "<name>", "source": "<text>"}], "digest": "sha256:<hex>"}
21/// ```
22///
23/// `reference` is what an item rendered from it records as its provenance `template`,
24/// verbatim; `entry` is loaded over the `search_path` directories and then the `templates`
25/// pairs, as a [`TemplateLoader`] searches; and `digest`, when present, must be the digest
26/// that chain computes. Every other key is ignored.
27#[derive(Debug, Clone, PartialEq)]
28pub struct LoaderDocument {
29 reference: String,
30 entry: String,
31 search_path: Vec<PathBuf>,
32 templates: Vec<(String, String)>,
33 digest: Option<Sha256Digest>,
34}
35
36impl LoaderDocument {
37 /// Every key a loader document is read for, in the order the contract states them — the
38 /// reader below takes its keys from here, and the documentation's examples are held to it.
39 /// Every other key is ignored.
40 pub const KEYS: [&'static str; 5] =
41 ["reference", "entry", "search_path", "templates", "digest"];
42
43 /// Read a loader document from its JSON text.
44 ///
45 /// # Errors
46 ///
47 /// [`TemplateError::MalformedLoader`] naming what is wrong: text that is not a JSON
48 /// object, a missing or empty `reference`, a missing or empty `entry`, a `search_path`
49 /// that is not a list of absolute directories, `templates` that are not `{name, source}`
50 /// string pairs, or a `digest` that is not a string.
51 pub fn from_json(text: &str) -> Result<Self, TemplateError> {
52 let malformed = |message: String| TemplateError::MalformedLoader { message };
53 let parsed: Value = serde_json::from_str(text)
54 .map_err(|error| malformed(format!("it is not JSON: {error}")))?;
55 let Value::Object(document) = parsed else {
56 return Err(malformed("its top level is not a JSON object".to_owned()));
57 };
58 let text_at = |key: &str| -> Result<Option<String>, TemplateError> {
59 match document.get(key) {
60 None => Ok(None),
61 Some(Value::String(text)) => Ok(Some(text.clone())),
62 Some(_) => Err(malformed(format!("`{key}` is not a string"))),
63 }
64 };
65 let [
66 reference_key,
67 entry_key,
68 search_key,
69 templates_key,
70 digest_key,
71 ] = Self::KEYS;
72 let reference = text_at(reference_key)?
73 .filter(|reference| !reference.is_empty())
74 .ok_or_else(|| malformed(format!("`{reference_key}` is missing or empty")))?;
75 let entry = text_at(entry_key)?
76 .filter(|entry| !entry.is_empty())
77 .ok_or_else(|| malformed(format!("`{entry_key}` is missing or empty")))?;
78 let digest = text_at(digest_key)?
79 .map(|digest| {
80 Sha256Digest::parse(digest)
81 .map_err(|problem| malformed(format!("`{digest_key}`: {problem}")))
82 })
83 .transpose()?;
84 let search_path = match document.get(search_key) {
85 None => Vec::new(),
86 Some(Value::Array(directories)) => directories
87 .iter()
88 .enumerate()
89 .map(|(index, directory)| match directory {
90 Value::String(directory) if Path::new(directory).is_absolute() => {
91 Ok(PathBuf::from(directory))
92 }
93 Value::String(directory) => Err(malformed(format!(
94 "`{search_key}[{index}]` is {directory:?}, which is not an absolute \
95 directory"
96 ))),
97 _ => Err(malformed(format!(
98 "`{search_key}[{index}]` is not a string"
99 ))),
100 })
101 .collect::<Result<_, _>>()?,
102 Some(_) => return Err(malformed(format!("`{search_key}` is not a list"))),
103 };
104 let templates = match document.get(templates_key) {
105 None => Vec::new(),
106 Some(Value::Array(pairs)) => pairs
107 .iter()
108 .enumerate()
109 .map(|(index, pair)| {
110 let field = |key: &str| match pair.get(key) {
111 // A name resolves nothing when it is empty, and an empty source is no
112 // template: neither is a pair a chain can use.
113 Some(Value::String(text)) if !text.is_empty() => Ok(text.clone()),
114 _ => Err(malformed(format!(
115 "`{templates_key}[{index}]` has no non-empty string `{key}`; each \
116 entry is \
117 {{\"name\": <name>, \"source\": <text>}}"
118 ))),
119 };
120 Ok((field("name")?, field("source")?))
121 })
122 .collect::<Result<_, TemplateError>>()?,
123 Some(_) => return Err(malformed(format!("`{templates_key}` is not a list"))),
124 };
125 Ok(Self {
126 reference,
127 entry,
128 search_path,
129 templates,
130 digest,
131 })
132 }
133
134 /// A loader document naming `entry`, recorded as `reference`, over nothing yet.
135 ///
136 /// # Errors
137 ///
138 /// [`TemplateError::MalformedLoader`] for an empty `reference` or `entry`, as
139 /// [`LoaderDocument::from_json`] refuses one.
140 pub fn new(
141 reference: impl Into<String>,
142 entry: impl Into<String>,
143 ) -> Result<Self, TemplateError> {
144 let (reference, entry) = (reference.into(), entry.into());
145 for (key, value) in [("reference", &reference), ("entry", &entry)] {
146 if value.is_empty() {
147 return Err(TemplateError::MalformedLoader {
148 message: format!("`{key}` is missing or empty"),
149 });
150 }
151 }
152 Ok(Self {
153 reference,
154 entry,
155 search_path: Vec::new(),
156 templates: Vec::new(),
157 digest: None,
158 })
159 }
160
161 /// Add `directory` to the end of the search path.
162 ///
163 /// # Errors
164 ///
165 /// [`TemplateError::MalformedLoader`] for a directory that is not absolute, as
166 /// [`LoaderDocument::from_json`] refuses one: a relative one would resolve against
167 /// whatever directory the render happens to run in.
168 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Result<Self, TemplateError> {
169 let directory = directory.into();
170 if !directory.is_absolute() {
171 return Err(TemplateError::MalformedLoader {
172 message: format!(
173 "the search directory {} is not an absolute directory",
174 directory.display()
175 ),
176 });
177 }
178 self.search_path.push(directory);
179 Ok(self)
180 }
181
182 /// Register a template's source under `name`, searched after every directory.
183 ///
184 /// # Errors
185 ///
186 /// [`TemplateError::MalformedLoader`] for an empty `name` or `source`, as
187 /// [`LoaderDocument::from_json`] refuses one.
188 pub fn with_template(
189 mut self,
190 name: impl Into<String>,
191 source: impl Into<String>,
192 ) -> Result<Self, TemplateError> {
193 let (name, source) = (name.into(), source.into());
194 if name.is_empty() || source.is_empty() {
195 return Err(TemplateError::MalformedLoader {
196 message: "a registered template has an empty name or source".to_owned(),
197 });
198 }
199 self.templates.push((name, source));
200 Ok(self)
201 }
202
203 /// Expect the chain to compute `digest`.
204 #[must_use]
205 pub fn with_digest(mut self, digest: Sha256Digest) -> Self {
206 self.digest = Some(digest);
207 self
208 }
209
210 /// What an item rendered from this document records as its provenance `template`.
211 #[must_use]
212 pub fn reference(&self) -> &str {
213 &self.reference
214 }
215
216 /// Load the entry over the search path and the registered pairs, and hold its digest to
217 /// the stated one.
218 ///
219 /// # Errors
220 ///
221 /// Every refusal of [`TemplateLoader::load_name`] — an unreadable or missing search path
222 /// directory among them, named — and [`TemplateError::LoaderDigest`] naming both digests
223 /// when a stated one is not the one the chain computes.
224 pub fn load(&self) -> Result<Template, TemplateError> {
225 let loader = self
226 .search_path
227 .iter()
228 .fold(TemplateLoader::new(), |loader, directory| {
229 loader.with_directory(directory)
230 });
231 let loader = self
232 .templates
233 .iter()
234 .fold(loader, |loader, (name, source)| {
235 loader.with_template(name, source)
236 });
237 let template = loader.load_name(&self.entry)?;
238 if let Some(stated) = &self.digest
239 && stated.as_str() != template.digest()
240 {
241 return Err(TemplateError::LoaderDigest {
242 stated: stated.to_string(),
243 computed: template.digest().to_owned(),
244 });
245 }
246 Ok(template)
247 }
248}
249
250/// Which template a create or a regenerate renders.
251#[derive(Debug, Clone, PartialEq)]
252pub enum TemplateInput {
253 /// A template file, whose chain resolves over `search_path`. Recorded by its absolute
254 /// path, which a later regenerate re-reads as a file.
255 File {
256 /// The template file.
257 path: PathBuf,
258 /// The directories its `extends`, `include` and `import` names resolve in.
259 search_path: Vec<PathBuf>,
260 },
261 /// A template a caller states as a loader document. Recorded by the document's
262 /// `reference`, which is never turned into a location.
263 Loader(LoaderDocument),
264}
265
266impl TemplateInput {
267 /// Load the template.
268 ///
269 /// # Errors
270 ///
271 /// As [`TemplateLoader::load_path`] for a file, and [`LoaderDocument::load`] for a loader
272 /// document.
273 pub fn load(&self) -> Result<Template, TemplateError> {
274 match self {
275 Self::File { path, search_path } => search_path
276 .iter()
277 .fold(TemplateLoader::new(), |loader, directory| {
278 loader.with_directory(directory)
279 })
280 .load_path(path),
281 Self::Loader(document) => document.load(),
282 }
283 }
284
285 /// What an item rendered from this template records as its provenance `template`: a
286 /// file's absolute path, or a loader document's `reference`.
287 #[must_use]
288 pub fn reference(&self) -> String {
289 match self {
290 Self::File { path, .. } => std::path::absolute(path)
291 .unwrap_or_else(|_| path.clone())
292 .display()
293 .to_string(),
294 Self::Loader(document) => document.reference().to_owned(),
295 }
296 }
297}