Skip to main content

onetaskgraph_core/template/
mod.rs

1// llmlint: ignore-file[code_lands_in_the_domain_that_owns_it] This module is where the task that introduced templates fixes their library API: at `onetaskgraph-core`'s crate root, beside the engine every surface shares, so a Rust consumer renders without the binary. A crate of its own would be a new published sibling — a change to the release surface `release-targets.toml` freezes — which is not this change's to make.
2//! Task templates: a minijinja document with a declared set of variables, rendered from
3//! answers.
4//!
5//! # The format
6//!
7//! A template file is UTF-8 minijinja source, optionally opened by YAML front matter — a
8//! first line reading `---`, closed by the next line reading `---`:
9//!
10//! ```yaml
11//! ---
12//! onetaskgraph_template: 1          # required when front matter is present
13//! description: <string>             # optional
14//! variables:                        # optional; name -> declaration
15//!   <name>:                         # ^[a-z][a-z0-9_]*$
16//!     description: <string>         # required, non-empty: what a prompt shows
17//!     type: string                  # string | text | integer | boolean | list | object
18//!     items: string                 # list only: string | object; default string
19//!     required: true                # default: true without `default`, false with one
20//!     default: <value of `type`>    # optional; `required: true` beside one is refused
21//! ---
22//! ```
23//!
24//! Nothing here restricts how the body uses a variable: it is minijinja, whole.
25//!
26//! # The chain
27//!
28//! `extends`, `include` and `import` resolve names over a [`TemplateLoader`]'s search path —
29//! the directories it was given, in order, then the `(name, source)` pairs registered on it —
30//! and never the working directory. The **declared set** is the union of every chain file's
31//! front matter. When two files declare one variable, the file nearer the rendered one (fewer
32//! references away from it, then loaded first) gives its `description`, `default` and
33//! `required`; its `type` and `items` must be the other's, or the chain is refused naming both
34//! files.
35//!
36//! # Rendering
37//!
38//! Strict: a name that is neither a declared variable nor set by the template fails the
39//! render, naming the name and the file. No auto-escaping, trailing newlines kept,
40//! `trim_blocks` and `lstrip_blocks` on. An optional variable given no answer and no default
41//! renders as `none`.
42//!
43//! # The digest
44//!
45//! `sha256:` and the lowercase hex SHA-256 over, for each chain file in first-load order, its
46//! resolved name, a NUL, its full bytes — front matter included — and a NUL. First-load
47//! order is the order rendering reads the files — for the chain's own digest, rendering with
48//! every variable at its default — the rendered file first, and a file named by an expression
49//! where the render first reads it like any other; the files no render reads, in a branch it
50//! did not take, follow in the order the chain names them.
51//!
52//! No prompting happens here. A caller that prompts asks [`Template::unanswered`] what is
53//! left, puts the answers it gathers over the ones it had with [`Answers::overlay`], and
54//! renders; the command line is the one caller that does.
55
56mod answers;
57mod front_matter;
58mod scan;
59
60use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
61use std::fmt;
62use std::path::{Component, Path, PathBuf};
63use std::sync::{Arc, Mutex, PoisonError};
64
65use schemars::JsonSchema;
66use serde::Serialize;
67use serde_json::Value;
68use sha2::{Digest as _, Sha256};
69
70pub use answers::Answers;
71use answers::Given;
72pub use front_matter::{DECLARATION_KEYS, FRONT_MATTER_KEYS, is_variable_name};
73
74/// What one variable holds.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
76#[serde(rename_all = "kebab-case")]
77pub enum VariableType {
78    /// One line of text.
79    String,
80    /// Text of any number of lines.
81    Text,
82    /// A whole number.
83    Integer,
84    /// `true` or `false`.
85    Boolean,
86    /// A list, of the variable's `items`.
87    List,
88    /// A mapping from string keys to values.
89    Object,
90}
91
92impl VariableType {
93    /// Every type, in the order front matter documents them.
94    pub const ALL: [Self; 6] = [
95        Self::String,
96        Self::Text,
97        Self::Integer,
98        Self::Boolean,
99        Self::List,
100        Self::Object,
101    ];
102
103    /// The type a front matter `type:` spells, if it spells one.
104    fn parse(text: &str) -> Option<Self> {
105        Self::ALL.into_iter().find(|kind| kind.as_str() == text)
106    }
107
108    /// How front matter spells it.
109    #[must_use]
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Self::String => "string",
113            Self::Text => "text",
114            Self::Integer => "integer",
115            Self::Boolean => "boolean",
116            Self::List => "list",
117            Self::Object => "object",
118        }
119    }
120}
121
122impl fmt::Display for VariableType {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str(self.as_str())
125    }
126}
127
128/// What each entry of a `list` variable holds.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
130#[serde(rename_all = "kebab-case")]
131pub enum ItemType {
132    /// Strings.
133    String,
134    /// Mappings from string keys to values.
135    Object,
136}
137
138impl ItemType {
139    /// Every item type, in the order front matter documents them.
140    pub const ALL: [Self; 2] = [Self::String, Self::Object];
141
142    /// The item type a front matter `items:` spells, if it spells one.
143    fn parse(text: &str) -> Option<Self> {
144        Self::ALL.into_iter().find(|items| items.as_str() == text)
145    }
146
147    /// How front matter spells it.
148    #[must_use]
149    pub fn as_str(self) -> &'static str {
150        match self {
151            Self::String => "string",
152            Self::Object => "object",
153        }
154    }
155}
156
157/// One variable of a template's declared set, merged down its chain.
158///
159/// Built only by loading a template, so `items` is present exactly when `type` is `list`,
160/// a `default` is a value of the variable's type, and `required` is never `true` beside one.
161// llmlint: ignore-block[invalid_states_unrepresentable] Every field is private and the one constructor is `front_matter::declaration_of`, which refuses a declaration unless those three rules hold; the chain merge only clones what it built. The flat shape is the wire contract C1 fixes and `TemplateVariables` emits (`type`, `items`, `required`, `default` side by side), which a nested enum would change for both SDKs.
162#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
163pub struct TemplateVariable {
164    /// The variable's name, as the template body uses it.
165    name: String,
166    /// What the variable is for; what a prompt shows.
167    description: String,
168    /// What the variable holds.
169    #[serde(rename = "type")]
170    kind: VariableType,
171    /// What each entry holds, for a `list` variable and no other.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    items: Option<ItemType>,
174    /// Whether rendering without an answer is refused. An optional variable with no answer
175    /// and no default renders as `none`.
176    required: bool,
177    /// The value used when no answer is given.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    default: Option<Value>,
180    /// The chain file whose declaration this is: the one nearest the rendered template.
181    declared_in: String,
182}
183// llmlint: ignore-end[invalid_states_unrepresentable]
184
185impl TemplateVariable {
186    /// The variable's name.
187    #[must_use]
188    pub fn name(&self) -> &str {
189        &self.name
190    }
191
192    /// What the variable is for.
193    #[must_use]
194    pub fn description(&self) -> &str {
195        &self.description
196    }
197
198    /// What the variable holds.
199    #[must_use]
200    pub fn kind(&self) -> VariableType {
201        self.kind
202    }
203
204    /// What each entry holds, for a `list` variable.
205    #[must_use]
206    pub fn items(&self) -> Option<ItemType> {
207        self.items
208    }
209
210    /// Whether rendering without an answer is refused.
211    #[must_use]
212    pub fn required(&self) -> bool {
213        self.required
214    }
215
216    /// The value used when no answer is given.
217    #[must_use]
218    pub fn default(&self) -> Option<&Value> {
219        self.default.as_ref()
220    }
221
222    /// The chain file whose declaration this is.
223    #[must_use]
224    pub fn declared_in(&self) -> &str {
225        &self.declared_in
226    }
227
228    /// Whether a value of this variable is written over several lines: `text`, `list` and
229    /// `object`.
230    #[must_use]
231    pub fn multi_line(&self) -> bool {
232        matches!(
233            self.kind,
234            VariableType::Text | VariableType::List | VariableType::Object
235        )
236    }
237
238    /// Read text as this variable's value: literally for `string` and `text`, as YAML for
239    /// every other type — the rule `--var` and a prompt both follow.
240    ///
241    /// # Errors
242    ///
243    /// Why the text is not a value of this variable's type.
244    pub fn parse(&self, text: &str) -> Result<Value, String> {
245        let value = match self.kind {
246            VariableType::String | VariableType::Text => Value::String(text.to_owned()),
247            _ => {
248                serde_norway::from_str(text).map_err(|error| format!("it is not YAML: {error}"))?
249            }
250        };
251        self.check(&value)?;
252        Ok(value)
253    }
254
255    /// Whether `value` is a value of this variable's type.
256    ///
257    /// # Errors
258    ///
259    /// Why it is not.
260    pub fn check(&self, value: &Value) -> Result<(), String> {
261        check_value(self.kind, self.items, value)
262    }
263}
264
265/// Whether `value` is a value of `kind` (with `items`, for a list).
266fn check_value(kind: VariableType, items: Option<ItemType>, value: &Value) -> Result<(), String> {
267    let fits = match kind {
268        VariableType::String => {
269            if let Value::String(text) = value
270                && text.contains(['\n', '\r'])
271            {
272                return Err("a `string` is one line; declare the variable `text` for more".into());
273            }
274            value.is_string()
275        }
276        VariableType::Text => value.is_string(),
277        VariableType::Integer => value.is_i64() || value.is_u64(),
278        VariableType::Boolean => value.is_boolean(),
279        VariableType::Object => value.is_object(),
280        VariableType::List => {
281            let Value::Array(entries) = value else {
282                return Err(format!("expected a list, got {}", describe(value)));
283            };
284            let items = items.unwrap_or(ItemType::String);
285            if let Some((index, entry)) =
286                entries.iter().enumerate().find(|(_, entry)| match items {
287                    ItemType::String => !entry.is_string(),
288                    ItemType::Object => !entry.is_object(),
289                })
290            {
291                return Err(format!(
292                    "entry {index} of the list is {}, and its items are {}s",
293                    describe(entry),
294                    items.as_str()
295                ));
296            }
297            true
298        }
299    };
300    if fits {
301        Ok(())
302    } else {
303        Err(format!(
304            "expected {}, got {}",
305            article(kind),
306            describe(value)
307        ))
308    }
309}
310
311/// A type with its article, for a message.
312fn article(kind: VariableType) -> String {
313    match kind {
314        VariableType::Integer | VariableType::Object => format!("an {kind}"),
315        _ => format!("a {kind}"),
316    }
317}
318
319/// What kind of JSON value `value` is, for a message.
320fn describe(value: &Value) -> String {
321    match value {
322        Value::Null => "null".to_owned(),
323        Value::Bool(value) => format!("the boolean {value}"),
324        Value::Number(number) => format!("the number {number}"),
325        Value::String(text) => format!("the string {text:?}"),
326        Value::Array(_) => "a list".to_owned(),
327        Value::Object(_) => "a mapping".to_owned(),
328    }
329}
330
331/// Which field of a declaration two chain files disagree about.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum ChainField {
334    /// The variable's `type`.
335    Type,
336    /// A `list` variable's `items`.
337    Items,
338}
339
340impl fmt::Display for ChainField {
341    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
342        formatter.write_str(match self {
343            Self::Type => "type",
344            Self::Items => "items",
345        })
346    }
347}
348
349/// Why a template could not be loaded, answered or rendered.
350#[derive(Debug, Clone, PartialEq, thiserror::Error)]
351#[non_exhaustive]
352pub enum TemplateError {
353    /// A name or path the chain needs resolved to nothing.
354    #[error(
355        "template {name:?} was not found{}\nnext: {}",
356        referenced_from.as_ref().map(|from| format!(" (named by {from})")).unwrap_or_default(),
357        if searched.is_empty() {
358            "give the directory holding it as a search path, or register it by name.".to_owned()
359        } else {
360            format!("it was looked for in {}; add the directory holding it as a search path.", searched.join(", "))
361        }
362    )]
363    NotFound {
364        /// The name or path.
365        name: String,
366        /// The chain file that named it, when one did.
367        referenced_from: Option<String>,
368        /// The search path directories it was looked for in.
369        searched: Vec<String>,
370    },
371    /// A template that is there and could not be read — a permission, or a directory where a
372    /// file was named.
373    #[error(
374        "template {name} could not be read: {message}\n\
375         next: name a readable file, or give it the permissions this process reads with."
376    )]
377    Unreadable {
378        /// The path it was read at.
379        name: String,
380        /// Why the read failed.
381        message: String,
382    },
383    /// A search path directory that is missing or is not a directory, refused before any
384    /// template is looked for, so a mistyped one is not silently searched as empty.
385    #[error(
386        "search path {directory} cannot be searched: {message}\n\
387         next: give a directory that exists, or leave it out of the search path."
388    )]
389    SearchPath {
390        /// The directory as it was given.
391        directory: String,
392        /// Why it cannot be searched.
393        message: String,
394    },
395    /// A chain file that is not a template this format reads.
396    #[error(
397        "template {file}: {}{message}\nnext: {}",
398        key.as_ref().map(|key| format!("front matter key `{key}` ")).unwrap_or_default(),
399        "correct that file; a template's front matter is described in the README under \"Task templates\"."
400    )]
401    Malformed {
402        /// The chain file.
403        file: String,
404        /// The front matter key the refusal is about, when it is about one.
405        key: Option<String>,
406        /// What is wrong.
407        message: String,
408    },
409    /// Two chain files declaring one variable with different `type`s or `items`.
410    #[error(
411        "template variable {variable:?} is declared with {field} {nearer_value} in {nearer} and \
412         with {field} {farther_value} in {farther}; a redeclaration may change `description`, \
413         `default` and `required`, never `type` or `items`\n\
414         next: declare it with the same {field} in both files, or rename one of them."
415    )]
416    ChainConflict {
417        /// The variable.
418        variable: String,
419        /// Which field differs.
420        field: ChainField,
421        /// The file nearer the rendered template.
422        nearer: String,
423        /// What that file declares.
424        nearer_value: &'static str,
425        /// The file farther from it.
426        farther: String,
427        /// What that file declares.
428        farther_value: &'static str,
429    },
430    /// An answers document that is not a mapping of answers.
431    #[error(
432        "the answers document is refused: {message}\n\
433         next: write the answers as a YAML mapping from variable name to value."
434    )]
435    MalformedAnswers {
436        /// What is wrong with it.
437        message: String,
438    },
439    /// Answers naming no declared variable.
440    #[error(
441        "{} no variable the template declares: {}\n\
442         next: remove {}, or declare {} in the template's front matter — `onetaskgraph template \
443         variables` lists what it declares.",
444        if names.len() == 1 { "an answer names" } else { "answers name" },
445        names.join(", "),
446        if names.len() == 1 { "that answer" } else { "those answers" },
447        if names.len() == 1 { "it" } else { "them" }
448    )]
449    UnknownAnswer {
450        /// Every undeclared name answered, in name order.
451        names: Vec<String>,
452    },
453    /// An answer that is not a value of its variable's type.
454    #[error(
455        "the answer to {name:?} is not {expected}: {problem}\n\
456         next: give {name} a value of type {kind}."
457    )]
458    MistypedAnswer {
459        /// The variable.
460        name: String,
461        /// Its type.
462        kind: VariableType,
463        /// Its type with an article, for the message.
464        expected: String,
465        /// Why the answer is not one.
466        problem: String,
467    },
468    /// Required variables no answer and no default covers.
469    #[error(
470        "{} unanswered: {}\n\
471         next: answer {} with --var NAME=VALUE or in an answers file (--answers FILE), or run \
472         interactively to be asked.",
473        if names.len() == 1 { "a required variable is" } else { "required variables are" },
474        names.join(", "),
475        if names.len() == 1 { "it" } else { "each" }
476    )]
477    MissingRequired {
478        /// Every one, in declaration order.
479        names: Vec<String>,
480    },
481    /// The render itself failed: an undefined name, or an error a filter or tag raised.
482    #[error(
483        "template {}{}: {message}\n\
484         next: {}",
485        file.as_deref().unwrap_or("?"),
486        line.map(|line| format!(" line {line}")).unwrap_or_default(),
487        match name {
488            Some(name) => format!(
489                "declare {name} in the front matter of a file in the chain, or set it in the \
490                 template before it is used."
491            ),
492            None => "correct the template at that line.".to_owned(),
493        }
494    )]
495    Render {
496        /// The chain file the render failed in.
497        file: Option<String>,
498        /// The file's own line, front matter counted.
499        line: Option<usize>,
500        /// The undefined name, when that is what failed.
501        name: Option<String>,
502        /// What failed.
503        message: String,
504    },
505}
506
507impl TemplateError {
508    /// This product's kebab-case name for the failure.
509    #[must_use]
510    pub fn kind(&self) -> &'static str {
511        match self {
512            Self::NotFound { .. } => "template-not-found",
513            Self::Unreadable { .. } => "template-unreadable",
514            Self::SearchPath { .. } => "template-search-path",
515            Self::Malformed { .. } => "template-malformed",
516            Self::ChainConflict { .. } => "template-chain-conflict",
517            Self::MalformedAnswers { .. } => "template-answers-malformed",
518            Self::UnknownAnswer { .. } => "template-unknown-answer",
519            Self::MistypedAnswer { .. } => "template-mistyped-answer",
520            Self::MissingRequired { .. } => "template-missing-required",
521            Self::Render { .. } => "template-render",
522        }
523    }
524
525    /// Whether this refuses the answers rather than the template: an answers document that
526    /// is not one, an answer to no declared variable or of the wrong type, and required
527    /// variables left unanswered. The command line exits `2` for these.
528    #[must_use]
529    pub fn refuses_answers(&self) -> bool {
530        matches!(
531            self,
532            Self::MalformedAnswers { .. }
533                | Self::UnknownAnswer { .. }
534                | Self::MistypedAnswer { .. }
535                | Self::MissingRequired { .. }
536        )
537    }
538
539    fn malformed(file: &str, key: Option<&str>, message: impl Into<String>) -> Self {
540        Self::Malformed {
541            file: file.to_owned(),
542            key: key.map(str::to_owned),
543            message: message.into(),
544        }
545    }
546}
547
548/// Where a template and the templates it names are found.
549///
550/// Directories are searched first, in the order given, then the registered pairs — which
551/// is how a Rust consumer supplies templates embedded in its own binary. The working
552/// directory is never searched unless it is given.
553#[derive(Debug, Clone, Default)]
554pub struct TemplateLoader {
555    directories: Vec<PathBuf>,
556    registered: Vec<(String, String)>,
557}
558
559impl TemplateLoader {
560    /// A loader with an empty search path.
561    #[must_use]
562    pub fn new() -> Self {
563        Self::default()
564    }
565
566    /// Add `directory` to the end of the search path's directories.
567    #[must_use]
568    pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
569        self.directories.push(directory.into());
570        self
571    }
572
573    /// Register a template's source under `name`, searched after every directory.
574    #[must_use]
575    pub fn with_template(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
576        self.registered.push((name.into(), source.into()));
577        self
578    }
579
580    /// Load the template at `path` and every template its chain names.
581    ///
582    /// The file is known to its chain — and in its digest — by its file name.
583    ///
584    /// # Errors
585    ///
586    /// [`TemplateError::NotFound`] for a file that cannot be read or a chain name nothing
587    /// resolves; [`TemplateError::Malformed`] for a chain file that is not UTF-8, whose front
588    /// matter is refused, or whose body does not parse; [`TemplateError::ChainConflict`] for
589    /// a variable two chain files type differently; [`TemplateError::SearchPath`] for a
590    /// search path directory that is missing or is not a directory.
591    pub fn load_path(&self, path: &Path) -> Result<Template, TemplateError> {
592        self.searchable()?;
593        let shown = path.display().to_string();
594        let bytes = std::fs::read(path).map_err(|error| {
595            if error.kind() == std::io::ErrorKind::NotFound {
596                TemplateError::NotFound {
597                    name: shown.clone(),
598                    referenced_from: None,
599                    searched: Vec::new(),
600                }
601            } else {
602                TemplateError::Unreadable {
603                    name: shown.clone(),
604                    message: error.to_string(),
605                }
606            }
607        })?;
608        let source = String::from_utf8(bytes)
609            .map_err(|_| TemplateError::malformed(&shown, None, "it is not UTF-8 text"))?;
610        let name = path
611            .file_name()
612            .map_or_else(|| shown.clone(), |name| name.to_string_lossy().into_owned());
613        self.load_chain(name, source)
614    }
615
616    /// Load the template the search path resolves `name` to, and every template its chain
617    /// names.
618    ///
619    /// # Errors
620    ///
621    /// As [`TemplateLoader::load_path`], and [`TemplateError::NotFound`] when the search
622    /// path resolves `name` to nothing.
623    pub fn load_name(&self, name: &str) -> Result<Template, TemplateError> {
624        self.searchable()?;
625        let source = self.find(name)?.ok_or_else(|| self.not_found(name, None))?;
626        self.load_chain(name.to_owned(), source)
627    }
628
629    /// The source the search path resolves `name` to, or `None`.
630    ///
631    /// A name spelled to climb out of the search path (`..`), or an absolute one, is looked
632    /// for among the registered pairs alone, so a template cannot name its way to a file
633    /// outside the directories it was given. A symbolic link inside one of those directories
634    /// is followed like any other file there: the check is on how the name is spelled, and
635    /// whoever put the link in a search directory chose what it reaches.
636    fn find(&self, name: &str) -> Result<Option<String>, TemplateError> {
637        let path = Path::new(name);
638        let spelled_within = !name.is_empty()
639            && path
640                .components()
641                .all(|component| matches!(component, Component::Normal(_)));
642        if spelled_within {
643            for directory in &self.directories {
644                let candidate = directory.join(path);
645                if candidate.is_file() {
646                    let bytes =
647                        std::fs::read(&candidate).map_err(|error| TemplateError::Unreadable {
648                            name: candidate.display().to_string(),
649                            message: error.to_string(),
650                        })?;
651                    return String::from_utf8(bytes)
652                        .map(Some)
653                        .map_err(|_| TemplateError::malformed(name, None, "it is not UTF-8 text"));
654                }
655            }
656        }
657        Ok(self
658            .registered
659            .iter()
660            .find(|(registered, _)| registered == name)
661            .map(|(_, source)| source.clone()))
662    }
663
664    fn not_found(&self, name: &str, referenced_from: Option<&str>) -> TemplateError {
665        TemplateError::NotFound {
666            name: name.to_owned(),
667            referenced_from: referenced_from.map(str::to_owned),
668            searched: self
669                .directories
670                .iter()
671                .map(|directory| directory.display().to_string())
672                .collect(),
673        }
674    }
675
676    /// Read the whole chain from its root, depth first, put it in first-load order and merge
677    /// its declarations.
678    ///
679    /// First-load order is the order a render with every variable at its default reads the
680    /// files. A file that render cannot read, found by an expression, is not this chain's
681    /// yet: the answers that name it reach it through [`Template::expand`], which refuses it.
682    fn load_chain(&self, root: String, source: String) -> Result<Template, TemplateError> {
683        let resolutions = Resolutions::new();
684        let files = self.build(root.clone(), source, &resolutions)?;
685        let variables = merge(&files)?;
686        let Discovered { read, .. } = discover(&root, &files, &variables, self, &Answers::new());
687        let files = in_read_order(files, &read);
688        let variables = merge(&files)?;
689        Ok(Template::assemble(
690            root,
691            files,
692            variables,
693            resolutions,
694            self.clone(),
695        ))
696    }
697
698    /// Refuse a search path directory that is missing or is not a directory.
699    fn searchable(&self) -> Result<(), TemplateError> {
700        for directory in &self.directories {
701            let refused = |message: String| TemplateError::SearchPath {
702                directory: directory.display().to_string(),
703                message,
704            };
705            match std::fs::metadata(directory) {
706                Ok(metadata) if metadata.is_dir() => {}
707                Ok(_) => return Err(refused("it is not a directory".to_owned())),
708                Err(error) => return Err(refused(error.to_string())),
709            }
710        }
711        Ok(())
712    }
713
714    /// Read the chain from `name`, whose source is `source`: that file, then every file it
715    /// names, depth first in the order its tags name them, skipping any already read. A tag
716    /// naming its template by a literal names what it spells; one naming it by an expression
717    /// names what `resolutions` records it evaluating to, in the order it was reached. That
718    /// is only the order the chain is found in: its callers put it in first-load order before
719    /// a digest or a nearness tie is taken over it.
720    fn build(
721        &self,
722        name: String,
723        source: String,
724        resolutions: &Resolutions,
725    ) -> Result<Vec<ChainFile>, TemplateError> {
726        let mut files = Vec::new();
727        let mut seen: HashSet<String> = HashSet::new();
728        let mut pending = vec![(name, source)];
729        // A stack, pushed in reverse, so the first name a file spells is the next one read.
730        while let Some((name, source)) = pending.pop() {
731            if !seen.insert(name.clone()) {
732                continue;
733            }
734            let split = front_matter::split(&name, &source)?;
735            compile_check(&name, &split.body, split.offset_lines)?;
736            let mut names = Vec::new();
737            let mut children = Vec::new();
738            for named in scan::named(&split.body) {
739                let references = match named {
740                    scan::Named::Literal(reference) => vec![reference],
741                    // What the render named there resolves or fails in the render itself.
742                    scan::Named::Expression { ordinal, .. } => resolutions
743                        .get(&NamingTag {
744                            file: name.clone(),
745                            ordinal,
746                        })
747                        .into_iter()
748                        .flatten()
749                        .map(|candidates| scan::Reference {
750                            candidates: candidates.names().to_vec(),
751                            optional: true,
752                        })
753                        .collect(),
754                };
755                for reference in references {
756                    // The first candidate that resolves is the one the render loads, so it is
757                    // the one that belongs to the chain; the rest are not read.
758                    let mut resolved = false;
759                    for candidate in &reference.candidates {
760                        if seen.contains(candidate) || *candidate == name {
761                            names.push(candidate.clone());
762                            resolved = true;
763                            break;
764                        }
765                        if let Some(source) = self.find(candidate)? {
766                            names.push(candidate.clone());
767                            children.push((candidate.clone(), source));
768                            resolved = true;
769                            break;
770                        }
771                    }
772                    if !resolved && !reference.optional {
773                        return Err(self.not_found(&reference.candidates.join(" or "), Some(&name)));
774                    }
775                }
776            }
777            pending.extend(children.into_iter().rev());
778            files.push(ChainFile {
779                name,
780                source,
781                body: split.body,
782                offset_lines: split.offset_lines,
783                declarations: split.declarations,
784                names,
785            });
786        }
787        Ok(files)
788    }
789}
790
791/// A tag naming a template by an expression: the chain file it is written in, and which of
792/// that file's such tags it is, counting from zero.
793#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
794struct NamingTag {
795    file: String,
796    ordinal: usize,
797}
798
799/// What one evaluation of a naming expression gave: a name, or a list of them of which the
800/// first that resolves is loaded. Never empty.
801#[derive(Debug, Clone, PartialEq, Eq)]
802struct Candidates(Vec<String>);
803
804impl Candidates {
805    /// The names `value` gives, or `None` when it gives none — it is neither a string nor a
806    /// sequence holding one.
807    fn of(value: &minijinja::Value) -> Option<Self> {
808        let names: Vec<String> = match value.as_str() {
809            Some(name) => vec![name.to_owned()],
810            None if value.kind() == minijinja::value::ValueKind::Seq => value
811                .try_iter()
812                .into_iter()
813                .flatten()
814                .filter_map(|item| item.as_str().map(str::to_owned))
815                .collect(),
816            None => Vec::new(),
817        };
818        (!names.is_empty()).then_some(Self(names))
819    }
820
821    fn names(&self) -> &[String] {
822        &self.0
823    }
824}
825
826/// What each naming tag was found to name in one render: every distinct value it gave, in the
827/// order the render first reached it.
828type Resolutions = BTreeMap<NamingTag, Vec<Candidates>>;
829
830/// The body of each of `files` as [`discover`] renders it: with every expression that names a
831/// template wrapped in [`scan::HOOK`], so the render reports what it named.
832fn hooked_bodies(files: &[ChainFile]) -> HashMap<String, String> {
833    files
834        .iter()
835        .enumerate()
836        .map(|(index, file)| (file.name.clone(), scan::hooked(&file.body, index)))
837        .collect()
838}
839
840/// Give `environment` the function [`hooked_bodies`] calls, and answer where what it
841/// records collects.
842fn record_names(
843    environment: &mut minijinja::Environment<'static>,
844    files: &[ChainFile],
845) -> Arc<Mutex<Resolutions>> {
846    let names: Vec<String> = files.iter().map(|file| file.name.clone()).collect();
847    let recorded: Arc<Mutex<Resolutions>> = Arc::default();
848    let sink = Arc::clone(&recorded);
849    environment.add_function(
850        scan::HOOK,
851        move |file: usize, ordinal: usize, value: minijinja::Value| {
852            if let Some(name) = names.get(file)
853                && let Some(candidates) = Candidates::of(&value)
854            {
855                let mut recorded = sink.lock().unwrap_or_else(PoisonError::into_inner);
856                let found = recorded
857                    .entry(NamingTag {
858                        file: name.clone(),
859                        ordinal,
860                    })
861                    .or_default();
862                if !found.contains(&candidates) {
863                    found.push(candidates);
864                }
865            }
866            value
867        },
868    );
869    recorded
870}
871
872/// Refuse a body that does not parse, before anything renders it.
873fn compile_check(name: &str, body: &str, offset_lines: usize) -> Result<(), TemplateError> {
874    let environment = environment();
875    environment
876        .template_from_named_str(name, body)
877        .map(|_| ())
878        .map_err(|error| TemplateError::Malformed {
879            file: name.to_owned(),
880            key: None,
881            message: format!(
882                "its body does not parse{}: {}",
883                error
884                    .line()
885                    .map(|line| format!(" at line {}", line + offset_lines))
886                    .unwrap_or_default(),
887                error.detail().unwrap_or("a syntax error")
888            ),
889        })
890}
891
892/// One file of a loaded chain.
893#[derive(Debug, Clone)]
894struct ChainFile {
895    name: String,
896    source: String,
897    body: String,
898    offset_lines: usize,
899    declarations: Vec<TemplateVariable>,
900    /// The chain files its tags name, in the order they name them: what nearness is measured
901    /// over.
902    names: Vec<String>,
903}
904
905/// Merge every chain file's declarations into the declared set.
906///
907/// Nearness is the fewest references from the rendered file, ties going to the file earlier
908/// in `files`; the order is theirs, each variable where it was first declared.
909fn merge(files: &[ChainFile]) -> Result<Vec<TemplateVariable>, TemplateError> {
910    let index_of: HashMap<&str, usize> = files
911        .iter()
912        .enumerate()
913        .map(|(index, file)| (file.name.as_str(), index))
914        .collect();
915    let mut depth = vec![usize::MAX; files.len()];
916    if !files.is_empty() {
917        depth[0] = 0;
918    }
919    let mut queue = VecDeque::from([0usize]);
920    while let Some(at) = queue.pop_front() {
921        for child in &files[at].names {
922            if let Some(&child) = index_of.get(child.as_str())
923                && depth[child] == usize::MAX
924            {
925                depth[child] = depth[at] + 1;
926                queue.push_back(child);
927            }
928        }
929    }
930    let mut nearness: Vec<usize> = (0..files.len()).collect();
931    nearness.sort_by_key(|&index| (depth[index], index));
932
933    let mut merged: BTreeMap<&str, TemplateVariable> = BTreeMap::new();
934    for &index in &nearness {
935        let file = &files[index];
936        for declaration in &file.declarations {
937            match merged.get(declaration.name.as_str()) {
938                None => {
939                    merged.insert(&declaration.name, declaration.clone());
940                }
941                Some(nearer) => {
942                    if nearer.kind != declaration.kind {
943                        return Err(conflict(
944                            nearer,
945                            ChainField::Type,
946                            nearer.kind.as_str(),
947                            &file.name,
948                            declaration.kind.as_str(),
949                            &declaration.name,
950                        ));
951                    }
952                    if nearer.items != declaration.items {
953                        let spell =
954                            |items: Option<ItemType>| items.map_or("none", ItemType::as_str);
955                        return Err(conflict(
956                            nearer,
957                            ChainField::Items,
958                            spell(nearer.items),
959                            &file.name,
960                            spell(declaration.items),
961                            &declaration.name,
962                        ));
963                    }
964                }
965            }
966        }
967    }
968
969    let mut ordered = Vec::with_capacity(merged.len());
970    for file in files {
971        for declaration in &file.declarations {
972            if let Some(variable) = merged.remove(declaration.name.as_str()) {
973                ordered.push(variable);
974            }
975        }
976    }
977    Ok(ordered)
978}
979
980fn conflict(
981    nearer: &TemplateVariable,
982    field: ChainField,
983    nearer_value: &'static str,
984    farther: &str,
985    farther_value: &'static str,
986    variable: &str,
987) -> TemplateError {
988    TemplateError::ChainConflict {
989        variable: variable.to_owned(),
990        field,
991        nearer: nearer.declared_in.clone(),
992        nearer_value,
993        farther: farther.to_owned(),
994        farther_value,
995    }
996}
997
998/// The digest over `files`, each a resolved name and its full source, in first-load order.
999fn digest<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
1000    let mut hasher = Sha256::new();
1001    for (name, source) in files {
1002        hasher.update(name.as_bytes());
1003        hasher.update([0]);
1004        hasher.update(source.as_bytes());
1005        hasher.update([0]);
1006    }
1007    let hash = hasher.finalize();
1008    let mut rendered = String::with_capacity(7 + hash.len() * 2);
1009    rendered.push_str("sha256:");
1010    for byte in hash {
1011        rendered.push_str(&format!("{byte:02x}"));
1012    }
1013    rendered
1014}
1015
1016/// The renderer every template is compiled and rendered in.
1017fn environment() -> minijinja::Environment<'static> {
1018    let mut environment = minijinja::Environment::new();
1019    environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
1020    environment.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
1021    environment.set_keep_trailing_newline(true);
1022    environment.set_trim_blocks(true);
1023    environment.set_lstrip_blocks(true);
1024    environment
1025}
1026
1027/// A loaded template: its chain, its declared set and its digest.
1028#[derive(Debug, Clone)]
1029pub struct Template {
1030    name: String,
1031    files: Vec<ChainFile>,
1032    variables: Vec<TemplateVariable>,
1033    /// What the tags naming a template by an expression were found to name.
1034    resolutions: Resolutions,
1035    digest: String,
1036    loader: TemplateLoader,
1037}
1038
1039/// What `onetaskgraph template variables` answers with: a template's declared set.
1040#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
1041pub struct TemplateVariables {
1042    /// The rendered template's resolved name.
1043    pub template: String,
1044    /// The chain's digest: `sha256:` and 64 lowercase hex digits, over every file it reads
1045    /// in first-load order — each template an expression names counted as it names one when
1046    /// every variable takes its default.
1047    pub digest: String,
1048    /// Every declared variable, in declaration order along the chain.
1049    pub variables: Vec<TemplateVariable>,
1050}
1051
1052/// A rendered template: what `onetaskgraph template render` answers with.
1053#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
1054pub struct RenderedTemplate {
1055    /// The rendered text.
1056    pub body: String,
1057    /// The digest of every file of the chain these answers render, in first-load order:
1058    /// `sha256:` and 64 lowercase hex digits. That is the order the render first read each
1059    /// file, a template an expression named included, then any file of the chain it did not
1060    /// read.
1061    pub digest: String,
1062    /// Every declared variable and the value it rendered with — an answer, a default, or
1063    /// `null` for an optional variable given neither.
1064    pub answers: BTreeMap<String, Value>,
1065}
1066
1067impl Template {
1068    fn assemble(
1069        name: String,
1070        files: Vec<ChainFile>,
1071        variables: Vec<TemplateVariable>,
1072        resolutions: Resolutions,
1073        loader: TemplateLoader,
1074    ) -> Self {
1075        let digest = digest(
1076            files
1077                .iter()
1078                .map(|file| (file.name.as_str(), file.source.as_str())),
1079        );
1080        Self {
1081            name,
1082            files,
1083            variables,
1084            resolutions,
1085            digest,
1086            loader,
1087        }
1088    }
1089
1090    /// This template with every template an expression names for `answers` added to its
1091    /// chain — each with the files its own literals name — and their front matter merged into
1092    /// the declared set under the same rules as the rest of the chain.
1093    ///
1094    /// Which file an expression names can depend on the answers, so it is found by rendering:
1095    /// leniently, from the answers that fit their declarations and the defaults of the rest,
1096    /// every tag naming a template by an expression reporting what it named. A file found that
1097    /// way may declare a variable, or a nearer default, that decides what an expression
1098    /// names, so it repeats until a render names what the one before it did; a file only an
1099    /// earlier render named is not part of the chain. Each file so found joins the chain at
1100    /// the tag that names it, exactly as a file a literal names would: its nearness is its own
1101    /// distance from the rendered template. The expanded chain is in first-load order — the
1102    /// order that last render first read each file, then any it did not read. A template
1103    /// named by no expression expands to its own files in that order.
1104    ///
1105    /// [`Template::unanswered`], [`Template::resolve`] and [`Template::render`] each expand
1106    /// first, so a caller need not.
1107    ///
1108    /// # Errors
1109    ///
1110    /// [`TemplateError::Malformed`], [`TemplateError::Unreadable`] and
1111    /// [`TemplateError::NotFound`] for a file found this way, or one its literals name, as for
1112    /// any chain file; [`TemplateError::ChainConflict`] for a declaration of it whose `type`
1113    /// or `items` another chain file's contradicts; and [`TemplateError::Malformed`] for the
1114    /// rendered template when the files its expressions name give defaults that name one
1115    /// another in turn, so that what they name never settles.
1116    pub fn expand(&self, answers: &Answers) -> Result<Self, TemplateError> {
1117        let mut expanded = self.clone();
1118        let mut named = vec![expanded.resolutions.clone()];
1119        loop {
1120            let Discovered {
1121                resolutions,
1122                read,
1123                failure,
1124            } = discover(
1125                &expanded.name,
1126                &expanded.files,
1127                &expanded.variables,
1128                &expanded.loader,
1129                answers,
1130            );
1131            if let Some(error) = failure {
1132                return Err(error);
1133            }
1134            if resolutions == expanded.resolutions {
1135                let files = in_read_order(expanded.files, &read);
1136                let variables = merge(&files)?;
1137                return Ok(Self::assemble(
1138                    self.name.clone(),
1139                    files,
1140                    variables,
1141                    resolutions,
1142                    self.loader.clone(),
1143                ));
1144            }
1145            if named.contains(&resolutions) {
1146                return Err(TemplateError::malformed(
1147                    &self.name,
1148                    None,
1149                    "what its expressions name never settles: each template they name gives a \
1150                     default naming another, round a cycle; answer the variable that decides it",
1151                ));
1152            }
1153            named.push(resolutions.clone());
1154            let files = expanded.rebuild(&resolutions)?;
1155            let variables = merge(&files)?;
1156            expanded = Self::assemble(
1157                self.name.clone(),
1158                files,
1159                variables,
1160                resolutions,
1161                self.loader.clone(),
1162            );
1163        }
1164    }
1165
1166    fn rebuild(&self, resolutions: &Resolutions) -> Result<Vec<ChainFile>, TemplateError> {
1167        let root = self
1168            .files
1169            .first()
1170            .map_or_else(String::new, |file| file.source.clone());
1171        self.loader.build(self.name.clone(), root, resolutions)
1172    }
1173
1174    /// The rendered template's resolved name.
1175    #[must_use]
1176    pub fn name(&self) -> &str {
1177        &self.name
1178    }
1179
1180    /// The chain's digest.
1181    #[must_use]
1182    pub fn digest(&self) -> &str {
1183        &self.digest
1184    }
1185
1186    /// The declared set, in declaration order along the chain.
1187    #[must_use]
1188    pub fn variables(&self) -> &[TemplateVariable] {
1189        &self.variables
1190    }
1191
1192    /// The resolved name of every file of the chain, in first-load order: the files
1193    /// [`Template::digest`] is taken over, in its order. For a loaded template these are the
1194    /// rendered one and each `extends`, `include` and `import` its literals reach; what
1195    /// [`Template::expand`] answers adds each template an expression names.
1196    pub fn chain(&self) -> impl Iterator<Item = &str> {
1197        self.files.iter().map(|file| file.name.as_str())
1198    }
1199
1200    /// The declared set as `template variables` reports it.
1201    #[must_use]
1202    pub fn describe(&self) -> TemplateVariables {
1203        TemplateVariables {
1204            template: self.name.clone(),
1205            digest: self.digest.clone(),
1206            variables: self.variables.clone(),
1207        }
1208    }
1209
1210    /// The declared variables `answers` leaves unanswered, in declaration order — whether or
1211    /// not a default covers them — over the chain [`Template::expand`] reaches for `answers`.
1212    /// What a prompting caller asks for; answering one can name a further template, so a
1213    /// caller asks again until nothing new is left.
1214    ///
1215    /// # Errors
1216    ///
1217    /// [`TemplateError::UnknownAnswer`] and [`TemplateError::MistypedAnswer`], as
1218    /// [`Template::render`] refuses them.
1219    pub fn unanswered(&self, answers: &Answers) -> Result<Vec<TemplateVariable>, TemplateError> {
1220        let expanded = self.expand(answers)?;
1221        let typed = expanded.typed(answers)?;
1222        Ok(expanded
1223            .variables
1224            .into_iter()
1225            .filter(|variable| !typed.contains_key(&variable.name))
1226            .collect())
1227    }
1228
1229    /// Every declared variable's value — over the chain [`Template::expand`] reaches for
1230    /// `answers` — its answer, else its default, else `null` for an optional one.
1231    ///
1232    /// # Errors
1233    ///
1234    /// [`TemplateError::UnknownAnswer`] naming every answer to no declared variable,
1235    /// [`TemplateError::MistypedAnswer`] for an answer not of its variable's type, and
1236    /// [`TemplateError::MissingRequired`] naming every required variable left unanswered.
1237    pub fn resolve(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1238        self.expand(answers)?.resolve_here(answers)
1239    }
1240
1241    /// [`Template::resolve`] over this chain as it stands.
1242    fn resolve_here(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1243        let mut typed = self.typed(answers)?;
1244        let missing: Vec<String> = self
1245            .variables
1246            .iter()
1247            .filter(|variable| {
1248                variable.required
1249                    && variable.default.is_none()
1250                    && !typed.contains_key(&variable.name)
1251            })
1252            .map(|variable| variable.name.clone())
1253            .collect();
1254        if !missing.is_empty() {
1255            return Err(TemplateError::MissingRequired { names: missing });
1256        }
1257        for variable in &self.variables {
1258            typed
1259                .entry(variable.name.clone())
1260                .or_insert_with(|| variable.default.clone().unwrap_or(Value::Null));
1261        }
1262        Ok(typed)
1263    }
1264
1265    /// Render the template from `answers`, over the chain [`Template::expand`] reaches for
1266    /// them: a template an expression names is part of the chain, its declarations part of
1267    /// the declared set the answers are held to, and its bytes part of the digest.
1268    ///
1269    /// # Errors
1270    ///
1271    /// Every refusal of [`Template::expand`] and [`Template::resolve`], and
1272    /// [`TemplateError::Render`] for a render that fails — an undefined name among them.
1273    pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
1274        self.expand(answers)?.render_here(answers)
1275    }
1276
1277    /// [`Template::render`] over this chain as it stands.
1278    ///
1279    /// The render reads only the files of this chain. [`Template::expand`] found them by
1280    /// rendering from exactly the values this render is given, so a file it names that the
1281    /// chain does not hold is one that was not there to find, and is not found here either.
1282    fn render_here(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
1283        let resolved = self.resolve_here(answers)?;
1284
1285        let offsets: HashMap<String, usize> = self
1286            .files
1287            .iter()
1288            .map(|file| (file.name.clone(), file.offset_lines))
1289            .collect();
1290        let bodies: HashMap<String, String> = self
1291            .files
1292            .iter()
1293            .map(|file| (file.name.clone(), file.body.clone()))
1294            .collect();
1295        let mut environment = environment();
1296        environment.set_loader(move |name| Ok(bodies.get(name).cloned()));
1297        let rendered = environment
1298            .get_template(&self.name)
1299            .and_then(|template| template.render(&resolved))
1300            .map_err(|error| render_error(&error, &offsets))?;
1301        Ok(RenderedTemplate {
1302            body: rendered,
1303            digest: self.digest.clone(),
1304            answers: resolved,
1305        })
1306    }
1307
1308    /// Check `answers` against the declared set and type each one.
1309    fn typed(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1310        let declared: HashMap<&str, &TemplateVariable> = self
1311            .variables
1312            .iter()
1313            .map(|variable| (variable.name.as_str(), variable))
1314            .collect();
1315        let unknown: Vec<String> = answers
1316            .names()
1317            .filter(|name| !declared.contains_key(name))
1318            .map(str::to_owned)
1319            .collect();
1320        if !unknown.is_empty() {
1321            return Err(TemplateError::UnknownAnswer { names: unknown });
1322        }
1323
1324        let mut typed = BTreeMap::new();
1325        for variable in &self.variables {
1326            let value = match answers.given(&variable.name) {
1327                None => continue,
1328                Some(Given::Value(value)) => variable.check(value).map(|()| value.clone()),
1329                Some(Given::Text(text)) => variable.parse(text),
1330            };
1331            let value = value.map_err(|problem| TemplateError::MistypedAnswer {
1332                name: variable.name.clone(),
1333                kind: variable.kind,
1334                expected: article(variable.kind),
1335                problem,
1336            })?;
1337            typed.insert(variable.name.clone(), value);
1338        }
1339        Ok(typed)
1340    }
1341}
1342
1343/// What a lenient render of `files` reads: what every tag naming a template by an
1344/// expression named, in the order the render reached them, and the order it first read each
1345/// chain file.
1346///
1347/// The render is only a way to learn what an expression names: its output is dropped, and
1348/// so is any failure of the render itself — a strict render reports those. A file it asks
1349/// for that is there and cannot be read is that file's failure, and is answered as one for
1350/// the caller to raise.
1351/// Each variable takes the answer given for it when that is one of its type, else its
1352/// default, else `none` for an optional one — as the strict render has it; a required one
1353/// with neither is left undefined, which a lenient render reads as empty.
1354fn discover(
1355    root: &str,
1356    files: &[ChainFile],
1357    variables: &[TemplateVariable],
1358    loader: &TemplateLoader,
1359    answers: &Answers,
1360) -> Discovered {
1361    let mut context = BTreeMap::new();
1362    for variable in variables {
1363        let answered = match answers.given(&variable.name) {
1364            Some(Given::Value(value)) => variable.check(value).ok().map(|()| value.clone()),
1365            Some(Given::Text(text)) => variable.parse(text).ok(),
1366            None => None,
1367        };
1368        if let Some(value) = answered.or_else(|| variable.default.clone()) {
1369            context.insert(variable.name.clone(), value);
1370        } else if !variable.required {
1371            context.insert(variable.name.clone(), Value::Null);
1372        }
1373    }
1374
1375    let bodies = hooked_bodies(files);
1376    let loader = loader.clone();
1377    let failed: Arc<Mutex<Option<TemplateError>>> = Arc::default();
1378    let failure = Arc::clone(&failed);
1379    let read: Arc<Mutex<Vec<String>>> = Arc::default();
1380    let reading = Arc::clone(&read);
1381    let mut environment = environment();
1382    environment.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
1383    let recorded = record_names(&mut environment, files);
1384    environment.set_loader(move |name| {
1385        let mut reading = reading.lock().unwrap_or_else(PoisonError::into_inner);
1386        if let Some(body) = bodies.get(name) {
1387            if !reading.iter().any(|read| read == name) {
1388                reading.push(name.to_owned());
1389            }
1390            return Ok(Some(body.clone()));
1391        }
1392        let source = match loader.find(name) {
1393            Ok(Some(source)) => source,
1394            Ok(None) => return Ok(None),
1395            Err(error) => {
1396                failure
1397                    .lock()
1398                    .unwrap_or_else(PoisonError::into_inner)
1399                    .get_or_insert(error);
1400                return Ok(None);
1401            }
1402        };
1403        Ok(Some(
1404            front_matter::split(name, &source)
1405                .map(|split| split.body)
1406                .unwrap_or_default(),
1407        ))
1408    });
1409    let _ = environment
1410        .get_template(root)
1411        .and_then(|template| template.render(&context));
1412    let failure = failed.lock().unwrap_or_else(PoisonError::into_inner).take();
1413    let resolutions = recorded
1414        .lock()
1415        .unwrap_or_else(PoisonError::into_inner)
1416        .clone();
1417    let read = read.lock().unwrap_or_else(PoisonError::into_inner).clone();
1418    Discovered {
1419        resolutions,
1420        read,
1421        failure,
1422    }
1423}
1424
1425/// What a lenient render of a chain found: what its naming expressions named, which of the
1426/// chain's files it read, in the order it first read each, and the first file it asked for
1427/// that is there and could not be read.
1428struct Discovered {
1429    resolutions: Resolutions,
1430    read: Vec<String>,
1431    failure: Option<TemplateError>,
1432}
1433
1434/// `files` in first-load order: the root, then every file a render of them read in the order
1435/// it first read it, then the files no render reaches — an untaken branch's — in the order
1436/// the chain names them.
1437fn in_read_order(files: Vec<ChainFile>, read: &[String]) -> Vec<ChainFile> {
1438    let mut files: Vec<(usize, ChainFile)> = files
1439        .into_iter()
1440        .enumerate()
1441        .map(|(index, file)| {
1442            let at = if index == 0 {
1443                0
1444            } else {
1445                read.iter()
1446                    .position(|name| *name == file.name)
1447                    .map_or(read.len() + index, |at| at + 1)
1448            };
1449            (at, file)
1450        })
1451        .collect();
1452    files.sort_by_key(|(at, _)| *at);
1453    files.into_iter().map(|(_, file)| file).collect()
1454}
1455
1456/// A minijinja failure as the template failure it is, located in the file's own lines.
1457///
1458/// A failure inside an included or imported file reaches here wrapped in one per template
1459/// it passed through; the innermost is where it happened.
1460fn render_error(error: &minijinja::Error, offsets: &HashMap<String, usize>) -> TemplateError {
1461    let mut error = error;
1462    while let Some(inner) = std::error::Error::source(error)
1463        .and_then(|source| source.downcast_ref::<minijinja::Error>())
1464    {
1465        error = inner;
1466    }
1467    let file = error.name().map(str::to_owned);
1468    let offset = file
1469        .as_deref()
1470        .and_then(|file| offsets.get(file))
1471        .copied()
1472        .unwrap_or(0);
1473    let line = error.line().map(|line| line + offset);
1474    if error.kind() == minijinja::ErrorKind::UndefinedError {
1475        let name = error
1476            .template_source()
1477            .zip(error.range())
1478            .and_then(|(source, range)| source.get(range))
1479            .map(str::trim)
1480            .filter(|name| !name.is_empty())
1481            .map(str::to_owned);
1482        return TemplateError::Render {
1483            message: match &name {
1484                Some(name) => format!(
1485                    "{name} is undefined: it is neither a declared variable nor set by the template"
1486                ),
1487                None => "a value there is undefined".to_owned(),
1488            },
1489            file,
1490            line,
1491            name,
1492        };
1493    }
1494    TemplateError::Render {
1495        file,
1496        line,
1497        name: None,
1498        message: error
1499            .detail()
1500            .map_or_else(|| error.kind().to_string(), str::to_owned),
1501    }
1502}