1mod copy;
19mod fetch;
20mod join;
21mod local;
22mod resume;
23
24use std::collections::BTreeMap;
25use std::num::NonZeroU32;
26use std::sync::atomic::{AtomicU32, Ordering};
27
28use onetaskgraph_plugin_api::{
29 Capabilities, Cursor, DependencyEdge, Direction, Document, DocumentQuery, Label, LabelFilter,
30 NativeId, Page, PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver, SourceError,
31 SourceName, StatusCategory, Task, TaskQuery, TextFields, TextQuery,
32};
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35
36use crate::GlobalId;
37use crate::config::Config;
38use crate::plan::{PageToken, Predicate, QueryPlan, QueryResponse, SourceFailure, SourcePlan};
39use crate::resolve::{ResolvedSource, UnavailableSource, resolve_available};
40
41use fetch::{Fetched, Stream, fits, merge, unrepeated, walk};
42use join::join_all;
43use local::{LocalDocuments, LocalProjects, LocalTasks};
44pub(crate) use resume::{Owed, Resumption, StreamState};
45use resume::{Resume, StreamKind};
46
47pub use copy::{CopyAction, CopyItems, CopyOutcome, CopyReport, CopyRequest, CopyScope, MatchBy};
48pub use local::ProjectSelector;
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
55pub struct Qualified<T> {
56 pub id: GlobalId,
58 pub item: T,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67pub struct QualifiedEdge {
68 pub from: QualifiedEndpoint,
73 pub to: QualifiedEndpoint,
75 pub kind: onetaskgraph_plugin_api::DependencyKind,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
81pub struct QualifiedEndpoint {
82 pub id: GlobalId,
84 pub kind: onetaskgraph_plugin_api::ItemKind,
86}
87
88impl std::fmt::Display for QualifiedEndpoint {
89 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 self.id.fmt(formatter)
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
96#[serde(tag = "kind", rename_all = "kebab-case")]
97pub enum SearchHit {
98 Task(Qualified<Task>),
100 Project(Qualified<Project>),
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
106#[serde(rename_all = "kebab-case")]
107pub enum SearchKind {
108 Tasks,
110 Projects,
112 #[default]
114 Both,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
119pub struct SourceListing {
120 pub source: SourceName,
122 pub kind: String,
134 #[serde(flatten)]
136 pub state: SourceState,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
141#[serde(tag = "state", rename_all = "kebab-case")]
142pub enum SourceState {
143 Available {
145 capabilities: Capabilities,
147 },
148 Unavailable {
150 error: SourceError,
152 },
153}
154
155#[derive(Debug, Clone, PartialEq)]
157pub struct Paging {
158 pub limit: NonZeroU32,
160 pub token: Option<PageToken>,
162}
163
164#[derive(Debug, Clone, Default, PartialEq)]
166pub struct Filters {
167 pub text: Option<TextQuery>,
169 pub labels: LabelFilter,
171 pub statuses: Vec<StatusCategory>,
173}
174
175#[derive(Debug, Clone)]
177pub struct TaskRequest {
178 pub sources: Vec<SourceName>,
180 pub filters: Filters,
182 pub project: ProjectSelector,
184 pub paging: Paging,
186}
187
188#[derive(Debug, Clone)]
190pub struct ProjectRequest {
191 pub sources: Vec<SourceName>,
193 pub filters: Filters,
195 pub paging: Paging,
197}
198
199#[derive(Debug, Clone, Default, PartialEq)]
206pub struct DocumentFilters {
207 pub text: Option<TextQuery>,
209 pub labels: LabelFilter,
211}
212
213#[derive(Debug, Clone)]
215pub struct DocumentRequest {
216 pub sources: Vec<SourceName>,
218 pub filters: DocumentFilters,
220 pub project: ProjectSelector,
222 pub paging: Paging,
224}
225
226#[derive(Debug, Clone)]
228pub struct LabelRequest {
229 pub sources: Vec<SourceName>,
231 pub paging: Paging,
233}
234
235#[derive(Debug, Clone)]
237pub struct SearchRequest {
238 pub sources: Vec<SourceName>,
240 pub text: TextQuery,
242 pub kind: SearchKind,
244 pub paging: Paging,
246}
247
248#[derive(Debug, Clone)]
250pub struct DependencyRequest {
251 pub id: GlobalId,
253 pub direction: Direction,
255 pub paging: Paging,
257}
258
259#[derive(Debug, Clone, PartialEq, thiserror::Error)]
267pub enum EngineError {
268 #[error(
270 "no source named {name:?} is configured\n\
271 next: name one of the configured sources ({configured}), or add {name:?} under \
272 `sources` — `onetaskgraph sources list` shows what this configuration has."
273 )]
274 UnknownSource {
275 name: String,
277 configured: String,
279 },
280
281 #[error(
283 "{message}\n\
284 next: page with a token exactly as the previous page reported it, and against \
285 the same configuration — or drop `--page` to start the walk again."
286 )]
287 Token {
288 message: String,
290 },
291
292 #[error(
294 "no sources are configured\n\
295 next: add one under `sources` in onetaskgraph.yaml — `onetaskgraph schema` \
296 prints what each plugin accepts."
297 )]
298 NoSources,
299
300 #[error(
302 "source {name} cannot be written: its plugin is {kind}, which has no write \
303 side\n\
304 next: copy into a source whose plugin can be written — `onetaskgraph sources \
305 list` reports each one's plugin."
306 )]
307 NotWritable {
308 name: String,
310 kind: String,
312 },
313
314 #[error(
321 "source {name} has no documents: its plugin is {kind}, which holds none\n\
322 next: name a source whose plugin has documents — `onetaskgraph sources list` \
323 reports each one's plugin and what it declares."
324 )]
325 NoDocuments {
326 name: String,
328 kind: String,
330 },
331
332 #[error(
334 "the destination source {name} could not be built: {error}\n\
335 next: fix that source — `onetaskgraph sources list` reports its state — then \
336 copy again."
337 )]
338 DestinationUnavailable {
339 name: String,
341 error: SourceError,
343 },
344
345 #[error(
347 "no item with the id {id}\n\
348 next: check the id, or list what is there — `onetaskgraph task list` and \
349 `onetaskgraph project list` report what the configured sources hold."
350 )]
351 NoSuchItem {
352 id: String,
354 },
355
356 #[error(
361 "{item} was copied from {origin}, which that destination no longer holds\n\
362 next: re-run with --recreate to create a new item there instead, or restore \
363 {origin}."
364 )]
365 StaleOrigin {
366 item: String,
368 origin: String,
370 },
371
372 #[error(
378 "source {name} could not do it: {error}\n\
379 next: fix what the source named above, then copy again."
380 )]
381 SourceRefused {
382 name: String,
384 error: SourceError,
386 },
387
388 #[error(
396 "the copy failed and could not be undone.\n\
397 it failed because: {error}\n\
398 it could not be undone because: {refusal}\n\
399 so the destination still holds: {left_behind}\n\
400 next: remove those items at the destination, then copy again."
401 )]
402 CopyNotUndone {
403 error: Box<EngineError>,
405 left_behind: LeftBehind,
411 refusal: SourceError,
413 },
414}
415
416#[derive(Debug, Clone, PartialEq)]
425pub struct LeftBehind {
426 first: GlobalId,
428 rest: Vec<GlobalId>,
430}
431
432impl LeftBehind {
433 #[must_use]
435 pub fn new(first: GlobalId) -> Self {
436 Self {
437 first,
438 rest: Vec::new(),
439 }
440 }
441
442 pub fn push(&mut self, id: GlobalId) {
444 self.rest.push(id);
445 }
446
447 pub fn iter(&self) -> impl Iterator<Item = &GlobalId> {
449 std::iter::once(&self.first).chain(self.rest.iter())
450 }
451}
452
453impl std::fmt::Display for LeftBehind {
454 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 write!(formatter, "{}", self.first)?;
457 for id in &self.rest {
458 write!(formatter, ", {id}")?;
459 }
460 Ok(())
461 }
462}
463
464pub enum ConfiguredSource {
471 Ready(ResolvedSource),
473 Unavailable(UnavailableSource),
475}
476
477impl ConfiguredSource {
478 #[must_use]
480 pub fn name(&self) -> &SourceName {
481 match self {
482 Self::Ready(source) => source.name(),
483 Self::Unavailable(source) => source.name(),
484 }
485 }
486}
487
488pub struct Engine {
490 sources: Vec<ConfiguredSource>,
492 selection: Vec<SourceName>,
494}
495
496impl Engine {
497 #[must_use]
505 pub fn build(config: &Config, secrets: &dyn SecretResolver) -> Self {
506 let (ready, unavailable) = resolve_available(config, secrets);
507 Self::new(
508 ready
509 .into_iter()
510 .map(ConfiguredSource::Ready)
511 .chain(unavailable.into_iter().map(ConfiguredSource::Unavailable))
512 .collect(),
513 config.selected_sources(),
514 )
515 }
516
517 #[must_use]
520 pub fn new(sources: Vec<ConfiguredSource>, selection: Vec<SourceName>) -> Self {
521 Self { sources, selection }
522 }
523
524 fn ready(&self) -> impl Iterator<Item = &ResolvedSource> {
526 self.sources.iter().filter_map(|source| match source {
527 ConfiguredSource::Ready(ready) => Some(ready),
528 ConfiguredSource::Unavailable(_) => None,
529 })
530 }
531
532 fn unavailable(&self) -> impl Iterator<Item = &UnavailableSource> {
534 self.sources.iter().filter_map(|source| match source {
535 ConfiguredSource::Unavailable(unavailable) => Some(unavailable),
536 ConfiguredSource::Ready(_) => None,
537 })
538 }
539
540 #[must_use]
542 pub fn listing(&self) -> Vec<SourceListing> {
543 let mut listings: Vec<SourceListing> = self
544 .ready()
545 .map(|source| SourceListing {
546 source: source.name().clone(),
547 kind: source.kind().to_owned(),
548 state: SourceState::Available {
549 capabilities: source.source().capabilities(),
550 },
551 })
552 .chain(self.unavailable().map(|source| SourceListing {
553 source: source.name().clone(),
554 kind: source.kind().to_owned(),
555 state: SourceState::Unavailable {
556 error: source.error().clone(),
557 },
558 }))
559 .collect();
560 listings.sort_by(|left, right| left.source.cmp(&right.source));
561 listings
562 }
563
564 #[must_use]
570 pub fn has(&self, name: &SourceName) -> bool {
571 self.sources.iter().any(|source| source.name() == name)
572 }
573
574 pub async fn tasks(
582 &self,
583 request: &TaskRequest,
584 ) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
585 let mut names = self.resolve_selection(&request.sources)?;
586 if let ProjectSelector::Qualified(id) = &request.project {
591 self.known(&id.source)?;
592 names.retain(|name| name == &id.source);
593 }
594 let query = shape("task-list", &names, &(&request.filters, &request.project));
595 let states = resumption(
596 self,
597 request.paging.token.as_ref(),
598 &[StreamKind::Items],
599 &query,
600 )?;
601 let budget = request.paging.limit.get();
602
603 let mut answer = Answer::new();
604 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
605
606 let shapes: Vec<TaskShape> = ready
607 .iter()
608 .map(|source| {
609 shape_tasks(
610 &source.source().capabilities(),
611 &request.filters,
612 &project_filter(&request.project),
613 )
614 })
615 .collect();
616 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
617 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
618
619 let walks = ready
620 .iter()
621 .enumerate()
622 .map(|(index, source)| {
623 fetch_tasks(
624 source,
625 &shapes[index],
626 &starts[index],
627 budget,
628 &counters[index],
629 )
630 })
631 .collect();
632
633 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
634 answer.finish(
635 streams,
636 budget,
637 owed(&states),
638 &query,
639 |name, task: Task| Qualified {
640 id: GlobalId::new(name.clone(), task.id.clone()),
641 item: task,
642 },
643 )
644 }
645
646 pub async fn projects(
652 &self,
653 request: &ProjectRequest,
654 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
655 let names = self.resolve_selection(&request.sources)?;
656 let query = shape("project-list", &names, &request.filters);
657 let states = resumption(
658 self,
659 request.paging.token.as_ref(),
660 &[StreamKind::Items],
661 &query,
662 )?;
663 let budget = request.paging.limit.get();
664
665 let mut answer = Answer::new();
666 let mut with_projects = Vec::new();
672 for source in answer.split(self, &names) {
673 if source.source().capabilities().projects.is_native() {
674 with_projects.push(source);
675 } else {
676 answer.unreachable_predicate(source, Predicate::Project);
677 }
678 }
679 let (ready, starts) = walking(with_projects, &states, StreamKind::Items);
680
681 let shapes: Vec<ProjectShape> = ready
682 .iter()
683 .map(|source| shape_projects(&source.source().capabilities(), &request.filters))
684 .collect();
685 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
686 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
687
688 let walks = ready
689 .iter()
690 .enumerate()
691 .map(|(index, source)| {
692 fetch_projects(
693 source,
694 &shapes[index],
695 &starts[index],
696 budget,
697 &counters[index],
698 )
699 })
700 .collect();
701
702 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
703 answer.finish(
704 streams,
705 budget,
706 owed(&states),
707 &query,
708 |name, project: Project| Qualified {
709 id: GlobalId::new(name.clone(), project.id.clone()),
710 item: project,
711 },
712 )
713 }
714
715 pub async fn documents(
727 &self,
728 request: &DocumentRequest,
729 ) -> Result<QueryResponse<Qualified<Document>>, EngineError> {
730 let mut names = self.resolve_selection(&request.sources)?;
731 if let ProjectSelector::Qualified(id) = &request.project {
734 self.known(&id.source)?;
735 names.retain(|name| name == &id.source);
736 }
737 let query = shape(
738 "document-list",
739 &names,
740 &(&request.filters, &request.project),
741 );
742 let states = resumption(
743 self,
744 request.paging.token.as_ref(),
745 &[StreamKind::Items],
746 &query,
747 )?;
748 let budget = request.paging.limit.get();
749
750 let mut answer = Answer::new();
751 let mut with_documents = Vec::new();
752 for source in answer.split(self, &names) {
753 if source.source().capabilities().documents.is_native() {
754 with_documents.push(source);
755 } else {
756 answer.unreachable_predicate(source, Predicate::Document);
757 }
758 }
759 let (ready, starts) = walking(with_documents, &states, StreamKind::Items);
760
761 let shapes: Vec<DocumentShape> = ready
762 .iter()
763 .map(|source| {
764 shape_documents(
765 &source.source().capabilities(),
766 &request.filters,
767 &project_filter(&request.project),
768 )
769 })
770 .collect();
771 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
772 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
773
774 let walks = ready
775 .iter()
776 .enumerate()
777 .map(|(index, source)| {
778 fetch_documents(
779 source,
780 &shapes[index],
781 &starts[index],
782 budget,
783 &counters[index],
784 )
785 })
786 .collect();
787
788 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
789 answer.finish(
790 streams,
791 budget,
792 owed(&states),
793 &query,
794 |name, document: Document| Qualified {
795 id: GlobalId::new(name.clone(), document.id.clone()),
796 item: document,
797 },
798 )
799 }
800
801 pub async fn labels(
807 &self,
808 request: &LabelRequest,
809 ) -> Result<QueryResponse<Qualified<Label>>, EngineError> {
810 let names = self.resolve_selection(&request.sources)?;
811 let query = shape("label-list", &names, &());
812 let states = resumption(
813 self,
814 request.paging.token.as_ref(),
815 &[StreamKind::Items],
816 &query,
817 )?;
818 let budget = request.paging.limit.get();
819
820 let mut answer = Answer::new();
821 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
822 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
823 let outcomes: Vec<Outcomes> = ready.iter().map(|_| Outcomes::default()).collect();
824
825 let walks = ready
826 .iter()
827 .enumerate()
828 .map(|(index, source)| fetch_labels(source, &starts[index], budget, &counters[index]))
829 .collect();
830
831 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
832 answer.finish(
833 streams,
834 budget,
835 owed(&states),
836 &query,
837 |name, label: Label| Qualified {
838 id: GlobalId::new(name.clone(), label.id.clone()),
839 item: label,
840 },
841 )
842 }
843
844 pub async fn search(
850 &self,
851 request: &SearchRequest,
852 ) -> Result<QueryResponse<SearchHit>, EngineError> {
853 let names = self.resolve_selection(&request.sources)?;
854 let reads: &[StreamKind] = match request.kind {
858 SearchKind::Tasks => &[StreamKind::Tasks],
859 SearchKind::Projects => &[StreamKind::Projects],
860 SearchKind::Both => &[StreamKind::Tasks, StreamKind::Projects],
861 };
862 let query = shape("search", &names, &request.text);
867 let states = resumption(self, request.paging.token.as_ref(), reads, &query)?;
868 let budget = request.paging.limit.get();
869 let filters = Filters {
870 text: Some(request.text.clone()),
871 ..Filters::default()
872 };
873
874 let mut answer = Answer::new();
875
876 let mut ready = Vec::new();
879 let mut kinds = Vec::new();
880 let mut starts = Vec::new();
881 for source in answer.split(self, &names) {
882 let mut streams = Vec::new();
883 if matches!(request.kind, SearchKind::Tasks | SearchKind::Both) {
884 streams.push(StreamKind::Tasks);
885 }
886 if matches!(request.kind, SearchKind::Projects | SearchKind::Both) {
887 if source.source().capabilities().projects.is_native() {
888 streams.push(StreamKind::Projects);
889 } else {
890 answer.unreachable_predicate(source, Predicate::Project);
891 }
892 }
893 for stream in streams {
894 if let Some(resume) = resume_at(&states, source.name(), stream) {
895 ready.push(source);
896 kinds.push(stream);
897 starts.push(resume);
898 }
899 }
900 }
901
902 let shapes: Vec<HitShape> = ready
903 .iter()
904 .zip(kinds.iter())
905 .map(|(source, kind)| shape_hits(&source.source().capabilities(), &filters, *kind))
906 .collect();
907 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
908 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
909
910 let walks = ready
911 .iter()
912 .enumerate()
913 .map(|(index, source)| {
914 fetch_hits(
915 source,
916 &shapes[index],
917 &starts[index],
918 budget,
919 &counters[index],
920 )
921 })
922 .collect();
923
924 let streams =
925 answer.collect_streams(&ready, &kinds, join_all(walks).await, &counters, outcomes);
926 answer.finish(
927 streams,
928 budget,
929 owed(&states),
930 &query,
931 |name, found: Found| match found {
932 Found::Task(task) => SearchHit::Task(Qualified {
933 id: GlobalId::new(name.clone(), task.id.clone()),
934 item: task,
935 }),
936 Found::Project(project) => SearchHit::Project(Qualified {
937 id: GlobalId::new(name.clone(), project.id.clone()),
938 item: project,
939 }),
940 },
941 )
942 }
943
944 pub async fn task(&self, id: &GlobalId) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
951 let name = self.known(&id.source)?;
952 let mut answer = Answer::new();
953 let selected = answer.split(self, std::slice::from_ref(&name));
954 let Some(source) = selected.first() else {
955 return answer.nothing();
956 };
957 let found = source.source().get_task(&id.native).await;
958 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
959 answer.one(source, found, |task| Qualified {
960 id: qualified,
961 item: task,
962 })
963 }
964
965 pub async fn project(
971 &self,
972 id: &GlobalId,
973 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
974 let name = self.known(&id.source)?;
975 let mut answer = Answer::new();
976 let selected = answer.split(self, std::slice::from_ref(&name));
977 let Some(source) = selected.first() else {
978 return answer.nothing();
979 };
980 let found = source.source().get_project(&id.native).await;
981 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
982 answer.one(source, found, |project| Qualified {
983 id: qualified,
984 item: project,
985 })
986 }
987
988 pub async fn document(
998 &self,
999 id: &GlobalId,
1000 ) -> Result<QueryResponse<Qualified<Document>>, EngineError> {
1001 let name = self.known(&id.source)?;
1002 let mut answer = Answer::new();
1003 let selected = answer.split(self, std::slice::from_ref(&name));
1004 let Some(source) = selected.first() else {
1005 return answer.nothing();
1006 };
1007 if !source.source().capabilities().documents.is_native() {
1008 answer.unreachable_predicate(source, Predicate::Document);
1009 return answer.nothing();
1010 }
1011 let found = source.source().get_document(&id.native).await;
1012 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
1013 answer.one(source, found, |document| Qualified {
1014 id: qualified,
1015 item: document,
1016 })
1017 }
1018
1019 pub async fn task_dependencies(
1026 &self,
1027 request: &DependencyRequest,
1028 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1029 self.dependencies(request, Entity::Task).await
1030 }
1031
1032 pub async fn project_dependencies(
1038 &self,
1039 request: &DependencyRequest,
1040 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1041 self.dependencies(request, Entity::Project).await
1042 }
1043
1044 async fn dependencies(
1047 &self,
1048 request: &DependencyRequest,
1049 entity: Entity,
1050 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1051 let name = self.known(&request.id.source)?;
1052 let query = shape(
1053 "dependencies",
1054 std::slice::from_ref(&name),
1055 &(entity, &request.id.native, request.direction),
1056 );
1057 let states = resumption(
1058 self,
1059 request.paging.token.as_ref(),
1060 &[StreamKind::Items],
1061 &query,
1062 )?;
1063 let budget = request.paging.limit.get();
1064
1065 let mut answer = Answer::new();
1066 let (ready, starts) = walking(
1067 answer.split(self, std::slice::from_ref(&name)),
1068 &states,
1069 StreamKind::Items,
1070 );
1071 let Some(source) = ready.first() else {
1072 return answer.nothing();
1073 };
1074
1075 let capabilities = source.source().capabilities();
1076 let support = match entity {
1077 Entity::Task => capabilities.task_dependencies,
1078 Entity::Project => capabilities.project_dependencies,
1079 };
1080 let emulating = request.direction == Direction::DependedOnBy && !support.answers_reverse();
1084 let mut outcomes = Outcomes::default();
1085 if request.direction == Direction::DependedOnBy {
1086 if emulating {
1087 outcomes.record(Predicate::ReverseDependencies, Outcome::Emulated);
1088 } else {
1089 outcomes.record(Predicate::ReverseDependencies, Outcome::PushedDown);
1090 }
1091 }
1092
1093 let counters = vec![AtomicU32::new(0)];
1094 let walked = fetch_edges(
1095 source,
1096 &request.id.native,
1097 request.direction,
1098 entity,
1099 emulating,
1100 &starts[0],
1101 budget,
1102 &counters[0],
1103 )
1104 .await;
1105
1106 let streams = answer.collect(&ready, vec![walked], &counters, vec![outcomes]);
1107 answer.finish(
1108 streams,
1109 budget,
1110 owed(&states),
1111 &query,
1112 |name, edge: DependencyEdge| QualifiedEdge {
1113 from: qualify_endpoint(name, edge.from),
1114 to: qualify_endpoint(name, edge.to),
1115 kind: edge.kind,
1116 },
1117 )
1118 }
1119
1120 fn resolve_selection(&self, asked: &[SourceName]) -> Result<Vec<SourceName>, EngineError> {
1122 if asked.is_empty() {
1123 if self.selection.is_empty() {
1124 return Err(EngineError::NoSources);
1125 }
1126 return Ok(self.selection.clone());
1127 }
1128 asked.iter().map(|name| self.known(name)).collect()
1129 }
1130
1131 fn known(&self, name: &SourceName) -> Result<SourceName, EngineError> {
1133 if self.has(name) {
1134 return Ok(name.clone());
1135 }
1136 if self.sources.is_empty() {
1137 return Err(EngineError::NoSources);
1138 }
1139 Err(EngineError::UnknownSource {
1140 name: name.to_string(),
1141 configured: self
1142 .listing()
1143 .iter()
1144 .map(|listing| listing.source.to_string())
1145 .collect::<Vec<_>>()
1146 .join(", "),
1147 })
1148 }
1149}
1150
1151fn qualify_endpoint(
1152 source: &SourceName,
1153 endpoint: onetaskgraph_plugin_api::DependencyEndpoint,
1154) -> QualifiedEndpoint {
1155 let kind = endpoint.kind;
1156 let is_qualified = endpoint.is_qualified();
1157 let endpoint_id = endpoint.into_id();
1158 QualifiedEndpoint {
1159 id: if is_qualified {
1160 endpoint_id
1161 .parse()
1162 .expect("plugin-api validates qualified dependency endpoints")
1163 } else {
1164 GlobalId::new(source.clone(), NativeId(endpoint_id))
1165 },
1166 kind,
1167 }
1168}
1169
1170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1172enum Entity {
1173 Task,
1175 Project,
1177}
1178
1179enum Found {
1181 Task(Task),
1183 Project(Project),
1185}
1186
1187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1189enum Outcome {
1190 PushedDown,
1192 AppliedLocally,
1194 Emulated,
1196 Unavailable,
1198}
1199
1200#[derive(Debug, Clone, Default, PartialEq)]
1212struct Outcomes(BTreeMap<Predicate, Outcome>);
1213
1214impl Outcomes {
1215 fn record(&mut self, predicate: Predicate, outcome: Outcome) {
1221 self.0.insert(predicate, outcome);
1222 }
1223
1224 fn record_all(&mut self, predicates: impl IntoIterator<Item = Predicate>, outcome: Outcome) {
1227 for predicate in predicates {
1228 self.record(predicate, outcome);
1229 }
1230 }
1231
1232 fn with(&self, outcome: Outcome) -> Vec<Predicate> {
1234 self.0
1235 .iter()
1236 .filter(|(_, recorded)| **recorded == outcome)
1237 .map(|(predicate, _)| *predicate)
1238 .collect()
1239 }
1240}
1241
1242struct TaskShape {
1244 pushed: TaskQuery,
1246 local: LocalTasks,
1248 outcomes: Outcomes,
1250}
1251
1252struct ProjectShape {
1254 pushed: ProjectQuery,
1256 local: LocalProjects,
1258 outcomes: Outcomes,
1260}
1261
1262struct DocumentShape {
1264 pushed: DocumentQuery,
1266 local: LocalDocuments,
1268 outcomes: Outcomes,
1270}
1271
1272struct HitShape {
1274 stream: StreamKind,
1276 tasks: TaskQuery,
1278 projects: ProjectQuery,
1280 local_tasks: LocalTasks,
1282 local_projects: LocalProjects,
1284 outcomes: Outcomes,
1286}
1287
1288struct Answer {
1294 plans: Vec<SourcePlan>,
1296 errors: Vec<SourceFailure>,
1298}
1299
1300impl Answer {
1301 fn new() -> Self {
1302 Self {
1303 plans: Vec::new(),
1304 errors: Vec::new(),
1305 }
1306 }
1307
1308 fn split<'a>(&mut self, engine: &'a Engine, names: &[SourceName]) -> Vec<&'a ResolvedSource> {
1313 let mut selected = Vec::new();
1314 for name in names {
1315 match engine.sources.iter().find(|source| source.name() == name) {
1316 Some(ConfiguredSource::Ready(source)) => selected.push(source),
1317 Some(ConfiguredSource::Unavailable(source)) => {
1318 self.errors.push(source.failure());
1319 }
1320 None => {}
1321 }
1322 }
1323 selected
1324 }
1325
1326 fn unreachable_predicate(&mut self, source: &ResolvedSource, predicate: Predicate) {
1328 let mut outcomes = Outcomes::default();
1329 outcomes.record(predicate, Outcome::Unavailable);
1330 self.plans.push(plan_for(source, outcomes, 0));
1331 }
1332
1333 fn collect<T>(
1335 &mut self,
1336 ready: &[&ResolvedSource],
1337 walked: Vec<Result<Fetched<T>, SourceError>>,
1338 counters: &[AtomicU32],
1339 outcomes: Vec<Outcomes>,
1340 ) -> Vec<Stream<T>> {
1341 let kinds = vec![StreamKind::Items; ready.len()];
1342 self.collect_streams(ready, &kinds, walked, counters, outcomes)
1343 }
1344
1345 fn collect_streams<T>(
1347 &mut self,
1348 ready: &[&ResolvedSource],
1349 kinds: &[StreamKind],
1350 walked: Vec<Result<Fetched<T>, SourceError>>,
1351 counters: &[AtomicU32],
1352 outcomes: Vec<Outcomes>,
1353 ) -> Vec<Stream<T>> {
1354 let mut streams = Vec::new();
1355 for (index, result) in walked.into_iter().enumerate() {
1356 let source = ready[index];
1357 let pages = counters[index].load(Ordering::Relaxed);
1358 self.plans
1359 .push(plan_for(source, outcomes[index].clone(), pages));
1360 match result {
1361 Ok(fetched) => streams.push(Stream {
1362 source: source.name().clone(),
1363 kind: kinds[index],
1364 fetched,
1365 }),
1366 Err(error) => self.errors.push(SourceFailure {
1369 source: source.name().clone(),
1370 error,
1371 }),
1372 }
1373 }
1374 streams
1375 }
1376
1377 fn one<T, U>(
1379 mut self,
1380 source: &ResolvedSource,
1381 found: Result<Option<T>, SourceError>,
1382 qualify: impl FnOnce(T) -> U,
1383 ) -> Result<QueryResponse<U>, EngineError> {
1384 self.plans.push(plan_for(source, Outcomes::default(), 1));
1385 let items = match found {
1386 Ok(Some(item)) => vec![qualify(item)],
1387 Ok(None) => Vec::new(),
1388 Err(error) => {
1389 self.errors.push(SourceFailure {
1390 source: source.name().clone(),
1391 error,
1392 });
1393 Vec::new()
1394 }
1395 };
1396 Ok(QueryResponse {
1397 items,
1398 next: None,
1399 plan: QueryPlan {
1400 per_source: merge_plans(self.plans),
1401 },
1402 errors: self.errors,
1403 })
1404 }
1405
1406 fn nothing<U>(self) -> Result<QueryResponse<U>, EngineError> {
1408 Ok(QueryResponse {
1409 items: Vec::new(),
1410 next: None,
1411 plan: QueryPlan {
1412 per_source: merge_plans(self.plans),
1413 },
1414 errors: self.errors,
1415 })
1416 }
1417
1418 fn finish<T, U>(
1423 self,
1424 streams: Vec<Stream<T>>,
1425 budget: u32,
1426 first: Option<&Owed>,
1427 query: &str,
1428 qualify: impl Fn(&SourceName, T) -> U,
1429 ) -> Result<QueryResponse<U>, EngineError> {
1430 let (rows, states, owed) = merge(streams, budget, first);
1431 let next = (!states.is_empty()).then(|| PageToken::encode(query, owed, &states));
1432 Ok(QueryResponse {
1433 items: rows
1434 .into_iter()
1435 .map(|(name, item)| qualify(&name, item))
1436 .collect(),
1437 next,
1438 plan: QueryPlan {
1439 per_source: merge_plans(self.plans),
1440 },
1441 errors: self.errors,
1442 })
1443 }
1444}
1445
1446fn plan_for(source: &ResolvedSource, outcomes: Outcomes, pages: u32) -> SourcePlan {
1449 SourcePlan {
1450 source: source.name().clone(),
1451 kind: source.kind().to_owned(),
1452 pushed_down: outcomes.with(Outcome::PushedDown),
1453 applied_locally: outcomes.with(Outcome::AppliedLocally),
1454 emulated: outcomes.with(Outcome::Emulated),
1455 unavailable: outcomes.with(Outcome::Unavailable),
1456 pages_fetched: pages,
1457 }
1458}
1459
1460fn merge_plans(plans: Vec<SourcePlan>) -> Vec<SourcePlan> {
1465 let mut merged: Vec<SourcePlan> = Vec::new();
1466 for plan in plans {
1467 if let Some(existing) = merged
1468 .iter_mut()
1469 .find(|existing| existing.source == plan.source)
1470 {
1471 existing.pushed_down.extend(plan.pushed_down);
1472 existing.applied_locally.extend(plan.applied_locally);
1473 existing.emulated.extend(plan.emulated);
1474 existing.unavailable.extend(plan.unavailable);
1475 existing.pages_fetched = existing.pages_fetched.saturating_add(plan.pages_fetched);
1476 for list in [
1477 &mut existing.pushed_down,
1478 &mut existing.applied_locally,
1479 &mut existing.emulated,
1480 &mut existing.unavailable,
1481 ] {
1482 list.sort_unstable();
1483 list.dedup();
1484 }
1485 } else {
1486 merged.push(plan);
1487 }
1488 }
1489 merged
1490}
1491
1492fn walking<'a>(
1498 selected: Vec<&'a ResolvedSource>,
1499 states: &Option<Resumption>,
1500 kind: StreamKind,
1501) -> (Vec<&'a ResolvedSource>, Vec<Resume>) {
1502 let mut ready = Vec::new();
1503 let mut starts = Vec::new();
1504 for source in selected {
1505 if let Some(resume) = resume_at(states, source.name(), kind) {
1506 ready.push(source);
1507 starts.push(resume);
1508 }
1509 }
1510 (ready, starts)
1511}
1512
1513fn shape(verb: &str, sources: &[SourceName], filters: &impl std::fmt::Debug) -> String {
1531 let names: Vec<&str> = sources.iter().map(SourceName::as_str).collect();
1532 fingerprint(&format!("{verb}|{names:?}|{filters:?}"))
1533}
1534
1535fn fingerprint(text: &str) -> String {
1537 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1538 for byte in text.as_bytes() {
1539 hash ^= u64::from(*byte);
1540 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1541 }
1542 format!("{hash:016x}")
1543}
1544
1545fn owed(document: &Option<Resumption>) -> Option<&Owed> {
1550 document.as_ref()?.owed.as_ref()
1551}
1552
1553fn resume_at(states: &Option<Resumption>, source: &SourceName, kind: StreamKind) -> Option<Resume> {
1555 match states {
1556 None => Some(Resume::default()),
1557 Some(document) => document
1558 .streams
1559 .iter()
1560 .find(|state| &state.source == source && state.stream == kind)
1561 .map(|state| state.resume.clone()),
1562 }
1563}
1564
1565fn resumption(
1596 engine: &Engine,
1597 token: Option<&PageToken>,
1598 reads: &[StreamKind],
1599 query: &str,
1600) -> Result<Option<Resumption>, EngineError> {
1601 let Some(document) = token.map(PageToken::decode) else {
1602 return Ok(None);
1603 };
1604
1605 if document.query != query {
1612 return Err(EngineError::Token {
1613 message: "this page token was written by a different query — resume the walk it \
1614 came from, or drop --page to start this one from the beginning"
1615 .to_owned(),
1616 });
1617 }
1618 let states = &document.streams;
1619
1620 let mut seen: Vec<(&SourceName, StreamKind)> = Vec::new();
1621 for state in states {
1622 if !reads.contains(&state.stream) {
1623 return Err(EngineError::Token {
1624 message: format!(
1625 "this page token resumes {}, which this command does not read — it \
1626 was written by a different query",
1627 state.stream.describe()
1628 ),
1629 });
1630 }
1631 let ceiling = engine
1632 .ready()
1633 .find(|source| source.name() == &state.source)
1634 .map(ceiling);
1635 if ceiling.is_none() && !engine.has(&state.source) {
1636 return Err(EngineError::Token {
1637 message: format!(
1638 "this page token resumes a source called {:?}, which this \
1639 configuration does not have",
1640 state.source.as_str()
1641 ),
1642 });
1643 }
1644 if let Some(ceiling) = ceiling
1645 && state.resume.skip >= ceiling
1646 {
1647 return Err(EngineError::Token {
1648 message: format!(
1649 "this page token resumes {} rows into a page of source {:?}, which \
1650 serves at most {ceiling}",
1651 state.resume.skip,
1652 state.source.as_str()
1653 ),
1654 });
1655 }
1656 if seen.contains(&(&state.source, state.stream)) {
1657 return Err(EngineError::Token {
1658 message: format!(
1659 "this page token gives source {:?} two places to resume from",
1660 state.source.as_str()
1661 ),
1662 });
1663 }
1664 seen.push((&state.source, state.stream));
1665 }
1666
1667 if let Some(owed) = &document.owed
1672 && !document
1673 .streams
1674 .iter()
1675 .any(|state| state.source == owed.source && state.stream == owed.stream)
1676 {
1677 return Err(EngineError::Token {
1678 message: format!(
1679 "this page token owes the next row to a stream it does not resume, \
1680 {:?}'s {}",
1681 owed.source.as_str(),
1682 owed.stream.describe()
1683 ),
1684 });
1685 }
1686
1687 Ok(Some(document))
1688}
1689
1690fn project_filter(selector: &ProjectSelector) -> ProjectFilter {
1696 match selector {
1697 ProjectSelector::Any => ProjectFilter::Any,
1698 ProjectSelector::Orphans => ProjectFilter::Orphans,
1699 ProjectSelector::Native(id) => ProjectFilter::Is(id.clone()),
1700 ProjectSelector::Qualified(id) => ProjectFilter::Is(id.native.clone()),
1701 }
1702}
1703
1704fn text_predicates(fields: TextFields) -> Vec<Predicate> {
1706 match fields {
1707 TextFields::Title => vec![Predicate::SearchTitle],
1708 TextFields::Content => vec![Predicate::SearchContent],
1709 TextFields::TitleOrContent => vec![Predicate::SearchTitle, Predicate::SearchContent],
1710 }
1711}
1712
1713fn searches_natively(capabilities: &Capabilities, fields: TextFields) -> bool {
1720 match fields {
1721 TextFields::Title => capabilities.search_title.is_native(),
1722 TextFields::Content => capabilities.search_content.is_native(),
1723 TextFields::TitleOrContent => {
1724 capabilities.search_title.is_native() && capabilities.search_content.is_native()
1725 }
1726 }
1727}
1728
1729fn shape_tasks(
1731 capabilities: &Capabilities,
1732 filters: &Filters,
1733 project: &ProjectFilter,
1734) -> TaskShape {
1735 let mut pushed = TaskQuery::default();
1736 let mut local = LocalTasks::default();
1737 let mut outcomes = Outcomes::default();
1738
1739 if !filters.labels.is_empty() {
1740 if capabilities.filter_by_label.is_native() {
1741 pushed.labels = filters.labels.clone();
1742 outcomes.record(Predicate::Label, Outcome::PushedDown);
1743 } else {
1744 local.labels = Some(filters.labels.clone());
1745 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1746 }
1747 }
1748 if !filters.statuses.is_empty() {
1749 if capabilities.filter_by_status.is_native() {
1750 pushed.statuses.clone_from(&filters.statuses);
1751 outcomes.record(Predicate::Status, Outcome::PushedDown);
1752 } else {
1753 local.statuses.clone_from(&filters.statuses);
1754 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1755 }
1756 }
1757 if let Some(text) = &filters.text {
1758 let predicates = text_predicates(text.fields);
1759 if searches_natively(capabilities, text.fields) {
1760 pushed.text = Some(text.clone());
1761 outcomes.record_all(predicates, Outcome::PushedDown);
1762 } else {
1763 local.text = Some(text.clone());
1764 outcomes.record_all(predicates, Outcome::AppliedLocally);
1765 }
1766 }
1767 match project {
1768 ProjectFilter::Any => {}
1769 ProjectFilter::Orphans => {
1770 if capabilities.orphan_tasks.is_native() {
1771 pushed.project = ProjectFilter::Orphans;
1772 outcomes.record(Predicate::Project, Outcome::PushedDown);
1773 } else {
1774 local.project = Some(ProjectFilter::Orphans);
1775 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1776 }
1777 }
1778 ProjectFilter::Is(id) => {
1779 if capabilities.projects.is_native() {
1780 pushed.project = ProjectFilter::Is(id.clone());
1781 outcomes.record(Predicate::Project, Outcome::PushedDown);
1782 } else {
1783 local.project = Some(ProjectFilter::Is(id.clone()));
1784 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1785 }
1786 }
1787 }
1788
1789 TaskShape {
1790 pushed,
1791 local,
1792 outcomes,
1793 }
1794}
1795
1796fn shape_projects(capabilities: &Capabilities, filters: &Filters) -> ProjectShape {
1798 let mut pushed = ProjectQuery::default();
1799 let mut local = LocalProjects::default();
1800 let mut outcomes = Outcomes::default();
1801
1802 if !filters.labels.is_empty() {
1803 if capabilities.filter_by_label.is_native() {
1804 pushed.labels = filters.labels.clone();
1805 outcomes.record(Predicate::Label, Outcome::PushedDown);
1806 } else {
1807 local.labels = Some(filters.labels.clone());
1808 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1809 }
1810 }
1811 if !filters.statuses.is_empty() {
1812 if capabilities.filter_by_status.is_native() {
1813 pushed.statuses.clone_from(&filters.statuses);
1814 outcomes.record(Predicate::Status, Outcome::PushedDown);
1815 } else {
1816 local.statuses.clone_from(&filters.statuses);
1817 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1818 }
1819 }
1820 if let Some(text) = &filters.text {
1821 let predicates = text_predicates(text.fields);
1822 if searches_natively(capabilities, text.fields) {
1823 pushed.text = Some(text.clone());
1824 outcomes.record_all(predicates, Outcome::PushedDown);
1825 } else {
1826 local.text = Some(text.clone());
1827 outcomes.record_all(predicates, Outcome::AppliedLocally);
1828 }
1829 }
1830
1831 ProjectShape {
1832 pushed,
1833 local,
1834 outcomes,
1835 }
1836}
1837
1838fn shape_documents(
1843 capabilities: &Capabilities,
1844 filters: &DocumentFilters,
1845 project: &ProjectFilter,
1846) -> DocumentShape {
1847 let mut pushed = DocumentQuery::default();
1848 let mut local = LocalDocuments::default();
1849 let mut outcomes = Outcomes::default();
1850
1851 if !filters.labels.is_empty() {
1852 if capabilities.filter_by_label.is_native() {
1853 pushed.labels = filters.labels.clone();
1854 outcomes.record(Predicate::Label, Outcome::PushedDown);
1855 } else {
1856 local.labels = Some(filters.labels.clone());
1857 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1858 }
1859 }
1860 if let Some(text) = &filters.text {
1861 let predicates = text_predicates(text.fields);
1862 if searches_natively(capabilities, text.fields) {
1863 pushed.text = Some(text.clone());
1864 outcomes.record_all(predicates, Outcome::PushedDown);
1865 } else {
1866 local.text = Some(text.clone());
1867 outcomes.record_all(predicates, Outcome::AppliedLocally);
1868 }
1869 }
1870 match project {
1871 ProjectFilter::Any => {}
1872 ProjectFilter::Orphans => {
1873 if capabilities.orphan_tasks.is_native() {
1874 pushed.project = ProjectFilter::Orphans;
1875 outcomes.record(Predicate::Project, Outcome::PushedDown);
1876 } else {
1877 local.project = Some(ProjectFilter::Orphans);
1878 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1879 }
1880 }
1881 ProjectFilter::Is(id) => {
1882 if capabilities.projects.is_native() {
1883 pushed.project = ProjectFilter::Is(id.clone());
1884 outcomes.record(Predicate::Project, Outcome::PushedDown);
1885 } else {
1886 local.project = Some(ProjectFilter::Is(id.clone()));
1887 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1888 }
1889 }
1890 }
1891
1892 DocumentShape {
1893 pushed,
1894 local,
1895 outcomes,
1896 }
1897}
1898
1899fn shape_hits(capabilities: &Capabilities, filters: &Filters, stream: StreamKind) -> HitShape {
1901 match stream {
1902 StreamKind::Projects => {
1903 let shaped = shape_projects(capabilities, filters);
1904 HitShape {
1905 stream,
1906 tasks: TaskQuery::default(),
1907 projects: shaped.pushed,
1908 local_tasks: LocalTasks::default(),
1909 local_projects: shaped.local,
1910 outcomes: shaped.outcomes,
1911 }
1912 }
1913 StreamKind::Items | StreamKind::Tasks => {
1914 let shaped = shape_tasks(capabilities, filters, &ProjectFilter::Any);
1915 HitShape {
1916 stream,
1917 tasks: shaped.pushed,
1918 projects: ProjectQuery::default(),
1919 local_tasks: shaped.local,
1920 local_projects: LocalProjects::default(),
1921 outcomes: shaped.outcomes,
1922 }
1923 }
1924 }
1925}
1926
1927fn page_size(compensating: bool, budget: u32, ceiling: u32) -> u32 {
1934 if compensating {
1935 ceiling
1936 } else {
1937 budget.min(ceiling)
1938 }
1939}
1940
1941fn ceiling(source: &ResolvedSource) -> u32 {
1943 source.source().capabilities().max_page_size.max(1)
1944}
1945
1946async fn fetch_tasks(
1948 source: &ResolvedSource,
1949 shape: &TaskShape,
1950 start: &Resume,
1951 budget: u32,
1952 calls: &AtomicU32,
1953) -> Result<Fetched<Task>, SourceError> {
1954 let compensating = shape.local != LocalTasks::default();
1955 walk(
1956 start,
1957 budget,
1958 page_size(compensating, budget, ceiling(source)),
1959 |task| shape.local.keeps(task),
1960 |cursor, limit| async move {
1961 calls.fetch_add(1, Ordering::Relaxed);
1962 let request = PageRequest { cursor, limit };
1963 source.source().query_tasks(&shape.pushed, &request).await
1964 },
1965 )
1966 .await
1967}
1968
1969async fn fetch_projects(
1971 source: &ResolvedSource,
1972 shape: &ProjectShape,
1973 start: &Resume,
1974 budget: u32,
1975 calls: &AtomicU32,
1976) -> Result<Fetched<Project>, SourceError> {
1977 let compensating = shape.local != LocalProjects::default();
1978 walk(
1979 start,
1980 budget,
1981 page_size(compensating, budget, ceiling(source)),
1982 |project| shape.local.keeps(project),
1983 |cursor, limit| async move {
1984 calls.fetch_add(1, Ordering::Relaxed);
1985 let request = PageRequest { cursor, limit };
1986 source
1987 .source()
1988 .query_projects(&shape.pushed, &request)
1989 .await
1990 },
1991 )
1992 .await
1993}
1994
1995async fn fetch_documents(
1997 source: &ResolvedSource,
1998 shape: &DocumentShape,
1999 start: &Resume,
2000 budget: u32,
2001 calls: &AtomicU32,
2002) -> Result<Fetched<Document>, SourceError> {
2003 let compensating = shape.local != LocalDocuments::default();
2004 walk(
2005 start,
2006 budget,
2007 page_size(compensating, budget, ceiling(source)),
2008 |document| shape.local.keeps(document),
2009 |cursor, limit| async move {
2010 calls.fetch_add(1, Ordering::Relaxed);
2011 let request = PageRequest { cursor, limit };
2012 source
2013 .source()
2014 .query_documents(&shape.pushed, &request)
2015 .await
2016 },
2017 )
2018 .await
2019}
2020
2021async fn fetch_labels(
2023 source: &ResolvedSource,
2024 start: &Resume,
2025 budget: u32,
2026 calls: &AtomicU32,
2027) -> Result<Fetched<Label>, SourceError> {
2028 walk(
2029 start,
2030 budget,
2031 page_size(false, budget, ceiling(source)),
2032 |_| true,
2033 |cursor, limit| async move {
2034 calls.fetch_add(1, Ordering::Relaxed);
2035 let request = PageRequest { cursor, limit };
2036 source.source().labels(&request).await
2037 },
2038 )
2039 .await
2040}
2041
2042async fn fetch_hits(
2044 source: &ResolvedSource,
2045 shape: &HitShape,
2046 start: &Resume,
2047 budget: u32,
2048 calls: &AtomicU32,
2049) -> Result<Fetched<Found>, SourceError> {
2050 let ceiling = ceiling(source);
2051 match shape.stream {
2052 StreamKind::Projects => {
2053 let compensating = shape.local_projects != LocalProjects::default();
2054 walk(
2055 start,
2056 budget,
2057 page_size(compensating, budget, ceiling),
2058 |found| match found {
2059 Found::Project(project) => shape.local_projects.keeps(project),
2060 Found::Task(_) => true,
2061 },
2062 |cursor, limit| async move {
2063 calls.fetch_add(1, Ordering::Relaxed);
2064 let request = PageRequest { cursor, limit };
2065 let page = source
2066 .source()
2067 .query_projects(&shape.projects, &request)
2068 .await?;
2069 Ok(Page {
2070 items: page.items.into_iter().map(Found::Project).collect(),
2071 next: page.next,
2072 })
2073 },
2074 )
2075 .await
2076 }
2077 StreamKind::Items | StreamKind::Tasks => {
2078 let compensating = shape.local_tasks != LocalTasks::default();
2079 walk(
2080 start,
2081 budget,
2082 page_size(compensating, budget, ceiling),
2083 |found| match found {
2084 Found::Task(task) => shape.local_tasks.keeps(task),
2085 Found::Project(_) => true,
2086 },
2087 |cursor, limit| async move {
2088 calls.fetch_add(1, Ordering::Relaxed);
2089 let request = PageRequest { cursor, limit };
2090 let page = source.source().query_tasks(&shape.tasks, &request).await?;
2091 Ok(Page {
2092 items: page.items.into_iter().map(Found::Task).collect(),
2093 next: page.next,
2094 })
2095 },
2096 )
2097 .await
2098 }
2099 }
2100}
2101
2102async fn forward_edges(
2104 source: &ResolvedSource,
2105 entity: Entity,
2106 id: &NativeId,
2107 request: &PageRequest,
2108) -> Result<Page<DependencyEdge>, SourceError> {
2109 match entity {
2110 Entity::Task => {
2111 source
2112 .source()
2113 .task_dependencies(id, Direction::DependsOn, request)
2114 .await
2115 }
2116 Entity::Project => {
2117 source
2118 .source()
2119 .project_dependencies(id, Direction::DependsOn, request)
2120 .await
2121 }
2122 }
2123}
2124
2125#[expect(
2134 clippy::too_many_arguments,
2135 reason = "every argument is one axis of one walk — the source, the item, the \
2136 direction, which of its two graphs, whether the reverse is emulated, where \
2137 to resume, how many rows to return and where to count calls. Grouping them \
2138 into a struct would name the same eight values one indirection further from \
2139 the loop that reads them."
2140)]
2141async fn fetch_edges(
2142 source: &ResolvedSource,
2143 native: &NativeId,
2144 direction: Direction,
2145 entity: Entity,
2146 emulating: bool,
2147 start: &Resume,
2148 budget: u32,
2149 calls: &AtomicU32,
2150) -> Result<Fetched<DependencyEdge>, SourceError> {
2151 let ceiling = ceiling(source);
2152 if !emulating {
2153 return walk(
2154 start,
2155 budget,
2156 page_size(false, budget, ceiling),
2157 |_| true,
2158 |cursor, limit| async move {
2159 calls.fetch_add(1, Ordering::Relaxed);
2160 let request = PageRequest { cursor, limit };
2161 match entity {
2162 Entity::Task => {
2163 source
2164 .source()
2165 .task_dependencies(native, direction, &request)
2166 .await
2167 }
2168 Entity::Project => {
2169 source
2170 .source()
2171 .project_dependencies(native, direction, &request)
2172 .await
2173 }
2174 }
2175 },
2176 )
2177 .await;
2178 }
2179
2180 walk(
2181 start,
2182 budget,
2183 ceiling,
2184 |_| true,
2185 |cursor, limit| async move {
2186 calls.fetch_add(1, Ordering::Relaxed);
2187 let request = PageRequest { cursor, limit };
2188 let (ids, next) = match entity {
2189 Entity::Task => {
2190 let page = source
2191 .source()
2192 .query_tasks(&TaskQuery::default(), &request)
2193 .await?;
2194 let ids: Vec<NativeId> = page.items.into_iter().map(|task| task.id).collect();
2195 (ids, page.next)
2196 }
2197 Entity::Project => {
2198 let page = source
2199 .source()
2200 .query_projects(&ProjectQuery::default(), &request)
2201 .await?;
2202 let ids: Vec<NativeId> =
2203 page.items.into_iter().map(|project| project.id).collect();
2204 (ids, page.next)
2205 }
2206 };
2207
2208 let mut edges = Vec::new();
2209 for id in ids {
2210 let mut inner: Option<Cursor> = None;
2211 loop {
2212 calls.fetch_add(1, Ordering::Relaxed);
2213 let request = PageRequest {
2214 cursor: inner.clone(),
2215 limit,
2216 };
2217 let page = forward_edges(source, entity, &id, &request).await?;
2218 fits(page.items.len(), limit)?;
2222 edges.extend(page.items.into_iter().filter(|edge| &edge.to == native));
2223 unrepeated(
2224 page.next.as_ref(),
2225 inner.as_ref(),
2226 "its forward edges were being scanned",
2227 )?;
2228 match page.next {
2229 Some(cursor) => inner = Some(cursor),
2230 None => break,
2231 }
2232 }
2233 }
2234
2235 Ok(Page { items: edges, next })
2236 },
2237 )
2238 .await
2239}