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