1use 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#[derive(Debug, Clone, PartialEq)]
46pub struct Body(Content);
47
48#[derive(Debug, Clone, PartialEq)]
49enum Content {
50 Plain(String),
52 Rendered {
55 rendered: RenderedTemplate,
56 provenance: TemplateProvenance,
57 },
58}
59
60impl Body {
61 #[must_use]
63 pub fn plain(text: impl Into<String>) -> Self {
64 Self(Content::Plain(text.into()))
65 }
66
67 pub fn rendered(
73 template: &TemplateInput,
74 rendered: RenderedTemplate,
75 ) -> Result<Self, TemplateError> {
76 Self::rendered_as(template.reference(), rendered)
77 }
78
79 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 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
123struct Parts<'a> {
126 content: String,
127 metadata: BTreeMap<String, Value>,
128 answers: Option<&'a BTreeMap<String, Value>>,
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub struct TaskCreate {
134 pub source: SourceName,
136 pub project: NativeId,
138 pub title: String,
140 pub body: Body,
142 pub status: Option<StatusCategory>,
144 pub labels: Vec<String>,
146 pub repositories: Vec<Repository>,
148 pub depends_on: Vec<GlobalId>,
151 pub delivers: Vec<GlobalId>,
154 pub metadata: BTreeMap<MetadataKey, Value>,
157 pub assets: Vec<AssetPayload>,
160}
161
162#[derive(Debug, Clone, PartialEq)]
164pub struct DocumentCreate {
165 pub source: SourceName,
167 pub project: NativeId,
169 pub title: String,
171 pub id: Option<NativeId>,
175 pub body: Body,
177 pub labels: Vec<String>,
179 pub repositories: Vec<Repository>,
181 pub metadata: BTreeMap<MetadataKey, Value>,
183 pub assets: Vec<AssetPayload>,
186}
187
188#[derive(Debug, Clone, PartialEq)]
195pub struct ProjectCreate {
196 pub source: SourceName,
198 pub id: NativeId,
202 pub title: String,
204 pub body: Body,
206 pub status: Option<StatusCategory>,
209 pub labels: Option<Vec<String>>,
212 pub repositories: Option<Vec<Repository>>,
215 pub metadata: BTreeMap<MetadataKey, Value>,
217}
218
219#[derive(Debug, Clone, PartialEq)]
222pub struct TaskCreated {
223 pub task: Qualified<Task>,
225 pub delivered: Vec<Delivered>,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum RenderedRecord {
232 Task,
234 Document,
236 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#[derive(Debug, Clone, PartialEq)]
269pub enum RenderTemplate {
270 Given(TemplateInput),
273 Recorded {
276 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#[derive(Debug, Clone, PartialEq, Default)]
291pub struct RenderRequest {
292 pub template: RenderTemplate,
294 pub answers: Answers,
297 pub dry_run: bool,
299 pub assets: Vec<AssetPayload>,
305}
306
307#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
309pub struct Regenerated {
310 pub id: GlobalId,
312 pub digest: Sha256Digest,
314 pub body_digest: Sha256Digest,
316 pub changed: bool,
319 pub body: String,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
327#[serde(transparent)]
328pub struct TemplateAnswers(pub BTreeMap<String, Value>);
329
330impl TemplateAnswers {
331 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#[derive(Debug, Clone, PartialEq, Eq)]
344pub enum UnusedAnswers {
345 NoProvenance,
347 NoneStored {
349 source: String,
351 },
352 OutOfStep,
355 MalformedProvenance {
358 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#[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 keeps_answers: bool,
404 content: String,
405 provenance: Option<TemplateProvenance>,
406 held_assets: Vec<Asset>,
409 recorded_assets: Option<AssetUploads>,
410 given_assets: Vec<AssetPayload>,
412}
413
414#[derive(Debug, Clone)]
418enum Stored {
419 Trusted(BTreeMap<String, Value>),
421 Unused {
424 reason: UnusedAnswers,
425 held: Option<BTreeMap<String, Value>>,
426 },
427}
428
429impl Stored {
430 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 #[must_use]
442 pub fn id(&self) -> &GlobalId {
443 &self.id
444 }
445
446 #[must_use]
448 pub fn template(&self) -> &Template {
449 &self.template
450 }
451
452 #[must_use]
454 pub fn base(&self) -> &Answers {
455 &self.base
456 }
457
458 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 #[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 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 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 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 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 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 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 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 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 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 (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 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 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 ®eneration.held_assets,
1027 regeneration.recorded_assets.as_ref(),
1028 );
1029 let changed = regeneration.content != rendered.body
1030 || !assets::same_set(®eneration.held_assets, &carried)
1031 || regeneration.provenance.as_ref() != Some(&provenance)
1032 || regeneration
1035 .stored
1036 .held()
1037 .map_or(regeneration.keeps_answers, |stored| {
1038 *stored != rendered.answers
1039 });
1040 let id = ®eneration.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 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(®eneration, &request.answers, request.dry_run)
1128 .await
1129 }
1130
1131 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(®eneration, &request.answers, request.dry_run)
1146 .await
1147 }
1148
1149 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(®eneration, &request.answers, request.dry_run)
1164 .await
1165 }
1166
1167 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 = ®eneration.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 ®eneration.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 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, ®eneration.given_assets, &kept)?;
1215 if let Some(first) = carried.first() {
1216 assets::stores(source, &record, &first.name)?;
1217 }
1218 Ok(carried)
1219 }
1220
1221 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 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 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
1283fn 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
1294fn 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
1304fn 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
1317fn 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
1326fn 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}