1use std::collections::BTreeMap;
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20use onetaskgraph_plugin_api::{
21 DependencyEdge, DependencyEndpoint, DependencyKind, Document, ItemKind, ItemWrite, Label,
22 MetadataKey, MetadataRecord, NativeId, Priority, Repository, SourceName, Status,
23 StatusCategory, Task, TaskRef,
24};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use super::delivery::{qualified_task, source_failed, targets};
30use super::{Delivered, Engine, EngineError, Qualified};
31use crate::GlobalId;
32use crate::resolve::ResolvedSource;
33use crate::template::{
34 Answers, RenderedTemplate, Sha256Digest, Template, TemplateError, TemplateInput,
35 TemplateProvenance,
36};
37
38#[derive(Debug, Clone, PartialEq)]
44pub struct Body(Content);
45
46#[derive(Debug, Clone, PartialEq)]
47enum Content {
48 Plain(String),
50 Rendered {
53 rendered: RenderedTemplate,
54 provenance: TemplateProvenance,
55 },
56}
57
58impl Body {
59 #[must_use]
61 pub fn plain(text: impl Into<String>) -> Self {
62 Self(Content::Plain(text.into()))
63 }
64
65 pub fn rendered(
71 template: &TemplateInput,
72 rendered: RenderedTemplate,
73 ) -> Result<Self, TemplateError> {
74 Self::rendered_as(template.reference(), rendered)
75 }
76
77 pub fn rendered_as(
84 template: impl Into<String>,
85 rendered: RenderedTemplate,
86 ) -> Result<Self, TemplateError> {
87 let provenance = TemplateProvenance::of(template, &rendered)?;
88 Ok(Self(Content::Rendered {
89 rendered,
90 provenance,
91 }))
92 }
93
94 fn parts(&self, metadata: &BTreeMap<MetadataKey, Value>) -> Parts<'_> {
96 let mut carried: BTreeMap<String, Value> = metadata
97 .iter()
98 .map(|(key, value)| (key.as_str().to_owned(), value.clone()))
99 .collect();
100 match &self.0 {
101 Content::Plain(content) => Parts {
102 content: content.clone(),
103 metadata: carried,
104 answers: None,
105 },
106 Content::Rendered {
107 rendered,
108 provenance,
109 } => {
110 carried.insert(TemplateProvenance::KEY.to_owned(), provenance.to_value());
111 Parts {
112 content: rendered.body.clone(),
113 metadata: carried,
114 answers: Some(&rendered.answers),
115 }
116 }
117 }
118 }
119}
120
121struct Parts<'a> {
124 content: String,
125 metadata: BTreeMap<String, Value>,
126 answers: Option<&'a BTreeMap<String, Value>>,
127}
128
129#[derive(Debug, Clone, PartialEq)]
131pub struct TaskCreate {
132 pub source: SourceName,
134 pub project: NativeId,
136 pub title: String,
138 pub body: Body,
140 pub status: Option<StatusCategory>,
142 pub labels: Vec<String>,
144 pub repositories: Vec<Repository>,
146 pub depends_on: Vec<GlobalId>,
149 pub delivers: Vec<GlobalId>,
152 pub metadata: BTreeMap<MetadataKey, Value>,
155}
156
157#[derive(Debug, Clone, PartialEq)]
159pub struct DocumentCreate {
160 pub source: SourceName,
162 pub project: NativeId,
164 pub title: String,
166 pub id: Option<NativeId>,
170 pub body: Body,
172 pub labels: Vec<String>,
174 pub repositories: Vec<Repository>,
176 pub metadata: BTreeMap<MetadataKey, Value>,
178}
179
180#[derive(Debug, Clone, PartialEq)]
183pub struct TaskCreated {
184 pub task: Qualified<Task>,
186 pub delivered: Vec<Delivered>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum RenderedRecord {
193 Task,
195 Document,
197}
198
199impl fmt::Display for RenderedRecord {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 formatter.write_str(self.noun())
202 }
203}
204
205impl RenderedRecord {
206 fn noun(self) -> &'static str {
207 match self {
208 Self::Task => "task",
209 Self::Document => "document",
210 }
211 }
212
213 fn no_such(self, id: &GlobalId) -> EngineError {
214 match self {
215 Self::Task => EngineError::NoSuchTask { id: id.to_string() },
216 Self::Document => EngineError::NoSuchDocument { id: id.to_string() },
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq)]
226pub enum RenderTemplate {
227 Given(TemplateInput),
230 Recorded {
233 search_path: Vec<PathBuf>,
235 },
236}
237
238impl Default for RenderTemplate {
239 fn default() -> Self {
240 Self::Recorded {
241 search_path: Vec::new(),
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Default)]
248pub struct RenderRequest {
249 pub template: RenderTemplate,
251 pub answers: Answers,
254 pub dry_run: bool,
256}
257
258#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
260pub struct Regenerated {
261 pub id: GlobalId,
263 pub digest: Sha256Digest,
265 pub body_digest: Sha256Digest,
267 pub changed: bool,
270 pub body: String,
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
278#[serde(transparent)]
279pub struct TemplateAnswers(pub BTreeMap<String, Value>);
280
281impl TemplateAnswers {
282 pub fn to_yaml(&self) -> Result<String, String> {
289 serde_norway::to_string(&self.0).map_err(|error| error.to_string())
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
295pub enum UnusedAnswers {
296 NoProvenance,
298 NoneStored {
300 source: String,
302 },
303 OutOfStep,
306 MalformedProvenance {
309 problem: String,
311 },
312}
313
314impl fmt::Display for UnusedAnswers {
315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
316 match self {
317 Self::NoProvenance => formatter.write_str(
318 "it records no template provenance, so no stored answers are known to be its",
319 ),
320 Self::NoneStored { source } => write!(
321 formatter,
322 "source {source} holds no stored answers for it — only a source that keeps an \
323 authoring file, such as local-md, stores them"
324 ),
325 Self::OutOfStep => formatter.write_str(
326 "the answers stored beside it do not hash to the answers_digest its provenance \
327 records, so they changed after it was rendered and are not trusted",
328 ),
329 Self::MalformedProvenance { problem } => write!(
330 formatter,
331 "{problem}, so nothing trusted says which stored answers are its"
332 ),
333 }
334 }
335}
336
337#[derive(Debug, Clone)]
345pub struct Regeneration {
346 id: GlobalId,
347 record: RenderedRecord,
348 template: Template,
349 reference: String,
350 base: Answers,
351 stored: Stored,
352 keeps_answers: bool,
355 content: String,
356 provenance: Option<TemplateProvenance>,
357}
358
359#[derive(Debug, Clone)]
363enum Stored {
364 Trusted(BTreeMap<String, Value>),
366 Unused {
369 reason: UnusedAnswers,
370 held: Option<BTreeMap<String, Value>>,
371 },
372}
373
374impl Stored {
375 fn held(&self) -> Option<&BTreeMap<String, Value>> {
377 match self {
378 Self::Trusted(answers) => Some(answers),
379 Self::Unused { held, .. } => held.as_ref(),
380 }
381 }
382}
383
384impl Regeneration {
385 #[must_use]
387 pub fn id(&self) -> &GlobalId {
388 &self.id
389 }
390
391 #[must_use]
393 pub fn template(&self) -> &Template {
394 &self.template
395 }
396
397 #[must_use]
399 pub fn base(&self) -> &Answers {
400 &self.base
401 }
402
403 pub fn settled(&self) -> impl Iterator<Item = &str> {
407 let trusted = match &self.stored {
408 Stored::Trusted(answers) => Some(answers),
409 Stored::Unused { .. } => None,
410 };
411 trusted
412 .into_iter()
413 .flat_map(BTreeMap::keys)
414 .map(String::as_str)
415 }
416
417 #[must_use]
419 pub fn unused(&self) -> Option<&UnusedAnswers> {
420 match &self.stored {
421 Stored::Trusted(_) => None,
422 Stored::Unused { reason, .. } => Some(reason),
423 }
424 }
425
426 pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, EngineError> {
437 let mut base = self.base.clone();
438 loop {
439 match self.template.render(&base.overlay(answers)) {
440 Err(TemplateError::UnknownAnswer { names })
441 if names.iter().all(|name| {
442 base.names().any(|held| held == name)
443 && !answers.names().any(|given| given == name)
444 }) =>
445 {
446 for name in names {
447 base.unset(name);
448 }
449 }
450 Err(TemplateError::MissingRequired { names }) => {
451 return Err(EngineError::MissingAnswers {
452 id: self.id.to_string(),
453 names,
454 reason: self.unused().map_or_else(
455 || {
456 "the stored answers were used and do not answer them: an \
457 answer unset falls back to its default, and these have none"
458 .to_owned()
459 },
460 |unused| format!("the stored answers were not used because {unused}"),
461 ),
462 });
463 }
464 Err(error) => return Err(EngineError::Template { error }),
465 Ok(rendered) => return Ok(rendered),
466 }
467 }
468 }
469}
470
471impl Engine {
472 pub async fn create_task(&self, request: &TaskCreate) -> Result<TaskCreated, EngineError> {
485 let source = self.creatable(&request.source, MetadataRecord::Task)?;
486 let Parts {
487 content,
488 metadata,
489 answers,
490 } = request.body.parts(&request.metadata);
491 let category = request.status.unwrap_or(StatusCategory::Todo);
492 let near = &request.source;
493 let id = NativeId::from(slug(&request.title, "task").as_str());
494 let depends_on = request
495 .depends_on
496 .iter()
497 .map(|far| DependencyEdge {
498 from: DependencyEndpoint::from_native(id.clone(), ItemKind::Task),
499 to: endpoint(far, near),
500 kind: DependencyKind::Blocks,
501 })
502 .collect();
503 let write = ItemWrite {
504 target: None,
505 item: Task {
506 id,
507 key: None,
508 title: request.title.clone(),
509 content: Some(content),
510 status: Status {
511 category,
512 name: category_word(category),
513 },
514 priority: Priority::None,
515 labels: labels(&request.labels),
516 project: Some(request.project.clone()),
517 url: None,
518 location: None,
519 created_at: None,
520 updated_at: None,
521 metadata,
522 repositories: request.repositories.clone(),
523 delivers: request
524 .delivers
525 .iter()
526 .map(|task| TaskRef::qualified(&task.source, &task.native))
527 .collect(),
528 delivered_by: Vec::new(),
529 },
530 depends_on,
531 };
532 let written = match answers {
533 Some(answers) => source.source().write_task_rendered(&write, answers).await,
534 None => source.source().write_task(&write).await,
535 }
536 .map_err(|error| source_failed(source, error))?;
537 let id = GlobalId::new(near.clone(), written);
538 let task = source
539 .source()
540 .get_task(&id.native)
541 .await
542 .map_err(|error| source_failed(source, error))?
543 .ok_or_else(|| EngineError::NoSuchTask { id: id.to_string() })?;
544 let delivers = targets(&task.delivers, near);
545 let delivered = self
546 .deliver(&id, task.status.category, &delivers, &[])
547 .await;
548 Ok(TaskCreated {
549 task: qualified_task(id, task),
550 delivered,
551 })
552 }
553
554 pub async fn create_document(
562 &self,
563 request: &DocumentCreate,
564 ) -> Result<Qualified<Document>, EngineError> {
565 let source = self.creatable(&request.source, MetadataRecord::Document)?;
566 documentary(source)?;
567 let target = match &request.id {
568 Some(id) => source
569 .source()
570 .get_document(id)
571 .await
572 .map_err(|error| source_failed(source, error))?
573 .map(|held| held.id),
574 None => None,
575 };
576 let Parts {
577 content,
578 metadata,
579 answers,
580 } = request.body.parts(&request.metadata);
581 let write = ItemWrite {
582 target,
583 item: Document {
584 id: request
585 .id
586 .clone()
587 .unwrap_or_else(|| NativeId::from(slug(&request.title, "document").as_str())),
588 title: request.title.clone(),
589 content: Some(content),
590 project: Some(request.project.clone()),
591 labels: labels(&request.labels),
592 url: None,
593 location: None,
594 created_at: None,
595 updated_at: None,
596 metadata,
597 repositories: request.repositories.clone(),
598 },
599 depends_on: Vec::new(),
600 };
601 let written = match answers {
602 Some(answers) => {
603 source
604 .source()
605 .write_document_rendered(&write, answers)
606 .await
607 }
608 None => source.source().write_document(&write).await,
609 }
610 .map_err(|error| source_failed(source, error))?;
611 let id = GlobalId::new(request.source.clone(), written);
612 let document = source
613 .source()
614 .get_document(&id.native)
615 .await
616 .map_err(|error| source_failed(source, error))?
617 .ok_or_else(|| EngineError::NoSuchDocument { id: id.to_string() })?;
618 Ok(Qualified { id, item: document })
619 }
620
621 pub async fn template_answers(
630 &self,
631 record: RenderedRecord,
632 id: &GlobalId,
633 ) -> Result<TemplateAnswers, EngineError> {
634 let source = self.built(&id.source)?;
635 if record == RenderedRecord::Document {
636 documentary(source)?;
637 }
638 self.read_item(source, record, id).await?;
639 self.stored(source, record, id)
640 .await?
641 .map(TemplateAnswers)
642 .ok_or_else(|| EngineError::NoStoredAnswers {
643 record,
644 id: id.to_string(),
645 reason: UnusedAnswers::NoneStored {
646 source: id.source.to_string(),
647 }
648 .to_string(),
649 })
650 }
651
652 pub async fn regeneration(
664 &self,
665 record: RenderedRecord,
666 id: &GlobalId,
667 request: &RenderRequest,
668 ) -> Result<Regeneration, EngineError> {
669 let source = self.built(&id.source)?;
670 if record == RenderedRecord::Document {
671 documentary(source)?;
672 }
673 let (content, metadata) = self.read_item(source, record, id).await?;
674 let read = TemplateProvenance::read(&metadata);
675 let template = match (&request.template, &read) {
676 (RenderTemplate::Given(given), _) => given.clone(),
677 (RenderTemplate::Recorded { .. }, Err(problem)) => {
680 return Err(EngineError::MalformedProvenance {
681 record,
682 id: id.to_string(),
683 problem: problem.clone(),
684 });
685 }
686 (RenderTemplate::Recorded { search_path }, Ok(Some(recorded)))
687 if Path::new(&recorded.template).is_file() =>
688 {
689 TemplateInput::File {
690 path: PathBuf::from(&recorded.template),
691 search_path: search_path.clone(),
692 }
693 }
694 (RenderTemplate::Recorded { .. }, Ok(Some(recorded))) => {
695 return Err(EngineError::TemplateNotAFile {
696 record,
697 id: id.to_string(),
698 reference: recorded.template.clone(),
699 });
700 }
701 (RenderTemplate::Recorded { .. }, Ok(None)) => {
702 return Err(EngineError::NoTemplate {
703 record,
704 id: id.to_string(),
705 });
706 }
707 };
708 let reference = template.reference();
709 let template = template
710 .load()
711 .map_err(|error| EngineError::Template { error })?;
712 let held = self.stored(source, record, id).await?;
713 let unused = |reason| Stored::Unused {
714 reason,
715 held: held.clone(),
716 };
717 let stored = match (&read, &held) {
718 (Err(problem), _) => unused(UnusedAnswers::MalformedProvenance {
719 problem: problem.clone(),
720 }),
721 (Ok(None), _) => unused(UnusedAnswers::NoProvenance),
722 (Ok(Some(_)), None) => unused(UnusedAnswers::NoneStored {
723 source: id.source.to_string(),
724 }),
725 (Ok(Some(recorded)), Some(answers))
726 if crate::template::answers_digest(answers) != recorded.answers_digest.as_str() =>
727 {
728 unused(UnusedAnswers::OutOfStep)
729 }
730 (Ok(Some(_)), Some(answers)) => Stored::Trusted(answers.clone()),
731 };
732 let mut base = Answers::new();
733 if let Stored::Trusted(answers) = &stored {
734 for (name, value) in answers.iter().filter(|(_, value)| !value.is_null()) {
737 base.set(name.clone(), value.clone());
738 }
739 }
740 Ok(Regeneration {
741 id: id.clone(),
742 record,
743 template,
744 reference,
745 base,
746 stored,
747 keeps_answers: source.source().keeps_template_answers(),
748 content,
749 provenance: read.ok().flatten(),
750 })
751 }
752
753 pub async fn regenerate(
763 &self,
764 regeneration: &Regeneration,
765 answers: &Answers,
766 dry_run: bool,
767 ) -> Result<Regenerated, EngineError> {
768 let rendered = regeneration.render(answers)?;
769 let provenance = TemplateProvenance::of(regeneration.reference.clone(), &rendered)
770 .map_err(|error| EngineError::Template { error })?;
771 let changed = regeneration.content != rendered.body
772 || regeneration.provenance.as_ref() != Some(&provenance)
773 || regeneration
776 .stored
777 .held()
778 .map_or(regeneration.keeps_answers, |stored| {
779 *stored != rendered.answers
780 });
781 let id = ®eneration.id;
782 if changed && !dry_run {
783 let source = self.built(&id.source)?;
784 if !source.source().writes().is_supported() {
785 return Err(EngineError::RenderingNotWritable {
786 name: source.name().to_string(),
787 kind: source.kind().to_owned(),
788 record: regeneration.record,
789 });
790 }
791 let value = provenance.to_value();
792 match regeneration.record {
793 RenderedRecord::Task => {
794 source
795 .source()
796 .set_task_rendering(&id.native, &rendered.body, &value, &rendered.answers)
797 .await
798 }
799 RenderedRecord::Document => {
800 source
801 .source()
802 .set_document_rendering(
803 &id.native,
804 &rendered.body,
805 &value,
806 &rendered.answers,
807 )
808 .await
809 }
810 }
811 .map_err(|error| source_failed(source, error))?
812 .ok_or_else(|| regeneration.record.no_such(id))?;
813 }
814 Ok(Regenerated {
815 id: id.clone(),
816 digest: provenance.digest,
817 body_digest: provenance.body_digest,
818 changed,
819 body: rendered.body,
820 })
821 }
822
823 pub async fn render_task(
830 &self,
831 id: &GlobalId,
832 request: &RenderRequest,
833 ) -> Result<Regenerated, EngineError> {
834 let regeneration = self.regeneration(RenderedRecord::Task, id, request).await?;
835 self.regenerate(®eneration, &request.answers, request.dry_run)
836 .await
837 }
838
839 pub async fn render_document(
846 &self,
847 id: &GlobalId,
848 request: &RenderRequest,
849 ) -> Result<Regenerated, EngineError> {
850 let regeneration = self
851 .regeneration(RenderedRecord::Document, id, request)
852 .await?;
853 self.regenerate(®eneration, &request.answers, request.dry_run)
854 .await
855 }
856
857 fn creatable(
859 &self,
860 name: &SourceName,
861 record: MetadataRecord,
862 ) -> Result<&ResolvedSource, EngineError> {
863 let source = self.built(name)?;
864 if source.source().writes().is_supported() {
865 return Ok(source);
866 }
867 Err(EngineError::NotCreatable {
868 name: source.name().to_string(),
869 kind: source.kind().to_owned(),
870 record,
871 })
872 }
873
874 async fn read_item(
876 &self,
877 source: &ResolvedSource,
878 record: RenderedRecord,
879 id: &GlobalId,
880 ) -> Result<(String, BTreeMap<String, Value>), EngineError> {
881 let read = match record {
882 RenderedRecord::Task => source
883 .source()
884 .get_task(&id.native)
885 .await
886 .map(|task| task.map(|task| (task.content, task.metadata))),
887 RenderedRecord::Document => source
888 .source()
889 .get_document(&id.native)
890 .await
891 .map(|document| document.map(|document| (document.content, document.metadata))),
892 }
893 .map_err(|error| source_failed(source, error))?;
894 let (content, metadata) = read.ok_or_else(|| record.no_such(id))?;
895 Ok((content.unwrap_or_default(), metadata))
896 }
897
898 async fn stored(
900 &self,
901 source: &ResolvedSource,
902 record: RenderedRecord,
903 id: &GlobalId,
904 ) -> Result<Option<BTreeMap<String, Value>>, EngineError> {
905 match record {
906 RenderedRecord::Task => source.source().task_template_answers(&id.native).await,
907 RenderedRecord::Document => source.source().document_template_answers(&id.native).await,
908 }
909 .map_err(|error| source_failed(source, error))
910 }
911}
912
913fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
915 if source.source().capabilities().documents.is_native() {
916 return Ok(());
917 }
918 Err(EngineError::NoDocuments {
919 name: source.name().to_string(),
920 kind: source.kind().to_owned(),
921 })
922}
923
924fn endpoint(far: &GlobalId, near: &SourceName) -> DependencyEndpoint {
927 if &far.source == near {
928 return DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task);
929 }
930 DependencyEndpoint::new(far.to_string(), ItemKind::Task)
931 .unwrap_or_else(|_| DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task))
932}
933
934fn labels(names: &[String]) -> Vec<Label> {
937 names
938 .iter()
939 .map(|name| Label {
940 id: NativeId::from(name.as_str()),
941 name: name.clone(),
942 color: None,
943 })
944 .collect()
945}
946
947fn category_word(category: StatusCategory) -> String {
950 serde_json::to_value(category)
951 .ok()
952 .and_then(|word| word.as_str().map(str::to_owned))
953 .unwrap_or_else(|| "todo".to_owned())
954}
955
956fn slug(title: &str, fallback: &str) -> String {
959 let mut slug = String::with_capacity(title.len());
960 for character in title.chars() {
961 if character.is_ascii_alphanumeric() {
962 slug.push(character.to_ascii_lowercase());
963 } else if !slug.is_empty() && !slug.ends_with('-') {
964 slug.push('-');
965 }
966 }
967 let slug = slug.trim_end_matches('-');
968 if slug.is_empty() {
969 fallback.to_owned()
970 } else {
971 slug.to_owned()
972 }
973}