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 placement = self.place(&request.source, &request.repositories);
488 let near = &placement.destination;
489 let source = self.creatable(near, MetadataRecord::Task)?;
490 let filed = if near == &request.source {
491 None
492 } else {
493 let home = GlobalId::new(request.source.clone(), request.project.clone());
494 self.creatable(&request.source, MetadataRecord::Task)?;
495 let filed = self.member_project(&home, near).await?;
496 if filed.project.is_none() {
497 return Err(EngineError::NoSuchProject {
498 id: home.to_string(),
499 });
500 }
501 Some(filed)
502 };
503 let project = filed
504 .as_ref()
505 .and_then(|filed| filed.project.clone())
506 .unwrap_or_else(|| request.project.clone());
507 let Parts {
508 content,
509 metadata,
510 answers,
511 } = request.body.parts(&request.metadata);
512 let category = request.status.unwrap_or(StatusCategory::Todo);
513 let id = NativeId::from(slug(&request.title, "task").as_str());
514 let depends_on = request
515 .depends_on
516 .iter()
517 .map(|far| DependencyEdge {
518 from: DependencyEndpoint::from_native(id.clone(), ItemKind::Task),
519 to: endpoint(far, near),
520 kind: DependencyKind::Blocks,
521 })
522 .collect();
523 let write = ItemWrite {
524 target: None,
525 item: Task {
526 id,
527 key: None,
528 title: request.title.clone(),
529 content: Some(content),
530 status: Status {
531 category,
532 name: category_word(category),
533 },
534 priority: Priority::None,
535 labels: labels(&request.labels),
536 project: Some(project),
537 url: None,
538 location: None,
539 created_at: None,
540 updated_at: None,
541 metadata,
542 repositories: request.repositories.clone(),
543 delivers: request
544 .delivers
545 .iter()
546 .map(|task| TaskRef::qualified(&task.source, &task.native))
547 .collect(),
548 delivered_by: Vec::new(),
549 },
550 depends_on,
551 };
552 let written = match match answers {
553 Some(answers) => source.source().write_task_rendered(&write, answers).await,
554 None => source.source().write_task(&write).await,
555 } {
556 Ok(written) => written,
557 Err(error) => {
558 let error = source_failed(source, error);
559 return Err(match filed {
560 Some(filed) => self.unfile(filed, error).await,
561 None => error,
562 });
563 }
564 };
565 let id = GlobalId::new(near.clone(), written);
566 let task = source
567 .source()
568 .get_task(&id.native)
569 .await
570 .map_err(|error| source_failed(source, error))?
571 .ok_or_else(|| EngineError::NoSuchTask { id: id.to_string() })?;
572 let delivers = targets(&task.delivers, near);
573 let delivered = self
574 .deliver(&id, task.status.category, &delivers, &[])
575 .await;
576 Ok(TaskCreated {
577 task: qualified_task(id, task),
578 delivered,
579 })
580 }
581
582 pub async fn create_document(
590 &self,
591 request: &DocumentCreate,
592 ) -> Result<Qualified<Document>, EngineError> {
593 let source = self.creatable(&request.source, MetadataRecord::Document)?;
594 documentary(source)?;
595 let target = match &request.id {
596 Some(id) => source
597 .source()
598 .get_document(id)
599 .await
600 .map_err(|error| source_failed(source, error))?
601 .map(|held| held.id),
602 None => None,
603 };
604 let Parts {
605 content,
606 metadata,
607 answers,
608 } = request.body.parts(&request.metadata);
609 let write = ItemWrite {
610 target,
611 item: Document {
612 id: request
613 .id
614 .clone()
615 .unwrap_or_else(|| NativeId::from(slug(&request.title, "document").as_str())),
616 title: request.title.clone(),
617 content: Some(content),
618 project: Some(request.project.clone()),
619 labels: labels(&request.labels),
620 url: None,
621 location: None,
622 created_at: None,
623 updated_at: None,
624 metadata,
625 repositories: request.repositories.clone(),
626 },
627 depends_on: Vec::new(),
628 };
629 let written = match answers {
630 Some(answers) => {
631 source
632 .source()
633 .write_document_rendered(&write, answers)
634 .await
635 }
636 None => source.source().write_document(&write).await,
637 }
638 .map_err(|error| source_failed(source, error))?;
639 let id = GlobalId::new(request.source.clone(), written);
640 let document = source
641 .source()
642 .get_document(&id.native)
643 .await
644 .map_err(|error| source_failed(source, error))?
645 .ok_or_else(|| EngineError::NoSuchDocument { id: id.to_string() })?;
646 Ok(Qualified { id, item: document })
647 }
648
649 pub async fn template_answers(
658 &self,
659 record: RenderedRecord,
660 id: &GlobalId,
661 ) -> Result<TemplateAnswers, EngineError> {
662 let source = self.built(&id.source)?;
663 if record == RenderedRecord::Document {
664 documentary(source)?;
665 }
666 self.read_item(source, record, id).await?;
667 self.stored(source, record, id)
668 .await?
669 .map(TemplateAnswers)
670 .ok_or_else(|| EngineError::NoStoredAnswers {
671 record,
672 id: id.to_string(),
673 reason: UnusedAnswers::NoneStored {
674 source: id.source.to_string(),
675 }
676 .to_string(),
677 })
678 }
679
680 pub async fn regeneration(
692 &self,
693 record: RenderedRecord,
694 id: &GlobalId,
695 request: &RenderRequest,
696 ) -> Result<Regeneration, EngineError> {
697 let source = self.built(&id.source)?;
698 if record == RenderedRecord::Document {
699 documentary(source)?;
700 }
701 let (content, metadata) = self.read_item(source, record, id).await?;
702 let read = TemplateProvenance::read(&metadata);
703 let template = match (&request.template, &read) {
704 (RenderTemplate::Given(given), _) => given.clone(),
705 (RenderTemplate::Recorded { .. }, Err(problem)) => {
708 return Err(EngineError::MalformedProvenance {
709 record,
710 id: id.to_string(),
711 problem: problem.clone(),
712 });
713 }
714 (RenderTemplate::Recorded { search_path }, Ok(Some(recorded)))
715 if Path::new(&recorded.template).is_file() =>
716 {
717 TemplateInput::File {
718 path: PathBuf::from(&recorded.template),
719 search_path: search_path.clone(),
720 }
721 }
722 (RenderTemplate::Recorded { .. }, Ok(Some(recorded))) => {
723 return Err(EngineError::TemplateNotAFile {
724 record,
725 id: id.to_string(),
726 reference: recorded.template.clone(),
727 });
728 }
729 (RenderTemplate::Recorded { .. }, Ok(None)) => {
730 return Err(EngineError::NoTemplate {
731 record,
732 id: id.to_string(),
733 });
734 }
735 };
736 let reference = template.reference();
737 let template = template
738 .load()
739 .map_err(|error| EngineError::Template { error })?;
740 let held = self.stored(source, record, id).await?;
741 let unused = |reason| Stored::Unused {
742 reason,
743 held: held.clone(),
744 };
745 let stored = match (&read, &held) {
746 (Err(problem), _) => unused(UnusedAnswers::MalformedProvenance {
747 problem: problem.clone(),
748 }),
749 (Ok(None), _) => unused(UnusedAnswers::NoProvenance),
750 (Ok(Some(_)), None) => unused(UnusedAnswers::NoneStored {
751 source: id.source.to_string(),
752 }),
753 (Ok(Some(recorded)), Some(answers))
754 if crate::template::answers_digest(answers) != recorded.answers_digest.as_str() =>
755 {
756 unused(UnusedAnswers::OutOfStep)
757 }
758 (Ok(Some(_)), Some(answers)) => Stored::Trusted(answers.clone()),
759 };
760 let mut base = Answers::new();
761 if let Stored::Trusted(answers) = &stored {
762 for (name, value) in answers.iter().filter(|(_, value)| !value.is_null()) {
765 base.set(name.clone(), value.clone());
766 }
767 }
768 Ok(Regeneration {
769 id: id.clone(),
770 record,
771 template,
772 reference,
773 base,
774 stored,
775 keeps_answers: source.source().keeps_template_answers(),
776 content,
777 provenance: read.ok().flatten(),
778 })
779 }
780
781 pub async fn regenerate(
791 &self,
792 regeneration: &Regeneration,
793 answers: &Answers,
794 dry_run: bool,
795 ) -> Result<Regenerated, EngineError> {
796 let rendered = regeneration.render(answers)?;
797 let provenance = TemplateProvenance::of(regeneration.reference.clone(), &rendered)
798 .map_err(|error| EngineError::Template { error })?;
799 let changed = regeneration.content != rendered.body
800 || regeneration.provenance.as_ref() != Some(&provenance)
801 || regeneration
804 .stored
805 .held()
806 .map_or(regeneration.keeps_answers, |stored| {
807 *stored != rendered.answers
808 });
809 let id = ®eneration.id;
810 if changed && !dry_run {
811 let source = self.built(&id.source)?;
812 if !source.source().writes().is_supported() {
813 return Err(EngineError::RenderingNotWritable {
814 name: source.name().to_string(),
815 kind: source.kind().to_owned(),
816 record: regeneration.record,
817 });
818 }
819 let value = provenance.to_value();
820 match regeneration.record {
821 RenderedRecord::Task => {
822 source
823 .source()
824 .set_task_rendering(&id.native, &rendered.body, &value, &rendered.answers)
825 .await
826 }
827 RenderedRecord::Document => {
828 source
829 .source()
830 .set_document_rendering(
831 &id.native,
832 &rendered.body,
833 &value,
834 &rendered.answers,
835 )
836 .await
837 }
838 }
839 .map_err(|error| source_failed(source, error))?
840 .ok_or_else(|| regeneration.record.no_such(id))?;
841 }
842 Ok(Regenerated {
843 id: id.clone(),
844 digest: provenance.digest,
845 body_digest: provenance.body_digest,
846 changed,
847 body: rendered.body,
848 })
849 }
850
851 pub async fn render_task(
858 &self,
859 id: &GlobalId,
860 request: &RenderRequest,
861 ) -> Result<Regenerated, EngineError> {
862 let regeneration = self.regeneration(RenderedRecord::Task, id, request).await?;
863 self.regenerate(®eneration, &request.answers, request.dry_run)
864 .await
865 }
866
867 pub async fn render_document(
874 &self,
875 id: &GlobalId,
876 request: &RenderRequest,
877 ) -> Result<Regenerated, EngineError> {
878 let regeneration = self
879 .regeneration(RenderedRecord::Document, id, request)
880 .await?;
881 self.regenerate(®eneration, &request.answers, request.dry_run)
882 .await
883 }
884
885 fn creatable(
887 &self,
888 name: &SourceName,
889 record: MetadataRecord,
890 ) -> Result<&ResolvedSource, EngineError> {
891 let source = self.built(name)?;
892 if source.source().writes().is_supported() {
893 return Ok(source);
894 }
895 Err(EngineError::NotCreatable {
896 name: source.name().to_string(),
897 kind: source.kind().to_owned(),
898 record,
899 })
900 }
901
902 async fn read_item(
904 &self,
905 source: &ResolvedSource,
906 record: RenderedRecord,
907 id: &GlobalId,
908 ) -> Result<(String, BTreeMap<String, Value>), EngineError> {
909 let read = match record {
910 RenderedRecord::Task => source
911 .source()
912 .get_task(&id.native)
913 .await
914 .map(|task| task.map(|task| (task.content, task.metadata))),
915 RenderedRecord::Document => source
916 .source()
917 .get_document(&id.native)
918 .await
919 .map(|document| document.map(|document| (document.content, document.metadata))),
920 }
921 .map_err(|error| source_failed(source, error))?;
922 let (content, metadata) = read.ok_or_else(|| record.no_such(id))?;
923 Ok((content.unwrap_or_default(), metadata))
924 }
925
926 async fn stored(
928 &self,
929 source: &ResolvedSource,
930 record: RenderedRecord,
931 id: &GlobalId,
932 ) -> Result<Option<BTreeMap<String, Value>>, EngineError> {
933 match record {
934 RenderedRecord::Task => source.source().task_template_answers(&id.native).await,
935 RenderedRecord::Document => source.source().document_template_answers(&id.native).await,
936 }
937 .map_err(|error| source_failed(source, error))
938 }
939}
940
941fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
943 if source.source().capabilities().documents.is_native() {
944 return Ok(());
945 }
946 Err(EngineError::NoDocuments {
947 name: source.name().to_string(),
948 kind: source.kind().to_owned(),
949 })
950}
951
952fn endpoint(far: &GlobalId, near: &SourceName) -> DependencyEndpoint {
955 if &far.source == near {
956 return DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task);
957 }
958 DependencyEndpoint::new(far.to_string(), ItemKind::Task)
959 .unwrap_or_else(|_| DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task))
960}
961
962fn labels(names: &[String]) -> Vec<Label> {
965 names
966 .iter()
967 .map(|name| Label {
968 id: NativeId::from(name.as_str()),
969 name: name.clone(),
970 color: None,
971 })
972 .collect()
973}
974
975fn category_word(category: StatusCategory) -> String {
978 serde_json::to_value(category)
979 .ok()
980 .and_then(|word| word.as_str().map(str::to_owned))
981 .unwrap_or_else(|| "todo".to_owned())
982}
983
984fn slug(title: &str, fallback: &str) -> String {
987 let mut slug = String::with_capacity(title.len());
988 for character in title.chars() {
989 if character.is_ascii_alphanumeric() {
990 slug.push(character.to_ascii_lowercase());
991 } else if !slug.is_empty() && !slug.ends_with('-') {
992 slug.push('-');
993 }
994 }
995 let slug = slug.trim_end_matches('-');
996 if slug.is_empty() {
997 fallback.to_owned()
998 } else {
999 slug.to_owned()
1000 }
1001}