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, Project, Repository, SourceName, Status,
23 StatusCategory, Task, TaskRef,
24};
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use super::copy::{Level, forward_edges};
30use super::delivery::{qualified_task, source_failed, targets};
31use super::{Delivered, Engine, EngineError, Qualified};
32use crate::GlobalId;
33use crate::resolve::ResolvedSource;
34use crate::template::{
35 Answers, RenderedTemplate, Sha256Digest, Template, TemplateError, TemplateInput,
36 TemplateProvenance,
37};
38
39#[derive(Debug, Clone, PartialEq)]
45pub struct Body(Content);
46
47#[derive(Debug, Clone, PartialEq)]
48enum Content {
49 Plain(String),
51 Rendered {
54 rendered: RenderedTemplate,
55 provenance: TemplateProvenance,
56 },
57}
58
59impl Body {
60 #[must_use]
62 pub fn plain(text: impl Into<String>) -> Self {
63 Self(Content::Plain(text.into()))
64 }
65
66 pub fn rendered(
72 template: &TemplateInput,
73 rendered: RenderedTemplate,
74 ) -> Result<Self, TemplateError> {
75 Self::rendered_as(template.reference(), rendered)
76 }
77
78 pub fn rendered_as(
85 template: impl Into<String>,
86 rendered: RenderedTemplate,
87 ) -> Result<Self, TemplateError> {
88 let provenance = TemplateProvenance::of(template, &rendered)?;
89 Ok(Self(Content::Rendered {
90 rendered,
91 provenance,
92 }))
93 }
94
95 fn parts(&self, metadata: &BTreeMap<MetadataKey, Value>) -> Parts<'_> {
97 let mut carried: BTreeMap<String, Value> = metadata
98 .iter()
99 .map(|(key, value)| (key.as_str().to_owned(), value.clone()))
100 .collect();
101 match &self.0 {
102 Content::Plain(content) => Parts {
103 content: content.clone(),
104 metadata: carried,
105 answers: None,
106 },
107 Content::Rendered {
108 rendered,
109 provenance,
110 } => {
111 carried.insert(TemplateProvenance::KEY.to_owned(), provenance.to_value());
112 Parts {
113 content: rendered.body.clone(),
114 metadata: carried,
115 answers: Some(&rendered.answers),
116 }
117 }
118 }
119 }
120}
121
122struct Parts<'a> {
125 content: String,
126 metadata: BTreeMap<String, Value>,
127 answers: Option<&'a BTreeMap<String, Value>>,
128}
129
130#[derive(Debug, Clone, PartialEq)]
132pub struct TaskCreate {
133 pub source: SourceName,
135 pub project: NativeId,
137 pub title: String,
139 pub body: Body,
141 pub status: Option<StatusCategory>,
143 pub labels: Vec<String>,
145 pub repositories: Vec<Repository>,
147 pub depends_on: Vec<GlobalId>,
150 pub delivers: Vec<GlobalId>,
153 pub metadata: BTreeMap<MetadataKey, Value>,
156}
157
158#[derive(Debug, Clone, PartialEq)]
160pub struct DocumentCreate {
161 pub source: SourceName,
163 pub project: NativeId,
165 pub title: String,
167 pub id: Option<NativeId>,
171 pub body: Body,
173 pub labels: Vec<String>,
175 pub repositories: Vec<Repository>,
177 pub metadata: BTreeMap<MetadataKey, Value>,
179}
180
181#[derive(Debug, Clone, PartialEq)]
188pub struct ProjectCreate {
189 pub source: SourceName,
191 pub id: NativeId,
195 pub title: String,
197 pub body: Body,
199 pub status: Option<StatusCategory>,
202 pub labels: Option<Vec<String>>,
205 pub repositories: Option<Vec<Repository>>,
208 pub metadata: BTreeMap<MetadataKey, Value>,
210}
211
212#[derive(Debug, Clone, PartialEq)]
215pub struct TaskCreated {
216 pub task: Qualified<Task>,
218 pub delivered: Vec<Delivered>,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum RenderedRecord {
225 Task,
227 Document,
229 Project,
231}
232
233impl fmt::Display for RenderedRecord {
234 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235 formatter.write_str(self.noun())
236 }
237}
238
239impl RenderedRecord {
240 fn noun(self) -> &'static str {
241 match self {
242 Self::Task => "task",
243 Self::Document => "document",
244 Self::Project => "project",
245 }
246 }
247
248 fn no_such(self, id: &GlobalId) -> EngineError {
249 match self {
250 Self::Task => EngineError::NoSuchTask { id: id.to_string() },
251 Self::Document => EngineError::NoSuchDocument { id: id.to_string() },
252 Self::Project => EngineError::NoSuchProject { id: id.to_string() },
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq)]
262pub enum RenderTemplate {
263 Given(TemplateInput),
266 Recorded {
269 search_path: Vec<PathBuf>,
271 },
272}
273
274impl Default for RenderTemplate {
275 fn default() -> Self {
276 Self::Recorded {
277 search_path: Vec::new(),
278 }
279 }
280}
281
282#[derive(Debug, Clone, PartialEq, Default)]
284pub struct RenderRequest {
285 pub template: RenderTemplate,
287 pub answers: Answers,
290 pub dry_run: bool,
292}
293
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
296pub struct Regenerated {
297 pub id: GlobalId,
299 pub digest: Sha256Digest,
301 pub body_digest: Sha256Digest,
303 pub changed: bool,
306 pub body: String,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
314#[serde(transparent)]
315pub struct TemplateAnswers(pub BTreeMap<String, Value>);
316
317impl TemplateAnswers {
318 pub fn to_yaml(&self) -> Result<String, String> {
325 serde_norway::to_string(&self.0).map_err(|error| error.to_string())
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub enum UnusedAnswers {
332 NoProvenance,
334 NoneStored {
336 source: String,
338 },
339 OutOfStep,
342 MalformedProvenance {
345 problem: String,
347 },
348}
349
350impl fmt::Display for UnusedAnswers {
351 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352 match self {
353 Self::NoProvenance => formatter.write_str(
354 "it records no template provenance, so no stored answers are known to be its",
355 ),
356 Self::NoneStored { source } => write!(
357 formatter,
358 "source {source} holds no stored answers for it — only a source that keeps an \
359 authoring file, such as local-md, stores them"
360 ),
361 Self::OutOfStep => formatter.write_str(
362 "the answers stored beside it do not hash to the answers_digest its provenance \
363 records, so they changed after it was rendered and are not trusted",
364 ),
365 Self::MalformedProvenance { problem } => write!(
366 formatter,
367 "{problem}, so nothing trusted says which stored answers are its"
368 ),
369 }
370 }
371}
372
373#[derive(Debug, Clone)]
381pub struct Regeneration {
382 id: GlobalId,
383 record: RenderedRecord,
384 template: Template,
385 reference: String,
386 base: Answers,
387 stored: Stored,
388 keeps_answers: bool,
391 content: String,
392 provenance: Option<TemplateProvenance>,
393}
394
395#[derive(Debug, Clone)]
399enum Stored {
400 Trusted(BTreeMap<String, Value>),
402 Unused {
405 reason: UnusedAnswers,
406 held: Option<BTreeMap<String, Value>>,
407 },
408}
409
410impl Stored {
411 fn held(&self) -> Option<&BTreeMap<String, Value>> {
413 match self {
414 Self::Trusted(answers) => Some(answers),
415 Self::Unused { held, .. } => held.as_ref(),
416 }
417 }
418}
419
420impl Regeneration {
421 #[must_use]
423 pub fn id(&self) -> &GlobalId {
424 &self.id
425 }
426
427 #[must_use]
429 pub fn template(&self) -> &Template {
430 &self.template
431 }
432
433 #[must_use]
435 pub fn base(&self) -> &Answers {
436 &self.base
437 }
438
439 pub fn settled(&self) -> impl Iterator<Item = &str> {
443 let trusted = match &self.stored {
444 Stored::Trusted(answers) => Some(answers),
445 Stored::Unused { .. } => None,
446 };
447 trusted
448 .into_iter()
449 .flat_map(BTreeMap::keys)
450 .map(String::as_str)
451 }
452
453 #[must_use]
455 pub fn unused(&self) -> Option<&UnusedAnswers> {
456 match &self.stored {
457 Stored::Trusted(_) => None,
458 Stored::Unused { reason, .. } => Some(reason),
459 }
460 }
461
462 pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, EngineError> {
473 let mut base = self.base.clone();
474 loop {
475 match self.template.render(&base.overlay(answers)) {
476 Err(TemplateError::UnknownAnswer { names })
477 if names.iter().all(|name| {
478 base.names().any(|held| held == name)
479 && !answers.names().any(|given| given == name)
480 }) =>
481 {
482 for name in names {
483 base.unset(name);
484 }
485 }
486 Err(TemplateError::MissingRequired { names }) => {
487 return Err(EngineError::MissingAnswers {
488 id: self.id.to_string(),
489 names,
490 reason: self.unused().map_or_else(
491 || {
492 "the stored answers were used and do not answer them: an \
493 answer unset falls back to its default, and these have none"
494 .to_owned()
495 },
496 |unused| format!("the stored answers were not used because {unused}"),
497 ),
498 });
499 }
500 Err(error) => return Err(EngineError::Template { error }),
501 Ok(rendered) => return Ok(rendered),
502 }
503 }
504 }
505}
506
507impl Engine {
508 pub async fn create_task(&self, request: &TaskCreate) -> Result<TaskCreated, EngineError> {
521 let placement = self.place(&request.source, &request.repositories);
524 let near = &placement.destination;
525 let source = self.creatable(near, MetadataRecord::Task)?;
526 let filed = if near == &request.source {
527 None
528 } else {
529 let home = GlobalId::new(request.source.clone(), request.project.clone());
530 self.creatable(&request.source, MetadataRecord::Task)?;
531 let filed = self.member_project(&home, near).await?;
532 if filed.project.is_none() {
533 return Err(EngineError::NoSuchProject {
534 id: home.to_string(),
535 });
536 }
537 Some(filed)
538 };
539 let project = filed
540 .as_ref()
541 .and_then(|filed| filed.project.clone())
542 .unwrap_or_else(|| request.project.clone());
543 let Parts {
544 content,
545 metadata,
546 answers,
547 } = request.body.parts(&request.metadata);
548 let category = request.status.unwrap_or(StatusCategory::Todo);
549 let id = NativeId::from(slug(&request.title, "task").as_str());
550 let depends_on = request
551 .depends_on
552 .iter()
553 .map(|far| DependencyEdge {
554 from: DependencyEndpoint::from_native(id.clone(), ItemKind::Task),
555 to: endpoint(far, near),
556 kind: DependencyKind::Blocks,
557 })
558 .collect();
559 let write = ItemWrite {
560 target: None,
561 item: Task {
562 id,
563 key: None,
564 title: request.title.clone(),
565 content: Some(content),
566 status: Status {
567 category,
568 name: category_word(category),
569 },
570 priority: Priority::None,
571 labels: labels(&request.labels),
572 project: Some(project),
573 url: None,
574 location: None,
575 created_at: None,
576 updated_at: None,
577 metadata,
578 repositories: request.repositories.clone(),
579 delivers: request
580 .delivers
581 .iter()
582 .map(|task| TaskRef::qualified(&task.source, &task.native))
583 .collect(),
584 delivered_by: Vec::new(),
585 },
586 depends_on,
587 };
588 let written = match match answers {
589 Some(answers) => source.source().write_task_rendered(&write, answers).await,
590 None => source.source().write_task(&write).await,
591 } {
592 Ok(written) => written,
593 Err(error) => {
594 let error = source_failed(source, error);
595 return Err(match filed {
596 Some(filed) => self.unfile(filed, error).await,
597 None => error,
598 });
599 }
600 };
601 let id = GlobalId::new(near.clone(), written);
602 let task = source
603 .source()
604 .get_task(&id.native)
605 .await
606 .map_err(|error| source_failed(source, error))?
607 .ok_or_else(|| EngineError::NoSuchTask { id: id.to_string() })?;
608 let delivers = targets(&task.delivers, near);
609 let delivered = self
610 .deliver(&id, task.status.category, &delivers, &[])
611 .await;
612 Ok(TaskCreated {
613 task: qualified_task(id, task),
614 delivered,
615 })
616 }
617
618 pub async fn create_document(
626 &self,
627 request: &DocumentCreate,
628 ) -> Result<Qualified<Document>, EngineError> {
629 let source = self.creatable(&request.source, MetadataRecord::Document)?;
630 documentary(source)?;
631 let target = match &request.id {
632 Some(id) => source
633 .source()
634 .get_document(id)
635 .await
636 .map_err(|error| source_failed(source, error))?
637 .map(|held| held.id),
638 None => None,
639 };
640 let Parts {
641 content,
642 metadata,
643 answers,
644 } = request.body.parts(&request.metadata);
645 let write = ItemWrite {
646 target,
647 item: Document {
648 id: request
649 .id
650 .clone()
651 .unwrap_or_else(|| NativeId::from(slug(&request.title, "document").as_str())),
652 title: request.title.clone(),
653 content: Some(content),
654 project: Some(request.project.clone()),
655 labels: labels(&request.labels),
656 url: None,
657 location: None,
658 created_at: None,
659 updated_at: None,
660 metadata,
661 repositories: request.repositories.clone(),
662 },
663 depends_on: Vec::new(),
664 };
665 let written = match answers {
666 Some(answers) => {
667 source
668 .source()
669 .write_document_rendered(&write, answers)
670 .await
671 }
672 None => source.source().write_document(&write).await,
673 }
674 .map_err(|error| source_failed(source, error))?;
675 let id = GlobalId::new(request.source.clone(), written);
676 let document = source
677 .source()
678 .get_document(&id.native)
679 .await
680 .map_err(|error| source_failed(source, error))?
681 .ok_or_else(|| EngineError::NoSuchDocument { id: id.to_string() })?;
682 Ok(Qualified { id, item: document })
683 }
684
685 pub async fn create_project(
700 &self,
701 request: &ProjectCreate,
702 ) -> Result<Qualified<Project>, EngineError> {
703 let source = self.creatable(&request.source, MetadataRecord::Project)?;
704 let held = source
705 .source()
706 .get_project(&request.id)
707 .await
708 .map_err(|error| source_failed(source, error))?;
709 let Parts {
710 content,
711 metadata: given,
712 answers,
713 } = request.body.parts(&request.metadata);
714 let (target, depends_on, mut metadata, status, labels, repositories) = match held {
715 Some(held) => {
716 let edges = forward_edges(source, &held.id, Level::Project).await?;
717 let mut kept = held.metadata;
718 kept.remove(TemplateProvenance::KEY);
719 (
720 Some(held.id),
721 edges,
722 kept,
723 held.status,
724 held.labels,
725 held.repositories,
726 )
727 }
728 None => {
729 let category = StatusCategory::Todo;
730 (
731 None,
732 Vec::new(),
733 BTreeMap::new(),
734 Status {
735 category,
736 name: category_word(category),
737 },
738 Vec::new(),
739 Vec::new(),
740 )
741 }
742 };
743 metadata.extend(given);
744 let status = request.status.map_or(status, |category| Status {
745 category,
746 name: category_word(category),
747 });
748 let write = ItemWrite {
749 target,
750 item: Project {
751 id: request.id.clone(),
752 title: request.title.clone(),
753 content: Some(content),
754 status,
755 labels: request.labels.as_deref().map_or(labels, self::labels),
756 url: None,
757 location: None,
758 created_at: None,
759 updated_at: None,
760 metadata,
761 repositories: request.repositories.clone().unwrap_or(repositories),
762 },
763 depends_on,
764 };
765 let written = match answers {
766 Some(answers) => {
767 source
768 .source()
769 .write_project_rendered(&write, answers)
770 .await
771 }
772 None => source.source().write_project(&write).await,
773 }
774 .map_err(|error| source_failed(source, error))?;
775 let id = GlobalId::new(request.source.clone(), written);
776 let project = source
777 .source()
778 .get_project(&id.native)
779 .await
780 .map_err(|error| source_failed(source, error))?
781 .ok_or_else(|| EngineError::NoSuchProject { id: id.to_string() })?;
782 Ok(Qualified { id, item: project })
783 }
784
785 pub async fn template_answers(
794 &self,
795 record: RenderedRecord,
796 id: &GlobalId,
797 ) -> Result<TemplateAnswers, EngineError> {
798 let source = self.built(&id.source)?;
799 if record == RenderedRecord::Document {
800 documentary(source)?;
801 }
802 self.read_item(source, record, id).await?;
803 self.stored(source, record, id)
804 .await?
805 .map(TemplateAnswers)
806 .ok_or_else(|| EngineError::NoStoredAnswers {
807 record,
808 id: id.to_string(),
809 reason: UnusedAnswers::NoneStored {
810 source: id.source.to_string(),
811 }
812 .to_string(),
813 })
814 }
815
816 pub async fn regeneration(
829 &self,
830 record: RenderedRecord,
831 id: &GlobalId,
832 request: &RenderRequest,
833 ) -> Result<Regeneration, EngineError> {
834 let source = self.built(&id.source)?;
835 if record == RenderedRecord::Document {
836 documentary(source)?;
837 }
838 let (content, metadata) = self.read_item(source, record, id).await?;
839 let read = TemplateProvenance::read(&metadata);
840 let template = match (&request.template, &read) {
841 (RenderTemplate::Given(given), _) => given.clone(),
842 (RenderTemplate::Recorded { .. }, Err(problem)) => {
845 return Err(EngineError::MalformedProvenance {
846 record,
847 id: id.to_string(),
848 problem: problem.clone(),
849 });
850 }
851 (RenderTemplate::Recorded { search_path }, Ok(Some(recorded)))
852 if Path::new(&recorded.template).is_file() =>
853 {
854 TemplateInput::File {
855 path: PathBuf::from(&recorded.template),
856 search_path: search_path.clone(),
857 }
858 }
859 (RenderTemplate::Recorded { .. }, Ok(Some(recorded))) => {
860 return Err(EngineError::TemplateNotAFile {
861 record,
862 id: id.to_string(),
863 reference: recorded.template.clone(),
864 });
865 }
866 (RenderTemplate::Recorded { .. }, Ok(None)) => {
867 return Err(EngineError::NoTemplate {
868 record,
869 id: id.to_string(),
870 });
871 }
872 };
873 let reference = template.reference();
874 let template = template
875 .load()
876 .map_err(|error| EngineError::Template { error })?;
877 let held = self.stored(source, record, id).await?;
878 let unused = |reason| Stored::Unused {
879 reason,
880 held: held.clone(),
881 };
882 let stored = match (&read, &held) {
883 (Err(problem), _) => unused(UnusedAnswers::MalformedProvenance {
884 problem: problem.clone(),
885 }),
886 (Ok(None), _) => unused(UnusedAnswers::NoProvenance),
887 (Ok(Some(_)), None) => unused(UnusedAnswers::NoneStored {
888 source: id.source.to_string(),
889 }),
890 (Ok(Some(recorded)), Some(answers))
891 if crate::template::answers_digest(answers) != recorded.answers_digest.as_str() =>
892 {
893 unused(UnusedAnswers::OutOfStep)
894 }
895 (Ok(Some(_)), Some(answers)) => Stored::Trusted(answers.clone()),
896 };
897 let mut base = Answers::new();
898 if let Stored::Trusted(answers) = &stored {
899 for (name, value) in answers.iter().filter(|(_, value)| !value.is_null()) {
902 base.set(name.clone(), value.clone());
903 }
904 }
905 Ok(Regeneration {
906 id: id.clone(),
907 record,
908 template,
909 reference,
910 base,
911 stored,
912 keeps_answers: source.source().keeps_template_answers(),
913 content,
914 provenance: read.ok().flatten(),
915 })
916 }
917
918 pub async fn regenerate(
928 &self,
929 regeneration: &Regeneration,
930 answers: &Answers,
931 dry_run: bool,
932 ) -> Result<Regenerated, EngineError> {
933 let rendered = regeneration.render(answers)?;
934 let provenance = TemplateProvenance::of(regeneration.reference.clone(), &rendered)
935 .map_err(|error| EngineError::Template { error })?;
936 let changed = regeneration.content != rendered.body
937 || regeneration.provenance.as_ref() != Some(&provenance)
938 || regeneration
941 .stored
942 .held()
943 .map_or(regeneration.keeps_answers, |stored| {
944 *stored != rendered.answers
945 });
946 let id = ®eneration.id;
947 if changed && !dry_run {
948 let source = self.built(&id.source)?;
949 if !source.source().writes().is_supported() {
950 return Err(EngineError::RenderingNotWritable {
951 name: source.name().to_string(),
952 kind: source.kind().to_owned(),
953 record: regeneration.record,
954 });
955 }
956 let value = provenance.to_value();
957 match regeneration.record {
958 RenderedRecord::Task => {
959 source
960 .source()
961 .set_task_rendering(&id.native, &rendered.body, &value, &rendered.answers)
962 .await
963 }
964 RenderedRecord::Document => {
965 source
966 .source()
967 .set_document_rendering(
968 &id.native,
969 &rendered.body,
970 &value,
971 &rendered.answers,
972 )
973 .await
974 }
975 RenderedRecord::Project => {
976 source
977 .source()
978 .set_project_rendering(
979 &id.native,
980 &rendered.body,
981 &value,
982 &rendered.answers,
983 )
984 .await
985 }
986 }
987 .map_err(|error| source_failed(source, error))?
988 .ok_or_else(|| regeneration.record.no_such(id))?;
989 }
990 Ok(Regenerated {
991 id: id.clone(),
992 digest: provenance.digest,
993 body_digest: provenance.body_digest,
994 changed,
995 body: rendered.body,
996 })
997 }
998
999 pub async fn render_task(
1006 &self,
1007 id: &GlobalId,
1008 request: &RenderRequest,
1009 ) -> Result<Regenerated, EngineError> {
1010 let regeneration = self.regeneration(RenderedRecord::Task, id, request).await?;
1011 self.regenerate(®eneration, &request.answers, request.dry_run)
1012 .await
1013 }
1014
1015 pub async fn render_project(
1022 &self,
1023 id: &GlobalId,
1024 request: &RenderRequest,
1025 ) -> Result<Regenerated, EngineError> {
1026 let regeneration = self
1027 .regeneration(RenderedRecord::Project, id, request)
1028 .await?;
1029 self.regenerate(®eneration, &request.answers, request.dry_run)
1030 .await
1031 }
1032
1033 pub async fn render_document(
1040 &self,
1041 id: &GlobalId,
1042 request: &RenderRequest,
1043 ) -> Result<Regenerated, EngineError> {
1044 let regeneration = self
1045 .regeneration(RenderedRecord::Document, id, request)
1046 .await?;
1047 self.regenerate(®eneration, &request.answers, request.dry_run)
1048 .await
1049 }
1050
1051 fn creatable(
1053 &self,
1054 name: &SourceName,
1055 record: MetadataRecord,
1056 ) -> Result<&ResolvedSource, EngineError> {
1057 let source = self.built(name)?;
1058 if source.source().writes().is_supported() {
1059 return Ok(source);
1060 }
1061 Err(EngineError::NotCreatable {
1062 name: source.name().to_string(),
1063 kind: source.kind().to_owned(),
1064 record,
1065 })
1066 }
1067
1068 async fn read_item(
1070 &self,
1071 source: &ResolvedSource,
1072 record: RenderedRecord,
1073 id: &GlobalId,
1074 ) -> Result<(String, BTreeMap<String, Value>), EngineError> {
1075 let read = match record {
1076 RenderedRecord::Task => source
1077 .source()
1078 .get_task(&id.native)
1079 .await
1080 .map(|task| task.map(|task| (task.content, task.metadata))),
1081 RenderedRecord::Document => source
1082 .source()
1083 .get_document(&id.native)
1084 .await
1085 .map(|document| document.map(|document| (document.content, document.metadata))),
1086 RenderedRecord::Project => source
1087 .source()
1088 .get_project(&id.native)
1089 .await
1090 .map(|project| project.map(|project| (project.content, project.metadata))),
1091 }
1092 .map_err(|error| source_failed(source, error))?;
1093 let (content, metadata) = read.ok_or_else(|| record.no_such(id))?;
1094 Ok((content.unwrap_or_default(), metadata))
1095 }
1096
1097 async fn stored(
1099 &self,
1100 source: &ResolvedSource,
1101 record: RenderedRecord,
1102 id: &GlobalId,
1103 ) -> Result<Option<BTreeMap<String, Value>>, EngineError> {
1104 match record {
1105 RenderedRecord::Task => source.source().task_template_answers(&id.native).await,
1106 RenderedRecord::Document => source.source().document_template_answers(&id.native).await,
1107 RenderedRecord::Project => source.source().project_template_answers(&id.native).await,
1108 }
1109 .map_err(|error| source_failed(source, error))
1110 }
1111}
1112
1113fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
1115 if source.source().capabilities().documents.is_native() {
1116 return Ok(());
1117 }
1118 Err(EngineError::NoDocuments {
1119 name: source.name().to_string(),
1120 kind: source.kind().to_owned(),
1121 })
1122}
1123
1124fn endpoint(far: &GlobalId, near: &SourceName) -> DependencyEndpoint {
1127 if &far.source == near {
1128 return DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task);
1129 }
1130 DependencyEndpoint::new(far.to_string(), ItemKind::Task)
1131 .unwrap_or_else(|_| DependencyEndpoint::from_native(far.native.clone(), ItemKind::Task))
1132}
1133
1134fn labels(names: &[String]) -> Vec<Label> {
1137 names
1138 .iter()
1139 .map(|name| Label {
1140 id: NativeId::from(name.as_str()),
1141 name: name.clone(),
1142 color: None,
1143 })
1144 .collect()
1145}
1146
1147fn category_word(category: StatusCategory) -> String {
1150 serde_json::to_value(category)
1151 .ok()
1152 .and_then(|word| word.as_str().map(str::to_owned))
1153 .unwrap_or_else(|| "todo".to_owned())
1154}
1155
1156fn slug(title: &str, fallback: &str) -> String {
1159 let mut slug = String::with_capacity(title.len());
1160 for character in title.chars() {
1161 if character.is_ascii_alphanumeric() {
1162 slug.push(character.to_ascii_lowercase());
1163 } else if !slug.is_empty() && !slug.ends_with('-') {
1164 slug.push('-');
1165 }
1166 }
1167 let slug = slug.trim_end_matches('-');
1168 if slug.is_empty() {
1169 fallback.to_owned()
1170 } else {
1171 slug.to_owned()
1172 }
1173}