Skip to main content

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}