1mod fetch;
19mod join;
20mod local;
21mod resume;
22
23use std::collections::BTreeMap;
24use std::num::NonZeroU32;
25use std::sync::atomic::{AtomicU32, Ordering};
26
27use onetaskgraph_plugin_api::{
28 Capabilities, Cursor, DependencyEdge, Direction, Label, LabelFilter, NativeId, Page,
29 PageRequest, Project, ProjectFilter, ProjectQuery, SecretResolver, SourceError, SourceName,
30 StatusCategory, Task, TaskQuery, TextFields, TextQuery,
31};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::GlobalId;
36use crate::config::Config;
37use crate::plan::{PageToken, Predicate, QueryPlan, QueryResponse, SourceFailure, SourcePlan};
38use crate::resolve::{ResolvedSource, UnavailableSource, resolve_available};
39
40use fetch::{Fetched, Stream, fits, merge, walk};
41use join::join_all;
42use local::{LocalProjects, LocalTasks};
43pub(crate) use resume::{Owed, Resumption, StreamState};
44use resume::{Resume, StreamKind};
45
46pub use local::ProjectSelector;
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53pub struct Qualified<T> {
54 pub id: GlobalId,
56 pub item: T,
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
65pub struct QualifiedEdge {
66 pub from: GlobalId,
68 pub to: GlobalId,
70 pub kind: onetaskgraph_plugin_api::DependencyKind,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
76#[serde(tag = "kind", rename_all = "kebab-case")]
77pub enum SearchHit {
78 Task(Qualified<Task>),
80 Project(Qualified<Project>),
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "kebab-case")]
87pub enum SearchKind {
88 Tasks,
90 Projects,
92 #[default]
94 Both,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
99pub struct SourceListing {
100 pub source: SourceName,
102 pub kind: String,
114 #[serde(flatten)]
116 pub state: SourceState,
117}
118
119#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
121#[serde(tag = "state", rename_all = "kebab-case")]
122pub enum SourceState {
123 Available {
125 capabilities: Capabilities,
127 },
128 Unavailable {
130 error: SourceError,
132 },
133}
134
135#[derive(Debug, Clone, PartialEq)]
137pub struct Paging {
138 pub limit: NonZeroU32,
140 pub token: Option<PageToken>,
142}
143
144#[derive(Debug, Clone, Default, PartialEq)]
146pub struct Filters {
147 pub text: Option<TextQuery>,
149 pub labels: LabelFilter,
151 pub statuses: Vec<StatusCategory>,
153}
154
155#[derive(Debug, Clone)]
157pub struct TaskRequest {
158 pub sources: Vec<SourceName>,
160 pub filters: Filters,
162 pub project: ProjectSelector,
164 pub paging: Paging,
166}
167
168#[derive(Debug, Clone)]
170pub struct ProjectRequest {
171 pub sources: Vec<SourceName>,
173 pub filters: Filters,
175 pub paging: Paging,
177}
178
179#[derive(Debug, Clone)]
181pub struct LabelRequest {
182 pub sources: Vec<SourceName>,
184 pub paging: Paging,
186}
187
188#[derive(Debug, Clone)]
190pub struct SearchRequest {
191 pub sources: Vec<SourceName>,
193 pub text: TextQuery,
195 pub kind: SearchKind,
197 pub paging: Paging,
199}
200
201#[derive(Debug, Clone)]
203pub struct DependencyRequest {
204 pub id: GlobalId,
206 pub direction: Direction,
208 pub paging: Paging,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
217pub enum EngineError {
218 #[error(
220 "no source named {name:?} is configured\n\
221 next: name one of the configured sources ({configured}), or add {name:?} under \
222 `sources` — `onetaskgraph sources list` shows what this configuration has."
223 )]
224 UnknownSource {
225 name: String,
227 configured: String,
229 },
230
231 #[error(
233 "{message}\n\
234 next: page with a token exactly as the previous page reported it, and against \
235 the same configuration — or drop `--page` to start the walk again."
236 )]
237 Token {
238 message: String,
240 },
241
242 #[error(
244 "no sources are configured\n\
245 next: add one under `sources` in onetaskgraph.yaml — `onetaskgraph schema` \
246 prints what each plugin accepts."
247 )]
248 NoSources,
249}
250
251pub enum ConfiguredSource {
258 Ready(ResolvedSource),
260 Unavailable(UnavailableSource),
262}
263
264impl ConfiguredSource {
265 #[must_use]
267 pub fn name(&self) -> &SourceName {
268 match self {
269 Self::Ready(source) => source.name(),
270 Self::Unavailable(source) => source.name(),
271 }
272 }
273}
274
275pub struct Engine {
277 sources: Vec<ConfiguredSource>,
279 selection: Vec<SourceName>,
281}
282
283impl Engine {
284 #[must_use]
292 pub fn build(config: &Config, secrets: &dyn SecretResolver) -> Self {
293 let (ready, unavailable) = resolve_available(config, secrets);
294 Self::new(
295 ready
296 .into_iter()
297 .map(ConfiguredSource::Ready)
298 .chain(unavailable.into_iter().map(ConfiguredSource::Unavailable))
299 .collect(),
300 config.selected_sources(),
301 )
302 }
303
304 #[must_use]
307 pub fn new(sources: Vec<ConfiguredSource>, selection: Vec<SourceName>) -> Self {
308 Self { sources, selection }
309 }
310
311 fn ready(&self) -> impl Iterator<Item = &ResolvedSource> {
313 self.sources.iter().filter_map(|source| match source {
314 ConfiguredSource::Ready(ready) => Some(ready),
315 ConfiguredSource::Unavailable(_) => None,
316 })
317 }
318
319 fn unavailable(&self) -> impl Iterator<Item = &UnavailableSource> {
321 self.sources.iter().filter_map(|source| match source {
322 ConfiguredSource::Unavailable(unavailable) => Some(unavailable),
323 ConfiguredSource::Ready(_) => None,
324 })
325 }
326
327 #[must_use]
329 pub fn listing(&self) -> Vec<SourceListing> {
330 let mut listings: Vec<SourceListing> = self
331 .ready()
332 .map(|source| SourceListing {
333 source: source.name().clone(),
334 kind: source.kind().to_owned(),
335 state: SourceState::Available {
336 capabilities: source.source().capabilities(),
337 },
338 })
339 .chain(self.unavailable().map(|source| SourceListing {
340 source: source.name().clone(),
341 kind: source.kind().to_owned(),
342 state: SourceState::Unavailable {
343 error: source.error().clone(),
344 },
345 }))
346 .collect();
347 listings.sort_by(|left, right| left.source.cmp(&right.source));
348 listings
349 }
350
351 #[must_use]
357 pub fn has(&self, name: &SourceName) -> bool {
358 self.sources.iter().any(|source| source.name() == name)
359 }
360
361 pub async fn tasks(
369 &self,
370 request: &TaskRequest,
371 ) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
372 let mut names = self.resolve_selection(&request.sources)?;
373 if let ProjectSelector::Qualified(id) = &request.project {
378 self.known(&id.source)?;
379 names.retain(|name| name == &id.source);
380 }
381 let query = shape("task-list", &names, &(&request.filters, &request.project));
382 let states = resumption(
383 self,
384 request.paging.token.as_ref(),
385 &[StreamKind::Items],
386 &query,
387 )?;
388 let budget = request.paging.limit.get();
389
390 let mut answer = Answer::new();
391 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
392
393 let shapes: Vec<TaskShape> = ready
394 .iter()
395 .map(|source| {
396 shape_tasks(
397 &source.source().capabilities(),
398 &request.filters,
399 &project_filter(&request.project),
400 )
401 })
402 .collect();
403 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
404 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
405
406 let walks = ready
407 .iter()
408 .enumerate()
409 .map(|(index, source)| {
410 fetch_tasks(
411 source,
412 &shapes[index],
413 &starts[index],
414 budget,
415 &counters[index],
416 )
417 })
418 .collect();
419
420 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
421 answer.finish(
422 streams,
423 budget,
424 owed(&states),
425 &query,
426 |name, task: Task| Qualified {
427 id: GlobalId::new(name.clone(), task.id.clone()),
428 item: task,
429 },
430 )
431 }
432
433 pub async fn projects(
439 &self,
440 request: &ProjectRequest,
441 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
442 let names = self.resolve_selection(&request.sources)?;
443 let query = shape("project-list", &names, &request.filters);
444 let states = resumption(
445 self,
446 request.paging.token.as_ref(),
447 &[StreamKind::Items],
448 &query,
449 )?;
450 let budget = request.paging.limit.get();
451
452 let mut answer = Answer::new();
453 let mut with_projects = Vec::new();
459 for source in answer.split(self, &names) {
460 if source.source().capabilities().projects.is_native() {
461 with_projects.push(source);
462 } else {
463 answer.unreachable_predicate(source, Predicate::Project);
464 }
465 }
466 let (ready, starts) = walking(with_projects, &states, StreamKind::Items);
467
468 let shapes: Vec<ProjectShape> = ready
469 .iter()
470 .map(|source| shape_projects(&source.source().capabilities(), &request.filters))
471 .collect();
472 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
473 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
474
475 let walks = ready
476 .iter()
477 .enumerate()
478 .map(|(index, source)| {
479 fetch_projects(
480 source,
481 &shapes[index],
482 &starts[index],
483 budget,
484 &counters[index],
485 )
486 })
487 .collect();
488
489 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
490 answer.finish(
491 streams,
492 budget,
493 owed(&states),
494 &query,
495 |name, project: Project| Qualified {
496 id: GlobalId::new(name.clone(), project.id.clone()),
497 item: project,
498 },
499 )
500 }
501
502 pub async fn labels(
508 &self,
509 request: &LabelRequest,
510 ) -> Result<QueryResponse<Qualified<Label>>, EngineError> {
511 let names = self.resolve_selection(&request.sources)?;
512 let query = shape("label-list", &names, &());
513 let states = resumption(
514 self,
515 request.paging.token.as_ref(),
516 &[StreamKind::Items],
517 &query,
518 )?;
519 let budget = request.paging.limit.get();
520
521 let mut answer = Answer::new();
522 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
523 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
524 let outcomes: Vec<Outcomes> = ready.iter().map(|_| Outcomes::default()).collect();
525
526 let walks = ready
527 .iter()
528 .enumerate()
529 .map(|(index, source)| fetch_labels(source, &starts[index], budget, &counters[index]))
530 .collect();
531
532 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
533 answer.finish(
534 streams,
535 budget,
536 owed(&states),
537 &query,
538 |name, label: Label| Qualified {
539 id: GlobalId::new(name.clone(), label.id.clone()),
540 item: label,
541 },
542 )
543 }
544
545 pub async fn search(
551 &self,
552 request: &SearchRequest,
553 ) -> Result<QueryResponse<SearchHit>, EngineError> {
554 let names = self.resolve_selection(&request.sources)?;
555 let reads: &[StreamKind] = match request.kind {
559 SearchKind::Tasks => &[StreamKind::Tasks],
560 SearchKind::Projects => &[StreamKind::Projects],
561 SearchKind::Both => &[StreamKind::Tasks, StreamKind::Projects],
562 };
563 let query = shape("search", &names, &request.text);
568 let states = resumption(self, request.paging.token.as_ref(), reads, &query)?;
569 let budget = request.paging.limit.get();
570 let filters = Filters {
571 text: Some(request.text.clone()),
572 ..Filters::default()
573 };
574
575 let mut answer = Answer::new();
576
577 let mut ready = Vec::new();
580 let mut kinds = Vec::new();
581 let mut starts = Vec::new();
582 for source in answer.split(self, &names) {
583 let mut streams = Vec::new();
584 if matches!(request.kind, SearchKind::Tasks | SearchKind::Both) {
585 streams.push(StreamKind::Tasks);
586 }
587 if matches!(request.kind, SearchKind::Projects | SearchKind::Both) {
588 if source.source().capabilities().projects.is_native() {
589 streams.push(StreamKind::Projects);
590 } else {
591 answer.unreachable_predicate(source, Predicate::Project);
592 }
593 }
594 for stream in streams {
595 if let Some(resume) = resume_at(&states, source.name(), stream) {
596 ready.push(source);
597 kinds.push(stream);
598 starts.push(resume);
599 }
600 }
601 }
602
603 let shapes: Vec<HitShape> = ready
604 .iter()
605 .zip(kinds.iter())
606 .map(|(source, kind)| shape_hits(&source.source().capabilities(), &filters, *kind))
607 .collect();
608 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
609 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
610
611 let walks = ready
612 .iter()
613 .enumerate()
614 .map(|(index, source)| {
615 fetch_hits(
616 source,
617 &shapes[index],
618 &starts[index],
619 budget,
620 &counters[index],
621 )
622 })
623 .collect();
624
625 let streams =
626 answer.collect_streams(&ready, &kinds, join_all(walks).await, &counters, outcomes);
627 answer.finish(
628 streams,
629 budget,
630 owed(&states),
631 &query,
632 |name, found: Found| match found {
633 Found::Task(task) => SearchHit::Task(Qualified {
634 id: GlobalId::new(name.clone(), task.id.clone()),
635 item: task,
636 }),
637 Found::Project(project) => SearchHit::Project(Qualified {
638 id: GlobalId::new(name.clone(), project.id.clone()),
639 item: project,
640 }),
641 },
642 )
643 }
644
645 pub async fn task(&self, id: &GlobalId) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
652 let name = self.known(&id.source)?;
653 let mut answer = Answer::new();
654 let selected = answer.split(self, std::slice::from_ref(&name));
655 let Some(source) = selected.first() else {
656 return answer.nothing();
657 };
658 let found = source.source().get_task(&id.native).await;
659 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
660 answer.one(source, found, |task| Qualified {
661 id: qualified,
662 item: task,
663 })
664 }
665
666 pub async fn project(
672 &self,
673 id: &GlobalId,
674 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
675 let name = self.known(&id.source)?;
676 let mut answer = Answer::new();
677 let selected = answer.split(self, std::slice::from_ref(&name));
678 let Some(source) = selected.first() else {
679 return answer.nothing();
680 };
681 let found = source.source().get_project(&id.native).await;
682 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
683 answer.one(source, found, |project| Qualified {
684 id: qualified,
685 item: project,
686 })
687 }
688
689 pub async fn task_dependencies(
696 &self,
697 request: &DependencyRequest,
698 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
699 self.dependencies(request, Entity::Task).await
700 }
701
702 pub async fn project_dependencies(
708 &self,
709 request: &DependencyRequest,
710 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
711 self.dependencies(request, Entity::Project).await
712 }
713
714 async fn dependencies(
717 &self,
718 request: &DependencyRequest,
719 entity: Entity,
720 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
721 let name = self.known(&request.id.source)?;
722 let query = shape(
723 "dependencies",
724 std::slice::from_ref(&name),
725 &(entity, &request.id.native, request.direction),
726 );
727 let states = resumption(
728 self,
729 request.paging.token.as_ref(),
730 &[StreamKind::Items],
731 &query,
732 )?;
733 let budget = request.paging.limit.get();
734
735 let mut answer = Answer::new();
736 let (ready, starts) = walking(
737 answer.split(self, std::slice::from_ref(&name)),
738 &states,
739 StreamKind::Items,
740 );
741 let Some(source) = ready.first() else {
742 return answer.nothing();
743 };
744
745 let capabilities = source.source().capabilities();
746 let support = match entity {
747 Entity::Task => capabilities.task_dependencies,
748 Entity::Project => capabilities.project_dependencies,
749 };
750 let emulating = request.direction == Direction::DependedOnBy && !support.answers_reverse();
754 let mut outcomes = Outcomes::default();
755 if request.direction == Direction::DependedOnBy {
756 if emulating {
757 outcomes.record(Predicate::ReverseDependencies, Outcome::Emulated);
758 } else {
759 outcomes.record(Predicate::ReverseDependencies, Outcome::PushedDown);
760 }
761 }
762
763 let counters = vec![AtomicU32::new(0)];
764 let walked = fetch_edges(
765 source,
766 &request.id.native,
767 request.direction,
768 entity,
769 emulating,
770 &starts[0],
771 budget,
772 &counters[0],
773 )
774 .await;
775
776 let streams = answer.collect(&ready, vec![walked], &counters, vec![outcomes]);
777 answer.finish(
778 streams,
779 budget,
780 owed(&states),
781 &query,
782 |name, edge: DependencyEdge| QualifiedEdge {
783 from: GlobalId::new(name.clone(), edge.from),
784 to: GlobalId::new(name.clone(), edge.to),
785 kind: edge.kind,
786 },
787 )
788 }
789
790 fn resolve_selection(&self, asked: &[SourceName]) -> Result<Vec<SourceName>, EngineError> {
792 if asked.is_empty() {
793 if self.selection.is_empty() {
794 return Err(EngineError::NoSources);
795 }
796 return Ok(self.selection.clone());
797 }
798 asked.iter().map(|name| self.known(name)).collect()
799 }
800
801 fn known(&self, name: &SourceName) -> Result<SourceName, EngineError> {
803 if self.has(name) {
804 return Ok(name.clone());
805 }
806 if self.sources.is_empty() {
807 return Err(EngineError::NoSources);
808 }
809 Err(EngineError::UnknownSource {
810 name: name.to_string(),
811 configured: self
812 .listing()
813 .iter()
814 .map(|listing| listing.source.to_string())
815 .collect::<Vec<_>>()
816 .join(", "),
817 })
818 }
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823enum Entity {
824 Task,
826 Project,
828}
829
830enum Found {
832 Task(Task),
834 Project(Project),
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
840enum Outcome {
841 PushedDown,
843 AppliedLocally,
845 Emulated,
847 Unavailable,
849}
850
851#[derive(Debug, Clone, Default, PartialEq)]
863struct Outcomes(BTreeMap<Predicate, Outcome>);
864
865impl Outcomes {
866 fn record(&mut self, predicate: Predicate, outcome: Outcome) {
872 self.0.insert(predicate, outcome);
873 }
874
875 fn record_all(&mut self, predicates: impl IntoIterator<Item = Predicate>, outcome: Outcome) {
878 for predicate in predicates {
879 self.record(predicate, outcome);
880 }
881 }
882
883 fn with(&self, outcome: Outcome) -> Vec<Predicate> {
885 self.0
886 .iter()
887 .filter(|(_, recorded)| **recorded == outcome)
888 .map(|(predicate, _)| *predicate)
889 .collect()
890 }
891}
892
893struct TaskShape {
895 pushed: TaskQuery,
897 local: LocalTasks,
899 outcomes: Outcomes,
901}
902
903struct ProjectShape {
905 pushed: ProjectQuery,
907 local: LocalProjects,
909 outcomes: Outcomes,
911}
912
913struct HitShape {
915 stream: StreamKind,
917 tasks: TaskQuery,
919 projects: ProjectQuery,
921 local_tasks: LocalTasks,
923 local_projects: LocalProjects,
925 outcomes: Outcomes,
927}
928
929struct Answer {
935 plans: Vec<SourcePlan>,
937 errors: Vec<SourceFailure>,
939}
940
941impl Answer {
942 fn new() -> Self {
943 Self {
944 plans: Vec::new(),
945 errors: Vec::new(),
946 }
947 }
948
949 fn split<'a>(&mut self, engine: &'a Engine, names: &[SourceName]) -> Vec<&'a ResolvedSource> {
954 let mut selected = Vec::new();
955 for name in names {
956 match engine.sources.iter().find(|source| source.name() == name) {
957 Some(ConfiguredSource::Ready(source)) => selected.push(source),
958 Some(ConfiguredSource::Unavailable(source)) => {
959 self.errors.push(source.failure());
960 }
961 None => {}
962 }
963 }
964 selected
965 }
966
967 fn unreachable_predicate(&mut self, source: &ResolvedSource, predicate: Predicate) {
969 let mut outcomes = Outcomes::default();
970 outcomes.record(predicate, Outcome::Unavailable);
971 self.plans.push(plan_for(source, outcomes, 0));
972 }
973
974 fn collect<T>(
976 &mut self,
977 ready: &[&ResolvedSource],
978 walked: Vec<Result<Fetched<T>, SourceError>>,
979 counters: &[AtomicU32],
980 outcomes: Vec<Outcomes>,
981 ) -> Vec<Stream<T>> {
982 let kinds = vec![StreamKind::Items; ready.len()];
983 self.collect_streams(ready, &kinds, walked, counters, outcomes)
984 }
985
986 fn collect_streams<T>(
988 &mut self,
989 ready: &[&ResolvedSource],
990 kinds: &[StreamKind],
991 walked: Vec<Result<Fetched<T>, SourceError>>,
992 counters: &[AtomicU32],
993 outcomes: Vec<Outcomes>,
994 ) -> Vec<Stream<T>> {
995 let mut streams = Vec::new();
996 for (index, result) in walked.into_iter().enumerate() {
997 let source = ready[index];
998 let pages = counters[index].load(Ordering::Relaxed);
999 self.plans
1000 .push(plan_for(source, outcomes[index].clone(), pages));
1001 match result {
1002 Ok(fetched) => streams.push(Stream {
1003 source: source.name().clone(),
1004 kind: kinds[index],
1005 fetched,
1006 }),
1007 Err(error) => self.errors.push(SourceFailure {
1010 source: source.name().clone(),
1011 error,
1012 }),
1013 }
1014 }
1015 streams
1016 }
1017
1018 fn one<T, U>(
1020 mut self,
1021 source: &ResolvedSource,
1022 found: Result<Option<T>, SourceError>,
1023 qualify: impl FnOnce(T) -> U,
1024 ) -> Result<QueryResponse<U>, EngineError> {
1025 self.plans.push(plan_for(source, Outcomes::default(), 1));
1026 let items = match found {
1027 Ok(Some(item)) => vec![qualify(item)],
1028 Ok(None) => Vec::new(),
1029 Err(error) => {
1030 self.errors.push(SourceFailure {
1031 source: source.name().clone(),
1032 error,
1033 });
1034 Vec::new()
1035 }
1036 };
1037 Ok(QueryResponse {
1038 items,
1039 next: None,
1040 plan: QueryPlan {
1041 per_source: merge_plans(self.plans),
1042 },
1043 errors: self.errors,
1044 })
1045 }
1046
1047 fn nothing<U>(self) -> Result<QueryResponse<U>, EngineError> {
1049 Ok(QueryResponse {
1050 items: Vec::new(),
1051 next: None,
1052 plan: QueryPlan {
1053 per_source: merge_plans(self.plans),
1054 },
1055 errors: self.errors,
1056 })
1057 }
1058
1059 fn finish<T, U>(
1064 self,
1065 streams: Vec<Stream<T>>,
1066 budget: u32,
1067 first: Option<&Owed>,
1068 query: &str,
1069 qualify: impl Fn(&SourceName, T) -> U,
1070 ) -> Result<QueryResponse<U>, EngineError> {
1071 let (rows, states, owed) = merge(streams, budget, first);
1072 let next = (!states.is_empty()).then(|| PageToken::encode(query, owed, &states));
1073 Ok(QueryResponse {
1074 items: rows
1075 .into_iter()
1076 .map(|(name, item)| qualify(&name, item))
1077 .collect(),
1078 next,
1079 plan: QueryPlan {
1080 per_source: merge_plans(self.plans),
1081 },
1082 errors: self.errors,
1083 })
1084 }
1085}
1086
1087fn plan_for(source: &ResolvedSource, outcomes: Outcomes, pages: u32) -> SourcePlan {
1090 SourcePlan {
1091 source: source.name().clone(),
1092 kind: source.kind().to_owned(),
1093 pushed_down: outcomes.with(Outcome::PushedDown),
1094 applied_locally: outcomes.with(Outcome::AppliedLocally),
1095 emulated: outcomes.with(Outcome::Emulated),
1096 unavailable: outcomes.with(Outcome::Unavailable),
1097 pages_fetched: pages,
1098 }
1099}
1100
1101fn merge_plans(plans: Vec<SourcePlan>) -> Vec<SourcePlan> {
1106 let mut merged: Vec<SourcePlan> = Vec::new();
1107 for plan in plans {
1108 if let Some(existing) = merged
1109 .iter_mut()
1110 .find(|existing| existing.source == plan.source)
1111 {
1112 existing.pushed_down.extend(plan.pushed_down);
1113 existing.applied_locally.extend(plan.applied_locally);
1114 existing.emulated.extend(plan.emulated);
1115 existing.unavailable.extend(plan.unavailable);
1116 existing.pages_fetched = existing.pages_fetched.saturating_add(plan.pages_fetched);
1117 for list in [
1118 &mut existing.pushed_down,
1119 &mut existing.applied_locally,
1120 &mut existing.emulated,
1121 &mut existing.unavailable,
1122 ] {
1123 list.sort_unstable();
1124 list.dedup();
1125 }
1126 } else {
1127 merged.push(plan);
1128 }
1129 }
1130 merged
1131}
1132
1133fn walking<'a>(
1139 selected: Vec<&'a ResolvedSource>,
1140 states: &Option<Resumption>,
1141 kind: StreamKind,
1142) -> (Vec<&'a ResolvedSource>, Vec<Resume>) {
1143 let mut ready = Vec::new();
1144 let mut starts = Vec::new();
1145 for source in selected {
1146 if let Some(resume) = resume_at(states, source.name(), kind) {
1147 ready.push(source);
1148 starts.push(resume);
1149 }
1150 }
1151 (ready, starts)
1152}
1153
1154fn shape(verb: &str, sources: &[SourceName], filters: &impl std::fmt::Debug) -> String {
1172 let names: Vec<&str> = sources.iter().map(SourceName::as_str).collect();
1173 fingerprint(&format!("{verb}|{names:?}|{filters:?}"))
1174}
1175
1176fn fingerprint(text: &str) -> String {
1178 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1179 for byte in text.as_bytes() {
1180 hash ^= u64::from(*byte);
1181 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1182 }
1183 format!("{hash:016x}")
1184}
1185
1186fn owed(document: &Option<Resumption>) -> Option<&Owed> {
1191 document.as_ref()?.owed.as_ref()
1192}
1193
1194fn resume_at(states: &Option<Resumption>, source: &SourceName, kind: StreamKind) -> Option<Resume> {
1196 match states {
1197 None => Some(Resume::default()),
1198 Some(document) => document
1199 .streams
1200 .iter()
1201 .find(|state| &state.source == source && state.stream == kind)
1202 .map(|state| state.resume.clone()),
1203 }
1204}
1205
1206fn resumption(
1237 engine: &Engine,
1238 token: Option<&PageToken>,
1239 reads: &[StreamKind],
1240 query: &str,
1241) -> Result<Option<Resumption>, EngineError> {
1242 let Some(document) = token.map(PageToken::decode) else {
1243 return Ok(None);
1244 };
1245
1246 if document.query != query {
1253 return Err(EngineError::Token {
1254 message: "this page token was written by a different query — resume the walk it \
1255 came from, or drop --page to start this one from the beginning"
1256 .to_owned(),
1257 });
1258 }
1259 let states = &document.streams;
1260
1261 let mut seen: Vec<(&SourceName, StreamKind)> = Vec::new();
1262 for state in states {
1263 if !reads.contains(&state.stream) {
1264 return Err(EngineError::Token {
1265 message: format!(
1266 "this page token resumes {}, which this command does not read — it \
1267 was written by a different query",
1268 state.stream.describe()
1269 ),
1270 });
1271 }
1272 let ceiling = engine
1273 .ready()
1274 .find(|source| source.name() == &state.source)
1275 .map(ceiling);
1276 if ceiling.is_none() && !engine.has(&state.source) {
1277 return Err(EngineError::Token {
1278 message: format!(
1279 "this page token resumes a source called {:?}, which this \
1280 configuration does not have",
1281 state.source.as_str()
1282 ),
1283 });
1284 }
1285 if let Some(ceiling) = ceiling
1286 && state.resume.skip >= ceiling
1287 {
1288 return Err(EngineError::Token {
1289 message: format!(
1290 "this page token resumes {} rows into a page of source {:?}, which \
1291 serves at most {ceiling}",
1292 state.resume.skip,
1293 state.source.as_str()
1294 ),
1295 });
1296 }
1297 if seen.contains(&(&state.source, state.stream)) {
1298 return Err(EngineError::Token {
1299 message: format!(
1300 "this page token gives source {:?} two places to resume from",
1301 state.source.as_str()
1302 ),
1303 });
1304 }
1305 seen.push((&state.source, state.stream));
1306 }
1307
1308 if let Some(owed) = &document.owed
1313 && !document
1314 .streams
1315 .iter()
1316 .any(|state| state.source == owed.source && state.stream == owed.stream)
1317 {
1318 return Err(EngineError::Token {
1319 message: format!(
1320 "this page token owes the next row to a stream it does not resume, \
1321 {:?}'s {}",
1322 owed.source.as_str(),
1323 owed.stream.describe()
1324 ),
1325 });
1326 }
1327
1328 Ok(Some(document))
1329}
1330
1331fn project_filter(selector: &ProjectSelector) -> ProjectFilter {
1337 match selector {
1338 ProjectSelector::Any => ProjectFilter::Any,
1339 ProjectSelector::Orphans => ProjectFilter::Orphans,
1340 ProjectSelector::Native(id) => ProjectFilter::Is(id.clone()),
1341 ProjectSelector::Qualified(id) => ProjectFilter::Is(id.native.clone()),
1342 }
1343}
1344
1345fn text_predicates(fields: TextFields) -> Vec<Predicate> {
1347 match fields {
1348 TextFields::Title => vec![Predicate::SearchTitle],
1349 TextFields::Content => vec![Predicate::SearchContent],
1350 TextFields::TitleOrContent => vec![Predicate::SearchTitle, Predicate::SearchContent],
1351 }
1352}
1353
1354fn searches_natively(capabilities: &Capabilities, fields: TextFields) -> bool {
1361 match fields {
1362 TextFields::Title => capabilities.search_title.is_native(),
1363 TextFields::Content => capabilities.search_content.is_native(),
1364 TextFields::TitleOrContent => {
1365 capabilities.search_title.is_native() && capabilities.search_content.is_native()
1366 }
1367 }
1368}
1369
1370fn shape_tasks(
1372 capabilities: &Capabilities,
1373 filters: &Filters,
1374 project: &ProjectFilter,
1375) -> TaskShape {
1376 let mut pushed = TaskQuery::default();
1377 let mut local = LocalTasks::default();
1378 let mut outcomes = Outcomes::default();
1379
1380 if !filters.labels.is_empty() {
1381 if capabilities.filter_by_label.is_native() {
1382 pushed.labels = filters.labels.clone();
1383 outcomes.record(Predicate::Label, Outcome::PushedDown);
1384 } else {
1385 local.labels = Some(filters.labels.clone());
1386 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1387 }
1388 }
1389 if !filters.statuses.is_empty() {
1390 if capabilities.filter_by_status.is_native() {
1391 pushed.statuses.clone_from(&filters.statuses);
1392 outcomes.record(Predicate::Status, Outcome::PushedDown);
1393 } else {
1394 local.statuses.clone_from(&filters.statuses);
1395 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1396 }
1397 }
1398 if let Some(text) = &filters.text {
1399 let predicates = text_predicates(text.fields);
1400 if searches_natively(capabilities, text.fields) {
1401 pushed.text = Some(text.clone());
1402 outcomes.record_all(predicates, Outcome::PushedDown);
1403 } else {
1404 local.text = Some(text.clone());
1405 outcomes.record_all(predicates, Outcome::AppliedLocally);
1406 }
1407 }
1408 match project {
1409 ProjectFilter::Any => {}
1410 ProjectFilter::Orphans => {
1411 if capabilities.orphan_tasks.is_native() {
1412 pushed.project = ProjectFilter::Orphans;
1413 outcomes.record(Predicate::Project, Outcome::PushedDown);
1414 } else {
1415 local.project = Some(ProjectFilter::Orphans);
1416 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1417 }
1418 }
1419 ProjectFilter::Is(id) => {
1420 if capabilities.projects.is_native() {
1421 pushed.project = ProjectFilter::Is(id.clone());
1422 outcomes.record(Predicate::Project, Outcome::PushedDown);
1423 } else {
1424 local.project = Some(ProjectFilter::Is(id.clone()));
1425 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
1426 }
1427 }
1428 }
1429
1430 TaskShape {
1431 pushed,
1432 local,
1433 outcomes,
1434 }
1435}
1436
1437fn shape_projects(capabilities: &Capabilities, filters: &Filters) -> ProjectShape {
1439 let mut pushed = ProjectQuery::default();
1440 let mut local = LocalProjects::default();
1441 let mut outcomes = Outcomes::default();
1442
1443 if !filters.labels.is_empty() {
1444 if capabilities.filter_by_label.is_native() {
1445 pushed.labels = filters.labels.clone();
1446 outcomes.record(Predicate::Label, Outcome::PushedDown);
1447 } else {
1448 local.labels = Some(filters.labels.clone());
1449 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
1450 }
1451 }
1452 if !filters.statuses.is_empty() {
1453 if capabilities.filter_by_status.is_native() {
1454 pushed.statuses.clone_from(&filters.statuses);
1455 outcomes.record(Predicate::Status, Outcome::PushedDown);
1456 } else {
1457 local.statuses.clone_from(&filters.statuses);
1458 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
1459 }
1460 }
1461 if let Some(text) = &filters.text {
1462 let predicates = text_predicates(text.fields);
1463 if searches_natively(capabilities, text.fields) {
1464 pushed.text = Some(text.clone());
1465 outcomes.record_all(predicates, Outcome::PushedDown);
1466 } else {
1467 local.text = Some(text.clone());
1468 outcomes.record_all(predicates, Outcome::AppliedLocally);
1469 }
1470 }
1471
1472 ProjectShape {
1473 pushed,
1474 local,
1475 outcomes,
1476 }
1477}
1478
1479fn shape_hits(capabilities: &Capabilities, filters: &Filters, stream: StreamKind) -> HitShape {
1481 match stream {
1482 StreamKind::Projects => {
1483 let shaped = shape_projects(capabilities, filters);
1484 HitShape {
1485 stream,
1486 tasks: TaskQuery::default(),
1487 projects: shaped.pushed,
1488 local_tasks: LocalTasks::default(),
1489 local_projects: shaped.local,
1490 outcomes: shaped.outcomes,
1491 }
1492 }
1493 StreamKind::Items | StreamKind::Tasks => {
1494 let shaped = shape_tasks(capabilities, filters, &ProjectFilter::Any);
1495 HitShape {
1496 stream,
1497 tasks: shaped.pushed,
1498 projects: ProjectQuery::default(),
1499 local_tasks: shaped.local,
1500 local_projects: LocalProjects::default(),
1501 outcomes: shaped.outcomes,
1502 }
1503 }
1504 }
1505}
1506
1507fn page_size(compensating: bool, budget: u32, ceiling: u32) -> u32 {
1514 if compensating {
1515 ceiling
1516 } else {
1517 budget.min(ceiling)
1518 }
1519}
1520
1521fn ceiling(source: &ResolvedSource) -> u32 {
1523 source.source().capabilities().max_page_size.max(1)
1524}
1525
1526async fn fetch_tasks(
1528 source: &ResolvedSource,
1529 shape: &TaskShape,
1530 start: &Resume,
1531 budget: u32,
1532 calls: &AtomicU32,
1533) -> Result<Fetched<Task>, SourceError> {
1534 let compensating = shape.local != LocalTasks::default();
1535 walk(
1536 start,
1537 budget,
1538 page_size(compensating, budget, ceiling(source)),
1539 |task| shape.local.keeps(task),
1540 |cursor, limit| async move {
1541 calls.fetch_add(1, Ordering::Relaxed);
1542 let request = PageRequest { cursor, limit };
1543 source.source().query_tasks(&shape.pushed, &request).await
1544 },
1545 )
1546 .await
1547}
1548
1549async fn fetch_projects(
1551 source: &ResolvedSource,
1552 shape: &ProjectShape,
1553 start: &Resume,
1554 budget: u32,
1555 calls: &AtomicU32,
1556) -> Result<Fetched<Project>, SourceError> {
1557 let compensating = shape.local != LocalProjects::default();
1558 walk(
1559 start,
1560 budget,
1561 page_size(compensating, budget, ceiling(source)),
1562 |project| shape.local.keeps(project),
1563 |cursor, limit| async move {
1564 calls.fetch_add(1, Ordering::Relaxed);
1565 let request = PageRequest { cursor, limit };
1566 source
1567 .source()
1568 .query_projects(&shape.pushed, &request)
1569 .await
1570 },
1571 )
1572 .await
1573}
1574
1575async fn fetch_labels(
1577 source: &ResolvedSource,
1578 start: &Resume,
1579 budget: u32,
1580 calls: &AtomicU32,
1581) -> Result<Fetched<Label>, SourceError> {
1582 walk(
1583 start,
1584 budget,
1585 page_size(false, budget, ceiling(source)),
1586 |_| true,
1587 |cursor, limit| async move {
1588 calls.fetch_add(1, Ordering::Relaxed);
1589 let request = PageRequest { cursor, limit };
1590 source.source().labels(&request).await
1591 },
1592 )
1593 .await
1594}
1595
1596async fn fetch_hits(
1598 source: &ResolvedSource,
1599 shape: &HitShape,
1600 start: &Resume,
1601 budget: u32,
1602 calls: &AtomicU32,
1603) -> Result<Fetched<Found>, SourceError> {
1604 let ceiling = ceiling(source);
1605 match shape.stream {
1606 StreamKind::Projects => {
1607 let compensating = shape.local_projects != LocalProjects::default();
1608 walk(
1609 start,
1610 budget,
1611 page_size(compensating, budget, ceiling),
1612 |found| match found {
1613 Found::Project(project) => shape.local_projects.keeps(project),
1614 Found::Task(_) => true,
1615 },
1616 |cursor, limit| async move {
1617 calls.fetch_add(1, Ordering::Relaxed);
1618 let request = PageRequest { cursor, limit };
1619 let page = source
1620 .source()
1621 .query_projects(&shape.projects, &request)
1622 .await?;
1623 Ok(Page {
1624 items: page.items.into_iter().map(Found::Project).collect(),
1625 next: page.next,
1626 })
1627 },
1628 )
1629 .await
1630 }
1631 StreamKind::Items | StreamKind::Tasks => {
1632 let compensating = shape.local_tasks != LocalTasks::default();
1633 walk(
1634 start,
1635 budget,
1636 page_size(compensating, budget, ceiling),
1637 |found| match found {
1638 Found::Task(task) => shape.local_tasks.keeps(task),
1639 Found::Project(_) => true,
1640 },
1641 |cursor, limit| async move {
1642 calls.fetch_add(1, Ordering::Relaxed);
1643 let request = PageRequest { cursor, limit };
1644 let page = source.source().query_tasks(&shape.tasks, &request).await?;
1645 Ok(Page {
1646 items: page.items.into_iter().map(Found::Task).collect(),
1647 next: page.next,
1648 })
1649 },
1650 )
1651 .await
1652 }
1653 }
1654}
1655
1656async fn forward_edges(
1658 source: &ResolvedSource,
1659 entity: Entity,
1660 id: &NativeId,
1661 request: &PageRequest,
1662) -> Result<Page<DependencyEdge>, SourceError> {
1663 match entity {
1664 Entity::Task => {
1665 source
1666 .source()
1667 .task_dependencies(id, Direction::DependsOn, request)
1668 .await
1669 }
1670 Entity::Project => {
1671 source
1672 .source()
1673 .project_dependencies(id, Direction::DependsOn, request)
1674 .await
1675 }
1676 }
1677}
1678
1679#[expect(
1688 clippy::too_many_arguments,
1689 reason = "every argument is one axis of one walk — the source, the item, the \
1690 direction, which of its two graphs, whether the reverse is emulated, where \
1691 to resume, how many rows to return and where to count calls. Grouping them \
1692 into a struct would name the same eight values one indirection further from \
1693 the loop that reads them."
1694)]
1695async fn fetch_edges(
1696 source: &ResolvedSource,
1697 native: &NativeId,
1698 direction: Direction,
1699 entity: Entity,
1700 emulating: bool,
1701 start: &Resume,
1702 budget: u32,
1703 calls: &AtomicU32,
1704) -> Result<Fetched<DependencyEdge>, SourceError> {
1705 let ceiling = ceiling(source);
1706 if !emulating {
1707 return walk(
1708 start,
1709 budget,
1710 page_size(false, budget, ceiling),
1711 |_| true,
1712 |cursor, limit| async move {
1713 calls.fetch_add(1, Ordering::Relaxed);
1714 let request = PageRequest { cursor, limit };
1715 match entity {
1716 Entity::Task => {
1717 source
1718 .source()
1719 .task_dependencies(native, direction, &request)
1720 .await
1721 }
1722 Entity::Project => {
1723 source
1724 .source()
1725 .project_dependencies(native, direction, &request)
1726 .await
1727 }
1728 }
1729 },
1730 )
1731 .await;
1732 }
1733
1734 walk(
1735 start,
1736 budget,
1737 ceiling,
1738 |_| true,
1739 |cursor, limit| async move {
1740 calls.fetch_add(1, Ordering::Relaxed);
1741 let request = PageRequest { cursor, limit };
1742 let (ids, next) = match entity {
1743 Entity::Task => {
1744 let page = source
1745 .source()
1746 .query_tasks(&TaskQuery::default(), &request)
1747 .await?;
1748 let ids: Vec<NativeId> = page.items.into_iter().map(|task| task.id).collect();
1749 (ids, page.next)
1750 }
1751 Entity::Project => {
1752 let page = source
1753 .source()
1754 .query_projects(&ProjectQuery::default(), &request)
1755 .await?;
1756 let ids: Vec<NativeId> =
1757 page.items.into_iter().map(|project| project.id).collect();
1758 (ids, page.next)
1759 }
1760 };
1761
1762 let mut edges = Vec::new();
1763 for id in ids {
1764 let mut inner: Option<Cursor> = None;
1765 loop {
1766 calls.fetch_add(1, Ordering::Relaxed);
1767 let request = PageRequest {
1768 cursor: inner.clone(),
1769 limit,
1770 };
1771 let page = forward_edges(source, entity, &id, &request).await?;
1772 fits(page.items.len(), limit)?;
1776 edges.extend(page.items.into_iter().filter(|edge| &edge.to == native));
1777 if page.next.is_some() && page.next == inner {
1778 return Err(SourceError::Malformed {
1779 message: "the source returned the cursor it was given while its \
1780 forward edges were being scanned, so the scan would \
1781 never end"
1782 .to_owned(),
1783 });
1784 }
1785 match page.next {
1786 Some(cursor) => inner = Some(cursor),
1787 None => break,
1788 }
1789 }
1790 }
1791
1792 Ok(Page { items: edges, next })
1793 },
1794 )
1795 .await
1796}