Skip to main content

onetaskgraph_core/engine/
rendered.rs

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