Skip to main content

onetaskgraph_core/engine/
rendered.rs

1//! Tasks and documents created from a body or a template, and regenerated in place.
2//!
3//! An item created or regenerated from a template records where it came from under the
4//! reserved `onetaskgraph.template` key ([`TemplateProvenance`]); one created from a plain
5//! body records none. The answers a regenerate needs are kept only where a source keeps them
6//! beside the item — `local-md`'s own file — and never in the item's content or metadata, so
7//! nothing of them is duplicated into an issue body. Every write here lands the content, the
8//! provenance and those answers in **one** call to the source, which the source makes one
9//! write.
10//!
11//! Regeneration is this engine's, and it never resolves a caller's layers or runs a caller's
12//! command: the template is the one given — a file, or a loader document a caller states —
13//! else the recorded `template` when that is a readable file, else the regenerate is refused
14//! naming the recorded reference.
15
16use std::collections::BTreeMap;
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20use onetaskgraph_plugin_api::{
21    DependencyEdge, DependencyEndpoint, DependencyKind, Document, ItemKind, ItemWrite, Label,
22    MetadataKey, MetadataRecord, NativeId, Priority, Repository, SourceName, Status,
23    StatusCategory, Task, TaskRef,
24};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use super::delivery::{qualified_task, source_failed, targets};
30use super::{Delivered, Engine, EngineError, Qualified};
31use crate::GlobalId;
32use crate::resolve::ResolvedSource;
33use crate::template::{
34    Answers, RenderedTemplate, Sha256Digest, Template, TemplateError, TemplateInput,
35    TemplateProvenance,
36};
37
38/// The content a create writes: text given as it is, or a template's rendering.
39///
40/// Built only by [`Body::plain`], [`Body::rendered`] and [`Body::rendered_as`], so a rendered
41/// body always carries the [`TemplateProvenance`] its rendering proves — a rendering whose
42/// digest is not one is refused where the body is built, never later where it is written.
43#[derive(Debug, Clone, PartialEq)]
44pub struct Body(Content);
45
46#[derive(Debug, Clone, PartialEq)]
47enum Content {
48    /// The item records no provenance and no answers are kept.
49    Plain(String),
50    /// The item records `provenance`, and a source that keeps answers keeps the rendering's
51    /// resolved answers beside it.
52    Rendered {
53        rendered: RenderedTemplate,
54        provenance: TemplateProvenance,
55    },
56}
57
58impl Body {
59    /// Text given as it is.
60    #[must_use]
61    pub fn plain(text: impl Into<String>) -> Self {
62        Self(Content::Plain(text.into()))
63    }
64
65    /// A template's rendering, recorded by the reference `template` answers.
66    ///
67    /// # Errors
68    ///
69    /// As [`Body::rendered_as`].
70    pub fn rendered(
71        template: &TemplateInput,
72        rendered: RenderedTemplate,
73    ) -> Result<Self, TemplateError> {
74        Self::rendered_as(template.reference(), rendered)
75    }
76
77    /// A template's rendering, recorded by `template` — a string of a library caller's own.
78    ///
79    /// # Errors
80    ///
81    /// [`TemplateError::Malformed`] when the rendering's `digest` is not one: a
82    /// [`RenderedTemplate`] assembled by hand rather than answered by a render.
83    pub fn rendered_as(
84        template: impl Into<String>,
85        rendered: RenderedTemplate,
86    ) -> Result<Self, TemplateError> {
87        let provenance = TemplateProvenance::of(template, &rendered)?;
88        Ok(Self(Content::Rendered {
89            rendered,
90            provenance,
91        }))
92    }
93
94    /// What a create writes of this body beside `metadata`.
95    fn parts(&self, metadata: &BTreeMap<MetadataKey, Value>) -> Parts<'_> {
96        let mut carried: BTreeMap<String, Value> = metadata
97            .iter()
98            .map(|(key, value)| (key.as_str().to_owned(), value.clone()))
99            .collect();
100        match &self.0 {
101            Content::Plain(content) => Parts {
102                content: content.clone(),
103                metadata: carried,
104                answers: None,
105            },
106            Content::Rendered {
107                rendered,
108                provenance,
109            } => {
110                carried.insert(TemplateProvenance::KEY.to_owned(), provenance.to_value());
111                Parts {
112                    content: rendered.body.clone(),
113                    metadata: carried,
114                    answers: Some(&rendered.answers),
115                }
116            }
117        }
118    }
119}
120
121/// What a create writes: the content, the metadata the item carries — the caller's keys and,
122/// for a rendering, its provenance — and the answers a source that keeps them keeps beside it.
123struct Parts<'a> {
124    content: String,
125    metadata: BTreeMap<String, Value>,
126    answers: Option<&'a BTreeMap<String, Value>>,
127}
128
129/// One task to create in one source.
130#[derive(Debug, Clone, PartialEq)]
131pub struct TaskCreate {
132    /// The configured source to create it in.
133    pub source: SourceName,
134    /// The project it is filed under, by that source's own id.
135    pub project: NativeId,
136    /// Its title.
137    pub title: String,
138    /// Its content.
139    pub body: Body,
140    /// Its status category; `todo` when none is given.
141    pub status: Option<StatusCategory>,
142    /// The names of its labels.
143    pub labels: Vec<String>,
144    /// The repositories its work changes.
145    pub repositories: Vec<Repository>,
146    /// The tasks it depends on — its own source's written without their source, any other's
147    /// qualified.
148    pub depends_on: Vec<GlobalId>,
149    /// The tasks it delivers. Each is kept in step with it once it lands, as every write of a
150    /// task that delivers keeps them.
151    pub delivers: Vec<GlobalId>,
152    /// The caller's own metadata keys — never one this product reserves, which a
153    /// [`MetadataKey`] cannot name.
154    pub metadata: BTreeMap<MetadataKey, Value>,
155}
156
157/// One project document to create, or replace, in one source.
158#[derive(Debug, Clone, PartialEq)]
159pub struct DocumentCreate {
160    /// The configured source to write it in.
161    pub source: SourceName,
162    /// The project it is filed under, by that source's own id.
163    pub project: NativeId,
164    /// Its title.
165    pub title: String,
166    /// The id to write it under: a document the source already holds by this id is
167    /// replaced, and otherwise one is created under it where the source lets a caller name
168    /// what it creates. `None` creates one under an id derived from the title.
169    pub id: Option<NativeId>,
170    /// Its content.
171    pub body: Body,
172    /// The names of its labels.
173    pub labels: Vec<String>,
174    /// The repositories it concerns.
175    pub repositories: Vec<Repository>,
176    /// The caller's own metadata keys.
177    pub metadata: BTreeMap<MetadataKey, Value>,
178}
179
180/// What creating a task came to: the task as its source reads it back, and every task it
181/// delivers, kept in step with it.
182#[derive(Debug, Clone, PartialEq)]
183pub struct TaskCreated {
184    /// The task, under its qualified id.
185    pub task: Qualified<Task>,
186    /// One entry per task it delivers; empty when it delivers none.
187    pub delivered: Vec<Delivered>,
188}
189
190/// Which kind of item a regenerate or an answers read names.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum RenderedRecord {
193    /// A task.
194    Task,
195    /// A project document.
196    Document,
197}
198
199impl fmt::Display for RenderedRecord {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter.write_str(self.noun())
202    }
203}
204
205impl RenderedRecord {
206    fn noun(self) -> &'static str {
207        match self {
208            Self::Task => "task",
209            Self::Document => "document",
210        }
211    }
212
213    fn no_such(self, id: &GlobalId) -> EngineError {
214        match self {
215            Self::Task => EngineError::NoSuchTask { id: id.to_string() },
216            Self::Document => EngineError::NoSuchDocument { id: id.to_string() },
217        }
218    }
219}
220
221/// Which template a regenerate renders.
222///
223/// One of two, rather than an optional template beside a search path: a search path means
224/// something only for the recorded file, and a given template carries its own.
225#[derive(Debug, Clone, PartialEq)]
226pub enum RenderTemplate {
227    /// The template given: a file with the directories its chain resolves over, or a loader
228    /// document.
229    Given(TemplateInput),
230    /// The `template` the item records, when that is a readable file, its chain resolved over
231    /// `search_path`. A recorded reference that is not a file is refused, never resolved.
232    Recorded {
233        /// The directories the recorded file's chain resolves over.
234        search_path: Vec<PathBuf>,
235    },
236}
237
238impl Default for RenderTemplate {
239    fn default() -> Self {
240        Self::Recorded {
241            search_path: Vec::new(),
242        }
243    }
244}
245
246/// What `task render` and `document render` are asked.
247#[derive(Debug, Clone, PartialEq, Default)]
248pub struct RenderRequest {
249    /// The template to render.
250    pub template: RenderTemplate,
251    /// The answers laid over the base: the stored ones when they are in step with the item's
252    /// provenance, none otherwise. An unset name drops its answer.
253    pub answers: Answers,
254    /// Read and render everything, and write nothing.
255    pub dry_run: bool,
256}
257
258/// What `task render` and `document render` answer with.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct Regenerated {
261    /// The item regenerated.
262    pub id: GlobalId,
263    /// The chain digest it rendered with.
264    pub digest: Sha256Digest,
265    /// The SHA-256 of the rendered content: what its provenance now records.
266    pub body_digest: Sha256Digest,
267    /// Whether its content, its provenance or its stored answers differ from what it held —
268    /// what a write changed, or for a dry run what one would change.
269    pub changed: bool,
270    /// The rendered content.
271    pub body: String,
272}
273
274/// What `task answers` and `document answers` answer with: the resolved answers an item was
275/// last rendered from — defaults applied, `null` for an optional variable given neither — by
276/// variable name, exactly as its source keeps them.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
278#[serde(transparent)]
279pub struct TemplateAnswers(pub BTreeMap<String, Value>);
280
281impl TemplateAnswers {
282    /// The answers as a YAML mapping — the document an answers file holds, so what this
283    /// prints can be handed back with `--answers`.
284    ///
285    /// # Errors
286    ///
287    /// Why the answers could not be written as YAML.
288    pub fn to_yaml(&self) -> Result<String, String> {
289        serde_norway::to_string(&self.0).map_err(|error| error.to_string())
290    }
291}
292
293/// Why a regenerate did not start from the answers stored beside the item.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub enum UnusedAnswers {
296    /// The item records no template provenance, so nothing says which answers are its.
297    NoProvenance,
298    /// Its source keeps no answers beside it — it keeps none at all, or none for this item.
299    NoneStored {
300        /// The configured name of its source.
301        source: String,
302    },
303    /// The answers stored beside it do not hash to the `answers_digest` its provenance
304    /// records.
305    OutOfStep,
306    /// Its `onetaskgraph.template` entry is not one this product writes, so nothing trusted says
307    /// which answers are its.
308    MalformedProvenance {
309        /// What is wrong with the entry.
310        problem: String,
311    },
312}
313
314impl fmt::Display for UnusedAnswers {
315    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316        match self {
317            Self::NoProvenance => formatter.write_str(
318                "it records no template provenance, so no stored answers are known to be its",
319            ),
320            Self::NoneStored { source } => write!(
321                formatter,
322                "source {source} holds no stored answers for it — only a source that keeps an \
323                 authoring file, such as local-md, stores them"
324            ),
325            Self::OutOfStep => formatter.write_str(
326                "the answers stored beside it do not hash to the answers_digest its provenance \
327                 records, so they changed after it was rendered and are not trusted",
328            ),
329            Self::MalformedProvenance { problem } => write!(
330                formatter,
331                "{problem}, so nothing trusted says which stored answers are its"
332            ),
333        }
334    }
335}
336
337/// Everything a regenerate reads before it renders: the item's own content and provenance,
338/// the template, and the answers a render starts from.
339///
340/// [`Engine::regeneration`] answers one and [`Engine::regenerate`] renders and writes it — the
341/// two halves apart so a caller that asks for answers, as the command line does when
342/// interactive, can ask between them. [`Engine::render_task`] and
343/// [`Engine::render_document`] are the two together.
344#[derive(Debug, Clone)]
345pub struct Regeneration {
346    id: GlobalId,
347    record: RenderedRecord,
348    template: Template,
349    reference: String,
350    base: Answers,
351    stored: Stored,
352    /// Whether its source keeps answers beside it, so that answers it does not hold are a
353    /// difference a write repairs rather than the source's nature.
354    keeps_answers: bool,
355    content: String,
356    provenance: Option<TemplateProvenance>,
357}
358
359/// The answers stored beside an item, and whether a render starts from them — one or the
360/// other, never both: answers that are the base are in step with the provenance, and answers
361/// that are not carry why.
362#[derive(Debug, Clone)]
363enum Stored {
364    /// In step with the item's provenance, so a render starts from them.
365    Trusted(BTreeMap<String, Value>),
366    /// Not a base, for `reason`. What is held, if anything, is still what a render's own
367    /// answers are compared with to say whether the item changed.
368    Unused {
369        reason: UnusedAnswers,
370        held: Option<BTreeMap<String, Value>>,
371    },
372}
373
374impl Stored {
375    /// Whatever answers the item holds, trusted or not.
376    fn held(&self) -> Option<&BTreeMap<String, Value>> {
377        match self {
378            Self::Trusted(answers) => Some(answers),
379            Self::Unused { held, .. } => held.as_ref(),
380        }
381    }
382}
383
384impl Regeneration {
385    /// The item being regenerated.
386    #[must_use]
387    pub fn id(&self) -> &GlobalId {
388        &self.id
389    }
390
391    /// The template it renders.
392    #[must_use]
393    pub fn template(&self) -> &Template {
394        &self.template
395    }
396
397    /// The answers a render starts from, before any new ones are laid over them.
398    #[must_use]
399    pub fn base(&self) -> &Answers {
400        &self.base
401    }
402
403    /// Every variable the stored answers settle when they are the base — an optional one
404    /// they left `null` included, which a render leaves unanswered again — and none when
405    /// they are not.
406    pub fn settled(&self) -> impl Iterator<Item = &str> {
407        let trusted = match &self.stored {
408            Stored::Trusted(answers) => Some(answers),
409            Stored::Unused { .. } => None,
410        };
411        trusted
412            .into_iter()
413            .flat_map(BTreeMap::keys)
414            .map(String::as_str)
415    }
416
417    /// Why the stored answers were not the base, when they were not.
418    #[must_use]
419    pub fn unused(&self) -> Option<&UnusedAnswers> {
420        match &self.stored {
421            Stored::Trusted(_) => None,
422            Stored::Unused { reason, .. } => Some(reason),
423        }
424    }
425
426    /// Render from the base with `answers` laid over it.
427    ///
428    /// A stored answer to a variable the template no longer declares is dropped rather than
429    /// refused: it was this product's to keep, not the caller's to answer for.
430    ///
431    /// # Errors
432    ///
433    /// [`EngineError::MissingAnswers`] naming every required variable left unanswered and
434    /// why the stored answers were not used; [`EngineError::Template`] for any other refusal
435    /// of the answers or of the template.
436    pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, EngineError> {
437        let mut base = self.base.clone();
438        loop {
439            match self.template.render(&base.overlay(answers)) {
440                Err(TemplateError::UnknownAnswer { names })
441                    if names.iter().all(|name| {
442                        base.names().any(|held| held == name)
443                            && !answers.names().any(|given| given == name)
444                    }) =>
445                {
446                    for name in names {
447                        base.unset(name);
448                    }
449                }
450                Err(TemplateError::MissingRequired { names }) => {
451                    return Err(EngineError::MissingAnswers {
452                        id: self.id.to_string(),
453                        names,
454                        reason: self.unused().map_or_else(
455                            || {
456                                "the stored answers were used and do not answer them: an \
457                                 answer unset falls back to its default, and these have none"
458                                    .to_owned()
459                            },
460                            |unused| format!("the stored answers were not used because {unused}"),
461                        ),
462                    });
463                }
464                Err(error) => return Err(EngineError::Template { error }),
465                Ok(rendered) => return Ok(rendered),
466            }
467        }
468    }
469}
470
471impl Engine {
472    /// Create one task, from a plain body or a template's rendering, and keep every task it
473    /// delivers in step with it.
474    ///
475    /// The content, the provenance and — where the source keeps them — the answers land in
476    /// one write. A delivered task that cannot be kept in step is not an error: it is
477    /// reported `failed` beside the task that was created.
478    ///
479    /// # Errors
480    ///
481    /// [`EngineError::UnknownSource`] and [`EngineError::SourceUnavailable`] for a source that
482    /// cannot be reached, [`EngineError::NotCreatable`] for one with no write side — neither is
483    /// written — and [`EngineError::SourceFailed`] when the source refuses the task.
484    pub async fn create_task(&self, request: &TaskCreate) -> Result<TaskCreated, EngineError> {
485        // A task goes where its repositories route it from the source named. Routed away, it
486        // is filed under the named project's member project in the source it lands in.
487        let placement = self.place(&request.source, &request.repositories);
488        let near = &placement.destination;
489        let source = self.creatable(near, MetadataRecord::Task)?;
490        let filed = if near == &request.source {
491            None
492        } else {
493            let home = GlobalId::new(request.source.clone(), request.project.clone());
494            self.creatable(&request.source, MetadataRecord::Task)?;
495            let filed = self.member_project(&home, near).await?;
496            if filed.project.is_none() {
497                return Err(EngineError::NoSuchProject {
498                    id: home.to_string(),
499                });
500            }
501            Some(filed)
502        };
503        let project = filed
504            .as_ref()
505            .and_then(|filed| filed.project.clone())
506            .unwrap_or_else(|| request.project.clone());
507        let Parts {
508            content,
509            metadata,
510            answers,
511        } = request.body.parts(&request.metadata);
512        let category = request.status.unwrap_or(StatusCategory::Todo);
513        let id = NativeId::from(slug(&request.title, "task").as_str());
514        let depends_on = request
515            .depends_on
516            .iter()
517            .map(|far| DependencyEdge {
518                from: DependencyEndpoint::from_native(id.clone(), ItemKind::Task),
519                to: endpoint(far, near),
520                kind: DependencyKind::Blocks,
521            })
522            .collect();
523        let write = ItemWrite {
524            target: None,
525            item: Task {
526                id,
527                key: None,
528                title: request.title.clone(),
529                content: Some(content),
530                status: Status {
531                    category,
532                    name: category_word(category),
533                },
534                priority: Priority::None,
535                labels: labels(&request.labels),
536                project: Some(project),
537                url: None,
538                location: None,
539                created_at: None,
540                updated_at: None,
541                metadata,
542                repositories: request.repositories.clone(),
543                delivers: request
544                    .delivers
545                    .iter()
546                    .map(|task| TaskRef::qualified(&task.source, &task.native))
547                    .collect(),
548                delivered_by: Vec::new(),
549            },
550            depends_on,
551        };
552        let written = match match answers {
553            Some(answers) => source.source().write_task_rendered(&write, answers).await,
554            None => source.source().write_task(&write).await,
555        } {
556            Ok(written) => written,
557            Err(error) => {
558                let error = source_failed(source, error);
559                return Err(match filed {
560                    Some(filed) => self.unfile(filed, error).await,
561                    None => error,
562                });
563            }
564        };
565        let id = GlobalId::new(near.clone(), written);
566        let task = source
567            .source()
568            .get_task(&id.native)
569            .await
570            .map_err(|error| source_failed(source, error))?
571            .ok_or_else(|| EngineError::NoSuchTask { id: id.to_string() })?;
572        let delivers = targets(&task.delivers, near);
573        let delivered = self
574            .deliver(&id, task.status.category, &delivers, &[])
575            .await;
576        Ok(TaskCreated {
577            task: qualified_task(id, task),
578            delivered,
579        })
580    }
581
582    /// Create one project document, or replace the one the source holds under
583    /// [`DocumentCreate::id`], from a plain body or a template's rendering.
584    ///
585    /// # Errors
586    ///
587    /// As [`create_task`](Self::create_task), and [`EngineError::NoDocuments`] for a source
588    /// declaring it has none, which is not asked.
589    pub async fn create_document(
590        &self,
591        request: &DocumentCreate,
592    ) -> Result<Qualified<Document>, EngineError> {
593        let source = self.creatable(&request.source, MetadataRecord::Document)?;
594        documentary(source)?;
595        let target = match &request.id {
596            Some(id) => source
597                .source()
598                .get_document(id)
599                .await
600                .map_err(|error| source_failed(source, error))?
601                .map(|held| held.id),
602            None => None,
603        };
604        let Parts {
605            content,
606            metadata,
607            answers,
608        } = request.body.parts(&request.metadata);
609        let write = ItemWrite {
610            target,
611            item: Document {
612                id: request
613                    .id
614                    .clone()
615                    .unwrap_or_else(|| NativeId::from(slug(&request.title, "document").as_str())),
616                title: request.title.clone(),
617                content: Some(content),
618                project: Some(request.project.clone()),
619                labels: labels(&request.labels),
620                url: None,
621                location: None,
622                created_at: None,
623                updated_at: None,
624                metadata,
625                repositories: request.repositories.clone(),
626            },
627            depends_on: Vec::new(),
628        };
629        let written = match answers {
630            Some(answers) => {
631                source
632                    .source()
633                    .write_document_rendered(&write, answers)
634                    .await
635            }
636            None => source.source().write_document(&write).await,
637        }
638        .map_err(|error| source_failed(source, error))?;
639        let id = GlobalId::new(request.source.clone(), written);
640        let document = source
641            .source()
642            .get_document(&id.native)
643            .await
644            .map_err(|error| source_failed(source, error))?
645            .ok_or_else(|| EngineError::NoSuchDocument { id: id.to_string() })?;
646        Ok(Qualified { id, item: document })
647    }
648
649    /// The answers the task or document `id` was last rendered from, as its source keeps them.
650    ///
651    /// # Errors
652    ///
653    /// [`EngineError::NoSuchTask`] or [`EngineError::NoSuchDocument`] when the item is not
654    /// there, [`EngineError::NoStoredAnswers`] naming it when none are stored for it — which
655    /// is every item of a source that keeps none — and [`EngineError::SourceFailed`] when the
656    /// source cannot answer.
657    pub async fn template_answers(
658        &self,
659        record: RenderedRecord,
660        id: &GlobalId,
661    ) -> Result<TemplateAnswers, EngineError> {
662        let source = self.built(&id.source)?;
663        if record == RenderedRecord::Document {
664            documentary(source)?;
665        }
666        self.read_item(source, record, id).await?;
667        self.stored(source, record, id)
668            .await?
669            .map(TemplateAnswers)
670            .ok_or_else(|| EngineError::NoStoredAnswers {
671                record,
672                id: id.to_string(),
673                reason: UnusedAnswers::NoneStored {
674                    source: id.source.to_string(),
675                }
676                .to_string(),
677            })
678    }
679
680    /// Read everything a regenerate of the task or document `id` needs, before it renders.
681    ///
682    /// # Errors
683    ///
684    /// [`EngineError::NoSuchTask`] or [`EngineError::NoSuchDocument`] when the item is not
685    /// there; [`EngineError::NoTemplate`] when no template is given and the item records
686    /// none; [`EngineError::MalformedProvenance`] when no template is given and the entry it
687    /// records is not one this product writes; [`EngineError::TemplateNotAFile`] when no template is given and the one it
688    /// records is not a readable file, which only a loader document can then stand in for;
689    /// [`EngineError::Template`] when the template cannot be loaded; and the refusals of a
690    /// source that cannot be reached or cannot answer.
691    pub async fn regeneration(
692        &self,
693        record: RenderedRecord,
694        id: &GlobalId,
695        request: &RenderRequest,
696    ) -> Result<Regeneration, EngineError> {
697        let source = self.built(&id.source)?;
698        if record == RenderedRecord::Document {
699            documentary(source)?;
700        }
701        let (content, metadata) = self.read_item(source, record, id).await?;
702        let read = TemplateProvenance::read(&metadata);
703        let template = match (&request.template, &read) {
704            (RenderTemplate::Given(given), _) => given.clone(),
705            // An entry this product did not write names nothing it can trust: with no template
706            // given there is nothing to render, and the refusal says why.
707            (RenderTemplate::Recorded { .. }, Err(problem)) => {
708                return Err(EngineError::MalformedProvenance {
709                    record,
710                    id: id.to_string(),
711                    problem: problem.clone(),
712                });
713            }
714            (RenderTemplate::Recorded { search_path }, Ok(Some(recorded)))
715                if Path::new(&recorded.template).is_file() =>
716            {
717                TemplateInput::File {
718                    path: PathBuf::from(&recorded.template),
719                    search_path: search_path.clone(),
720                }
721            }
722            (RenderTemplate::Recorded { .. }, Ok(Some(recorded))) => {
723                return Err(EngineError::TemplateNotAFile {
724                    record,
725                    id: id.to_string(),
726                    reference: recorded.template.clone(),
727                });
728            }
729            (RenderTemplate::Recorded { .. }, Ok(None)) => {
730                return Err(EngineError::NoTemplate {
731                    record,
732                    id: id.to_string(),
733                });
734            }
735        };
736        let reference = template.reference();
737        let template = template
738            .load()
739            .map_err(|error| EngineError::Template { error })?;
740        let held = self.stored(source, record, id).await?;
741        let unused = |reason| Stored::Unused {
742            reason,
743            held: held.clone(),
744        };
745        let stored = match (&read, &held) {
746            (Err(problem), _) => unused(UnusedAnswers::MalformedProvenance {
747                problem: problem.clone(),
748            }),
749            (Ok(None), _) => unused(UnusedAnswers::NoProvenance),
750            (Ok(Some(_)), None) => unused(UnusedAnswers::NoneStored {
751                source: id.source.to_string(),
752            }),
753            (Ok(Some(recorded)), Some(answers))
754                if crate::template::answers_digest(answers) != recorded.answers_digest.as_str() =>
755            {
756                unused(UnusedAnswers::OutOfStep)
757            }
758            (Ok(Some(_)), Some(answers)) => Stored::Trusted(answers.clone()),
759        };
760        let mut base = Answers::new();
761        if let Stored::Trusted(answers) = &stored {
762            // An optional variable given nothing resolved to `null`; left unanswered, it
763            // resolves to that again.
764            for (name, value) in answers.iter().filter(|(_, value)| !value.is_null()) {
765                base.set(name.clone(), value.clone());
766            }
767        }
768        Ok(Regeneration {
769            id: id.clone(),
770            record,
771            template,
772            reference,
773            base,
774            stored,
775            keeps_answers: source.source().keeps_template_answers(),
776            content,
777            provenance: read.ok().flatten(),
778        })
779    }
780
781    /// Render `regeneration` with `answers` laid over its base, and write the content, the
782    /// provenance and the answers in one write — and nothing else about the item — when any of
783    /// them differs from what it holds and this is not a dry run.
784    ///
785    /// # Errors
786    ///
787    /// Every refusal of [`Regeneration::render`]; [`EngineError::RenderingNotWritable`] for a
788    /// source with no write side, which is not asked; and [`EngineError::SourceFailed`] when
789    /// the source refuses the write.
790    pub async fn regenerate(
791        &self,
792        regeneration: &Regeneration,
793        answers: &Answers,
794        dry_run: bool,
795    ) -> Result<Regenerated, EngineError> {
796        let rendered = regeneration.render(answers)?;
797        let provenance = TemplateProvenance::of(regeneration.reference.clone(), &rendered)
798            .map_err(|error| EngineError::Template { error })?;
799        let changed = regeneration.content != rendered.body
800            || regeneration.provenance.as_ref() != Some(&provenance)
801            // Answers a source keeps and this item does not hold — a block deleted by hand —
802            // are a difference the write repairs; a source keeping none holds none by nature.
803            || regeneration
804                .stored
805                .held()
806                .map_or(regeneration.keeps_answers, |stored| {
807                    *stored != rendered.answers
808                });
809        let id = &regeneration.id;
810        if changed && !dry_run {
811            let source = self.built(&id.source)?;
812            if !source.source().writes().is_supported() {
813                return Err(EngineError::RenderingNotWritable {
814                    name: source.name().to_string(),
815                    kind: source.kind().to_owned(),
816                    record: regeneration.record,
817                });
818            }
819            let value = provenance.to_value();
820            match regeneration.record {
821                RenderedRecord::Task => {
822                    source
823                        .source()
824                        .set_task_rendering(&id.native, &rendered.body, &value, &rendered.answers)
825                        .await
826                }
827                RenderedRecord::Document => {
828                    source
829                        .source()
830                        .set_document_rendering(
831                            &id.native,
832                            &rendered.body,
833                            &value,
834                            &rendered.answers,
835                        )
836                        .await
837                }
838            }
839            .map_err(|error| source_failed(source, error))?
840            .ok_or_else(|| regeneration.record.no_such(id))?;
841        }
842        Ok(Regenerated {
843            id: id.clone(),
844            digest: provenance.digest,
845            body_digest: provenance.body_digest,
846            changed,
847            body: rendered.body,
848        })
849    }
850
851    /// Regenerate one task in place: [`regeneration`](Self::regeneration) then
852    /// [`regenerate`](Self::regenerate), never asking for an answer.
853    ///
854    /// # Errors
855    ///
856    /// Every refusal of either half.
857    pub async fn render_task(
858        &self,
859        id: &GlobalId,
860        request: &RenderRequest,
861    ) -> Result<Regenerated, EngineError> {
862        let regeneration = self.regeneration(RenderedRecord::Task, id, request).await?;
863        self.regenerate(&regeneration, &request.answers, request.dry_run)
864            .await
865    }
866
867    /// Regenerate one project document in place, on the terms of
868    /// [`render_task`](Self::render_task).
869    ///
870    /// # Errors
871    ///
872    /// As [`render_task`](Self::render_task).
873    pub async fn render_document(
874        &self,
875        id: &GlobalId,
876        request: &RenderRequest,
877    ) -> Result<Regenerated, EngineError> {
878        let regeneration = self
879            .regeneration(RenderedRecord::Document, id, request)
880            .await?;
881        self.regenerate(&regeneration, &request.answers, request.dry_run)
882            .await
883    }
884
885    /// The built source called `name`, when it can be written through.
886    fn creatable(
887        &self,
888        name: &SourceName,
889        record: MetadataRecord,
890    ) -> Result<&ResolvedSource, EngineError> {
891        let source = self.built(name)?;
892        if source.source().writes().is_supported() {
893            return Ok(source);
894        }
895        Err(EngineError::NotCreatable {
896            name: source.name().to_string(),
897            kind: source.kind().to_owned(),
898            record,
899        })
900    }
901
902    /// The content and the metadata of the item `id` names.
903    async fn read_item(
904        &self,
905        source: &ResolvedSource,
906        record: RenderedRecord,
907        id: &GlobalId,
908    ) -> Result<(String, BTreeMap<String, Value>), EngineError> {
909        let read = match record {
910            RenderedRecord::Task => source
911                .source()
912                .get_task(&id.native)
913                .await
914                .map(|task| task.map(|task| (task.content, task.metadata))),
915            RenderedRecord::Document => source
916                .source()
917                .get_document(&id.native)
918                .await
919                .map(|document| document.map(|document| (document.content, document.metadata))),
920        }
921        .map_err(|error| source_failed(source, error))?;
922        let (content, metadata) = read.ok_or_else(|| record.no_such(id))?;
923        Ok((content.unwrap_or_default(), metadata))
924    }
925
926    /// The answers the item `id` names has stored beside it, when it has any.
927    async fn stored(
928        &self,
929        source: &ResolvedSource,
930        record: RenderedRecord,
931        id: &GlobalId,
932    ) -> Result<Option<BTreeMap<String, Value>>, EngineError> {
933        match record {
934            RenderedRecord::Task => source.source().task_template_answers(&id.native).await,
935            RenderedRecord::Document => source.source().document_template_answers(&id.native).await,
936        }
937        .map_err(|error| source_failed(source, error))
938    }
939}
940
941/// Refuse a source declaring it has no documents, before it is asked anything.
942fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
943    if source.source().capabilities().documents.is_native() {
944        return Ok(());
945    }
946    Err(EngineError::NoDocuments {
947        name: source.name().to_string(),
948        kind: source.kind().to_owned(),
949    })
950}
951
952/// A dependency's far end as the near source writes it: its own item by its own id, another
953/// source's qualified.
954fn endpoint(far: &GlobalId, near: &SourceName) -> DependencyEndpoint {
955    if &far.source == near {
956        return DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task);
957    }
958    DependencyEndpoint::new(far.to_string(), ItemKind::Task)
959        .unwrap_or_else(|_| DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task))
960}
961
962/// A create names each label by name alone, so its id is that name too: the id is what a
963/// folder of Markdown writes, and the name is what a source that matches labels reads.
964fn labels(names: &[String]) -> Vec<Label> {
965    names
966        .iter()
967        .map(|name| Label {
968            id: NativeId::from(name.as_str()),
969            name: name.clone(),
970            color: None,
971        })
972        .collect()
973}
974
975/// A category as this product spells it — the word every source's default mapping reads back
976/// as that category.
977fn category_word(category: StatusCategory) -> String {
978    serde_json::to_value(category)
979        .ok()
980        .and_then(|word| word.as_str().map(str::to_owned))
981        .unwrap_or_else(|| "todo".to_owned())
982}
983
984/// The id a created item is suggested under: its title in lower case, every run of anything
985/// but an ASCII letter or digit one dash. A source is free to file it under another.
986fn slug(title: &str, fallback: &str) -> String {
987    let mut slug = String::with_capacity(title.len());
988    for character in title.chars() {
989        if character.is_ascii_alphanumeric() {
990            slug.push(character.to_ascii_lowercase());
991        } else if !slug.is_empty() && !slug.ends_with('-') {
992            slug.push('-');
993        }
994    }
995    let slug = slug.trim_end_matches('-');
996    if slug.is_empty() {
997        fallback.to_owned()
998    } else {
999        slug.to_owned()
1000    }
1001}