1mod comment;
19mod copy;
20mod delivery;
21mod fetch;
22mod join;
23mod local;
24mod metadata;
25mod narrow;
26mod rendered;
27mod resume;
28mod update;
29
30use std::collections::BTreeMap;
31use std::num::NonZeroU32;
32use std::sync::atomic::{AtomicU32, Ordering};
33
34use chrono::{DateTime, Utc};
35use onetaskgraph_plugin_api::{
36 Capabilities, Cursor, DependencyEdge, Direction, Document, DocumentQuery, Label, LabelFilter,
37 MetadataMatch, MetadataRecord, NativeId, Page, PageRequest, Priority, Project, ProjectFilter,
38 ProjectQuery, Repository, SecretResolver, SourceError, SourceName, StatusCategory, Task,
39 TaskQuery, TextFields, TextQuery,
40};
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44use crate::GlobalId;
45use crate::config::{Config, Placement, Routes};
46use crate::plan::{PageToken, Predicate, QueryPlan, QueryResponse, SourceFailure, SourcePlan};
47use crate::resolve::{ResolvedSource, UnavailableSource, resolve_available};
48
49use fetch::{Fetched, Stream, fits, merge, unrepeated, walk};
50use join::join_all;
51use local::{LocalDocuments, LocalProjects, LocalTasks};
52pub(crate) use resume::{Owed, Resumption, StreamState};
53use resume::{Resume, StreamKind};
54
55pub use comment::{CommentList, DeletedComment, TaskDetail, TaskDetails};
56pub(crate) use copy::malformed_links;
57pub use copy::{
58 BudgetSpent, CopyAction, CopyItems, CopyLink, CopyLookup, CopyOutcome, CopyReport, CopyRequest,
59 CopyScope, CopyVia, MatchBy, NoCounterpart, Spent,
60};
61pub use delivery::{Delivered, DeliveryOutcome, TaskStatusSet, settled};
62pub use local::ProjectSelector;
63pub use metadata::MetadataSet;
64pub use narrow::{TaskContentSet, TaskPrioritySet};
65pub use rendered::{
66 Body, DocumentCreate, Regenerated, Regeneration, RenderRequest, RenderTemplate, RenderedRecord,
67 TaskCreate, TaskCreated, TemplateAnswers, UnusedAnswers,
68};
69pub use update::TaskUpdated;
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
76pub struct Qualified<T> {
77 pub id: GlobalId,
79 pub item: T,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
88pub struct QualifiedEdge {
89 pub from: QualifiedEndpoint,
94 pub to: QualifiedEndpoint,
96 pub kind: onetaskgraph_plugin_api::DependencyKind,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
102pub struct QualifiedEndpoint {
103 pub id: GlobalId,
105 pub kind: onetaskgraph_plugin_api::ItemKind,
107}
108
109impl std::fmt::Display for QualifiedEndpoint {
110 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 self.id.fmt(formatter)
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
117#[serde(tag = "kind", rename_all = "kebab-case")]
118pub enum SearchHit {
119 Task(Qualified<Task>),
121 Project(Qualified<Project>),
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
127#[serde(rename_all = "kebab-case")]
128pub enum SearchKind {
129 Tasks,
131 Projects,
133 #[default]
135 Both,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
140pub struct SourceListing {
141 pub source: SourceName,
143 pub kind: String,
155 #[serde(flatten)]
157 pub state: SourceState,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
162#[serde(tag = "state", rename_all = "kebab-case")]
163pub enum SourceState {
164 Available {
166 capabilities: Capabilities,
168 },
169 Unavailable {
171 error: SourceError,
173 },
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub struct Paging {
179 pub limit: NonZeroU32,
181 pub token: Option<PageToken>,
183}
184
185#[derive(Debug, Clone, Default, PartialEq)]
187pub struct Filters {
188 pub text: Option<TextQuery>,
190 pub labels: LabelFilter,
192 pub statuses: Vec<StatusCategory>,
194}
195
196#[derive(Debug, Clone)]
198pub struct TaskRequest {
199 pub sources: Vec<SourceName>,
201 pub filters: Filters,
203 pub project: ProjectSelector,
205 pub priorities: Vec<Priority>,
211 pub commented_since: Option<DateTime<Utc>>,
218 pub metadata: Vec<MetadataMatch>,
224 pub origin: Option<GlobalId>,
227 pub include_members: bool,
235 pub paging: Paging,
237}
238
239#[derive(Debug, Clone)]
241pub struct ProjectRequest {
242 pub sources: Vec<SourceName>,
244 pub filters: Filters,
246 pub paging: Paging,
248}
249
250#[derive(Debug, Clone, Default, PartialEq)]
257pub struct DocumentFilters {
258 pub text: Option<TextQuery>,
260 pub labels: LabelFilter,
262}
263
264#[derive(Debug, Clone)]
266pub struct DocumentRequest {
267 pub sources: Vec<SourceName>,
269 pub filters: DocumentFilters,
271 pub project: ProjectSelector,
273 pub paging: Paging,
275}
276
277#[derive(Debug, Clone)]
279pub struct LabelRequest {
280 pub sources: Vec<SourceName>,
282 pub paging: Paging,
284}
285
286#[derive(Debug, Clone)]
288pub struct SearchRequest {
289 pub sources: Vec<SourceName>,
291 pub text: TextQuery,
293 pub kind: SearchKind,
295 pub paging: Paging,
297}
298
299#[derive(Debug, Clone)]
301pub struct DependencyRequest {
302 pub id: GlobalId,
304 pub direction: Direction,
306 pub paging: Paging,
308}
309
310#[derive(Debug, Clone, PartialEq, thiserror::Error)]
318pub enum EngineError {
319 #[error(
321 "no source named {name:?} is configured\n\
322 next: name one of the configured sources ({configured}), or add {name:?} under \
323 `sources` — `onetaskgraph sources list` shows what this configuration has."
324 )]
325 UnknownSource {
326 name: String,
328 configured: String,
330 },
331
332 #[error(
334 "{message}\n\
335 next: page with a token exactly as the previous page reported it, and against \
336 the same configuration — or drop `--page` to start the walk again."
337 )]
338 Token {
339 message: String,
341 },
342
343 #[error(
345 "no sources are configured\n\
346 next: add one under `sources` in onetaskgraph.yaml — `onetaskgraph schema` \
347 prints what each plugin accepts."
348 )]
349 NoSources,
350
351 #[error(
353 "source {name} cannot be written: its plugin is {kind}, which has no write \
354 side\n\
355 next: copy into a source whose plugin can be written — `onetaskgraph sources \
356 list` reports each one's plugin."
357 )]
358 NotWritable {
359 name: String,
361 kind: String,
363 },
364
365 #[error(
372 "source {name} has no documents: its plugin is {kind}, which holds none\n\
373 next: name a source whose plugin has documents — `onetaskgraph sources list` \
374 reports each one's plugin and what it declares."
375 )]
376 NoDocuments {
377 name: String,
379 kind: String,
381 },
382
383 #[error(
388 "source {name} has no comments: its plugin is {kind}, whose tasks hold none\n\
389 next: name a task of a source whose plugin has comments — `onetaskgraph sources \
390 list` reports each one's plugin."
391 )]
392 NoComments {
393 name: String,
395 kind: String,
397 },
398
399 #[error(
401 "source {name} cannot be written: its plugin is {kind}, whose comments can be read \
402 but not added to, edited or removed\n\
403 next: list them with `onetaskgraph task comment list`, or comment on a task of a \
404 source whose plugin can be written — `onetaskgraph sources list` reports each \
405 one's plugin."
406 )]
407 CommentsNotWritable {
408 name: String,
410 kind: String,
412 },
413
414 #[error(
416 "source {name} cannot write a status: its plugin is {kind}, which has no write side\n\
417 next: set the status in that source itself, or name a task of a source whose plugin \
418 can be written — `onetaskgraph sources list` reports each one's plugin."
419 )]
420 StatusNotWritable {
421 name: String,
423 kind: String,
425 },
426
427 #[error(
429 "source {name} cannot write a {record}'s metadata: its plugin is {kind}, which has no \
430 write side\n\
431 next: set the key in that source itself, or name a {record} of a source whose plugin \
432 can be written — `onetaskgraph sources list` reports each one's plugin."
433 )]
434 MetadataNotWritable {
435 name: String,
437 kind: String,
439 record: MetadataRecord,
441 },
442
443 #[error(
445 "source {name} cannot write a priority: its plugin is {kind}, which has no write side\n\
446 next: set the priority in that source itself, or name a task of a source whose plugin \
447 can be written — `onetaskgraph sources list` reports each one's plugin."
448 )]
449 PriorityNotWritable {
450 name: String,
452 kind: String,
454 },
455
456 #[error(
458 "source {name} cannot write a task's content: its plugin is {kind}, which has no write \
459 side\n\
460 next: edit the content in that source itself, or name a task of a source whose plugin \
461 can be written — `onetaskgraph sources list` reports each one's plugin."
462 )]
463 ContentNotWritable {
464 name: String,
466 kind: String,
468 },
469
470 #[error(
472 "source {name} cannot update a task: its plugin is {kind}, which has no write side\n\
473 next: change the task in that source itself, or name a task of a source whose plugin \
474 can be written — `onetaskgraph sources list` reports each one's plugin."
475 )]
476 UpdateNotWritable {
477 name: String,
479 kind: String,
481 },
482
483 #[error(
485 "source {name} cannot create a {record}: its plugin is {kind}, which has no write side\n\
486 next: create it in a source whose plugin can be written — `onetaskgraph sources list` \
487 reports each one's plugin."
488 )]
489 NotCreatable {
490 name: String,
492 kind: String,
494 record: MetadataRecord,
496 },
497
498 #[error(
500 "source {name} cannot write a {record}'s rendering: its plugin is {kind}, which has no \
501 write side\n\
502 next: render it with --dry-run to read the result, or regenerate a {record} of a source \
503 whose plugin can be written."
504 )]
505 RenderingNotWritable {
506 name: String,
508 kind: String,
510 record: RenderedRecord,
512 },
513
514 #[error(
517 "supply every required answer to regenerate {id}: {} unanswered, and {reason}\n\
518 next: answer {} with --var NAME=VALUE or an answers file (--answers FILE), or run \
519 interactively to be asked.",
520 names.join(", "),
521 if names.len() == 1 { "it" } else { "each" }
522 )]
523 MissingAnswers {
524 id: String,
526 names: Vec<String>,
528 reason: String,
531 },
532
533 #[error("{error}")]
535 Template {
536 error: crate::template::TemplateError,
538 },
539
540 #[error(
542 "{record} {id} has no stored template answers: {reason}\n\
543 next: regenerate it with every required answer (`onetaskgraph {record} render {id} \
544 --var NAME=VALUE`), or read its provenance with `onetaskgraph {record} show {id}`."
545 )]
546 NoStoredAnswers {
547 record: RenderedRecord,
549 id: String,
551 reason: String,
553 },
554
555 #[error(
557 "{record} {id} records no template it was rendered from, and none was given\n\
558 next: name one with --template FILE or --template-loader FILE."
559 )]
560 NoTemplate {
561 record: RenderedRecord,
563 id: String,
565 },
566
567 #[error(
570 "{record} {id} records a template entry this product did not write — {problem}\n\
571 next: regenerate it with --template FILE or --template-loader FILE and every required \
572 answer, which records a fresh entry."
573 )]
574 MalformedProvenance {
575 record: RenderedRecord,
577 id: String,
579 problem: String,
581 },
582
583 #[error(
585 "{record} {id} was rendered from {reference:?}, which is not a readable file, so it \
586 cannot be re-read; a recorded reference is never turned into a location\n\
587 next: supply the template with --template-loader FILE (a loader document naming what \
588 to render), or name a template file with --template FILE."
589 )]
590 TemplateNotAFile {
591 record: RenderedRecord,
593 id: String,
595 reference: String,
597 },
598
599 #[error(
604 "source {name} cannot hold the field priority, so {task}'s priority {priority} cannot \
605 be written to it: its plugin is {kind}, which declares priority unsupported\n\
606 next: write to a source whose plugin holds a priority — `onetaskgraph sources list` \
607 reports what each declares — or set the task's priority to none first; a \
608 github-projects source holds one once its configuration sets priority_mapping."
609 )]
610 NoPriority {
611 name: String,
613 kind: String,
615 task: String,
617 priority: onetaskgraph_plugin_api::Priority,
619 },
620
621 #[error(
623 "no project with the id {id}\n\
624 next: check the id, or list what is there — `onetaskgraph project list` reports every \
625 project the configured sources hold."
626 )]
627 NoSuchProject {
628 id: String,
630 },
631
632 #[error(
634 "no document with the id {id}\n\
635 next: check the id, or list what is there — `onetaskgraph document list` reports every \
636 document the configured sources hold."
637 )]
638 NoSuchDocument {
639 id: String,
641 },
642
643 #[error(
645 "no task with the id {id}\n\
646 next: check the id, or list what is there — `onetaskgraph task list` reports every \
647 task the configured sources hold."
648 )]
649 NoSuchTask {
650 id: String,
652 },
653
654 #[error(
656 "task {task} has no comment with the id {comment}\n\
657 next: list its comments — `onetaskgraph task comment list {task}` reports each \
658 one's id."
659 )]
660 NoSuchComment {
661 task: String,
663 comment: String,
665 },
666
667 #[error(
672 "source {name} could not be built: {error}\n\
673 next: fix that source — `onetaskgraph sources list` reports its state — then run the \
674 command again."
675 )]
676 SourceUnavailable {
677 name: String,
679 error: SourceError,
681 },
682
683 #[error(
689 "source {name} could not do it: {error}\n\
690 next: fix what the source named above, then run the command again."
691 )]
692 SourceFailed {
693 name: String,
695 error: SourceError,
697 },
698
699 #[error(
701 "the destination source {name} could not be built: {error}\n\
702 next: fix that source — `onetaskgraph sources list` reports its state — then \
703 copy again."
704 )]
705 DestinationUnavailable {
706 name: String,
708 error: SourceError,
710 },
711
712 #[error(
714 "no item with the id {id}\n\
715 next: check the id, or list what is there — `onetaskgraph task list` and \
716 `onetaskgraph project list` report what the configured sources hold."
717 )]
718 NoSuchItem {
719 id: String,
721 },
722
723 #[error(
728 "{item} was copied from {origin}, which that destination no longer holds\n\
729 next: re-run with --recreate to create a new item there instead, or restore \
730 {origin}."
731 )]
732 StaleOrigin {
733 item: String,
735 origin: String,
737 },
738
739 #[error(
745 "{item} was last copied to {link}, which that destination no longer holds\n\
746 next: re-run with --recreate to create a new item there instead, or restore \
747 {link}."
748 )]
749 StaleLink {
750 item: String,
752 link: String,
754 },
755
756 #[error(
759 "--create cannot be given with {flag}: --create asserts the destination holds no \
760 counterpart, so there is nothing for {flag} to look for\n\
761 next: drop {flag} to create each item without looking, or drop --create to look."
762 )]
763 CreateWith {
764 flag: CopyLookup,
766 },
767
768 #[error(
774 "{item} already records a counterpart at the destination, {carrier}, and --create \
775 asserts it has none\n\
776 next: copy it without --create, which updates {carrier}."
777 )]
778 CreateCarried {
779 item: GlobalId,
781 carrier: GlobalId,
783 },
784
785 #[error(
791 "{id} is not a task of {project}, so it cannot be copied as one of its members\n\
792 next: name a task `onetaskgraph task list --project {project}` reports, or copy \
793 {id} on its own with `onetaskgraph task copy`.",
794 project = .projects.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
795 )]
796 NotAMember {
797 id: GlobalId,
799 projects: Vec<GlobalId>,
801 },
802
803 #[error(
812 "{item} depends on {member}, which this copy was not told to carry and which records \
813 no origin in {destination}\n\
814 next: name {member} with --member as well, record its {destination} id at \
815 onetaskgraph.origin, or copy the whole project without --member."
816 )]
817 UnrecordedMember {
818 item: GlobalId,
820 member: GlobalId,
822 destination: SourceName,
824 },
825
826 #[error(
833 "{item} was copied to {counterpart}, but its repositories now route it to {route}\n\
834 next: move or remove {counterpart} by hand and copy again, or restore {item}'s \
835 repositories so it routes to {source} again.",
836 source = .counterpart.source
837 )]
838 Misrouted {
839 item: GlobalId,
841 counterpart: GlobalId,
843 route: SourceName,
845 },
846
847 #[error(
853 "source {name} could not do it: {error}\n\
854 next: fix what the source named above, then copy again."
855 )]
856 SourceRefused {
857 name: String,
859 error: SourceError,
861 },
862
863 #[error(
871 "the copy failed and could not be undone.\n\
872 it failed because: {error}\n\
873 it could not be undone because: {refusal}\n\
874 so these still hold what it wrote: {left_behind}\n\
875 next: remove or put back those items, then copy again."
876 )]
877 CopyNotUndone {
878 error: Box<EngineError>,
880 left_behind: LeftBehind,
886 refusal: SourceError,
888 },
889}
890
891#[derive(Debug, Clone, PartialEq)]
900pub struct LeftBehind {
901 first: GlobalId,
903 rest: Vec<GlobalId>,
905}
906
907impl LeftBehind {
908 #[must_use]
910 pub fn new(first: GlobalId) -> Self {
911 Self {
912 first,
913 rest: Vec::new(),
914 }
915 }
916
917 pub fn push(&mut self, id: GlobalId) {
919 self.rest.push(id);
920 }
921
922 pub fn iter(&self) -> impl Iterator<Item = &GlobalId> {
924 std::iter::once(&self.first).chain(self.rest.iter())
925 }
926}
927
928impl std::fmt::Display for LeftBehind {
929 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
931 write!(formatter, "{}", self.first)?;
932 for id in &self.rest {
933 write!(formatter, ", {id}")?;
934 }
935 Ok(())
936 }
937}
938
939pub enum ConfiguredSource {
946 Ready(ResolvedSource),
948 Unavailable(UnavailableSource),
950}
951
952impl ConfiguredSource {
953 #[must_use]
955 pub fn name(&self) -> &SourceName {
956 match self {
957 Self::Ready(source) => source.name(),
958 Self::Unavailable(source) => source.name(),
959 }
960 }
961}
962
963pub struct Engine {
965 sources: Vec<ConfiguredSource>,
967 selection: Vec<SourceName>,
969 routes: Routes,
972}
973
974impl Engine {
975 #[must_use]
983 pub fn build(config: &Config, secrets: &dyn SecretResolver) -> Self {
984 let (ready, unavailable) = resolve_available(config, secrets);
985 Self::new(
986 ready
987 .into_iter()
988 .map(ConfiguredSource::Ready)
989 .chain(unavailable.into_iter().map(ConfiguredSource::Unavailable))
990 .collect(),
991 config.selected_sources(),
992 )
993 .with_routes(config.routes())
994 }
995
996 #[must_use]
999 pub fn new(sources: Vec<ConfiguredSource>, selection: Vec<SourceName>) -> Self {
1000 Self {
1001 sources,
1002 selection,
1003 routes: Routes::default(),
1004 }
1005 }
1006
1007 #[must_use]
1012 pub fn with_routes(mut self, routes: Routes) -> Self {
1013 self.routes = routes;
1014 self
1015 }
1016
1017 #[must_use]
1019 pub fn place(&self, source: &SourceName, repositories: &[Repository]) -> Placement {
1020 self.routes.place(source, repositories)
1021 }
1022
1023 fn ready(&self) -> impl Iterator<Item = &ResolvedSource> {
1025 self.sources.iter().filter_map(|source| match source {
1026 ConfiguredSource::Ready(ready) => Some(ready),
1027 ConfiguredSource::Unavailable(_) => None,
1028 })
1029 }
1030
1031 fn unavailable(&self) -> impl Iterator<Item = &UnavailableSource> {
1033 self.sources.iter().filter_map(|source| match source {
1034 ConfiguredSource::Unavailable(unavailable) => Some(unavailable),
1035 ConfiguredSource::Ready(_) => None,
1036 })
1037 }
1038
1039 #[must_use]
1041 pub fn listing(&self) -> Vec<SourceListing> {
1042 let mut listings: Vec<SourceListing> = self
1043 .ready()
1044 .map(|source| SourceListing {
1045 source: source.name().clone(),
1046 kind: source.kind().to_owned(),
1047 state: SourceState::Available {
1048 capabilities: source.source().capabilities(),
1049 },
1050 })
1051 .chain(self.unavailable().map(|source| SourceListing {
1052 source: source.name().clone(),
1053 kind: source.kind().to_owned(),
1054 state: SourceState::Unavailable {
1055 error: source.error().clone(),
1056 },
1057 }))
1058 .collect();
1059 listings.sort_by(|left, right| left.source.cmp(&right.source));
1060 listings
1061 }
1062
1063 #[must_use]
1069 pub fn has(&self, name: &SourceName) -> bool {
1070 self.sources.iter().any(|source| source.name() == name)
1071 }
1072
1073 pub async fn end_command(&self) -> Result<(), EngineError> {
1093 let mut first = None;
1094 for source in self.ready() {
1095 if let Err(error) = source.source().end_command().await
1096 && first.is_none()
1097 {
1098 first = Some(EngineError::SourceFailed {
1099 name: source.name().to_string(),
1100 error,
1101 });
1102 }
1103 }
1104 first.map_or(Ok(()), Err)
1105 }
1106
1107 pub async fn tasks(
1115 &self,
1116 request: &TaskRequest,
1117 ) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
1118 let mut names = self.resolve_selection(&request.sources)?;
1119 let mut members: Vec<GlobalId> = Vec::new();
1124 let mut unread: Vec<SourceFailure> = Vec::new();
1125 if let ProjectSelector::Qualified(id) = &request.project {
1126 self.known(&id.source)?;
1127 names.retain(|name| name == &id.source);
1128 if request.include_members {
1131 (members, unread) = self.members(id).await;
1132 names.extend(members.iter().map(|member| member.source.clone()));
1133 }
1134 }
1135 let selector = |name: &SourceName| -> ProjectSelector {
1136 members
1137 .iter()
1138 .find(|member| &member.source == name)
1139 .map_or_else(
1140 || request.project.clone(),
1141 |member| ProjectSelector::Qualified(member.clone()),
1142 )
1143 };
1144 let query = shape(
1145 "task-list",
1146 &names,
1147 &(
1148 &request.filters,
1149 &request.project,
1150 &request.priorities,
1151 &request.commented_since,
1152 &request.metadata,
1153 &request.origin,
1154 &members,
1155 ),
1156 );
1157 let states = resumption(
1158 self,
1159 request.paging.token.as_ref(),
1160 &[StreamKind::Items],
1161 &query,
1162 )?;
1163 let budget = request.paging.limit.get();
1164
1165 let mut answer = Answer::new();
1166 answer.errors.extend(unread);
1167 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
1168
1169 let shapes: Vec<TaskShape> = ready
1170 .iter()
1171 .map(|source| {
1172 shape_tasks(
1173 &source.source().capabilities(),
1174 &request.filters,
1175 &project_filter(&selector(source.name())),
1176 &request.priorities,
1177 request.commented_since,
1178 &request.metadata,
1179 request.origin.as_ref(),
1180 )
1181 })
1182 .collect();
1183 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
1184 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
1185
1186 let walks = ready
1187 .iter()
1188 .enumerate()
1189 .map(|(index, source)| {
1190 fetch_tasks(
1191 source,
1192 &shapes[index],
1193 &starts[index],
1194 budget,
1195 &counters[index],
1196 )
1197 })
1198 .collect();
1199
1200 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
1201 answer.finish(
1202 streams,
1203 budget,
1204 owed(&states),
1205 &query,
1206 |name, task: Task| {
1207 delivery::qualified_task(GlobalId::new(name.clone(), task.id.clone()), task)
1208 },
1209 )
1210 }
1211
1212 async fn members(&self, home: &GlobalId) -> (Vec<GlobalId>, Vec<SourceFailure>) {
1223 let mut members = Vec::new();
1224 let mut unread = Vec::new();
1225 let Some(source) = self.ready().find(|source| source.name() == &home.source) else {
1226 return (members, unread);
1227 };
1228 let held = match source.source().get_project(&home.native).await {
1229 Ok(Some(held)) => held,
1230 Ok(None) => {
1231 unread.push(SourceFailure {
1232 source: home.source.clone(),
1233 error: SourceError::Refused {
1234 message: format!(
1235 "{home} names no project {} holds, so its member projects cannot \
1236 be read",
1237 home.source
1238 ),
1239 },
1240 });
1241 return (members, unread);
1242 }
1243 Err(error) => {
1244 unread.push(SourceFailure {
1245 source: home.source.clone(),
1246 error,
1247 });
1248 return (members, unread);
1249 }
1250 };
1251 let named = match copy::members_of(home, &held.metadata) {
1252 Ok(named) => named,
1253 Err(message) => {
1256 unread.push(SourceFailure {
1257 source: home.source.clone(),
1258 error: SourceError::Malformed { message },
1259 });
1260 return (members, unread);
1261 }
1262 };
1263 for member in named {
1264 if !self.has(&member.source) {
1265 unread.push(SourceFailure {
1266 source: member.source.clone(),
1267 error: SourceError::Config {
1268 message: format!(
1269 "{home} names {member} as a member project, but no source named \
1270 {} is configured",
1271 member.source
1272 ),
1273 },
1274 });
1275 continue;
1276 }
1277 let Some(there) = self.ready().find(|source| source.name() == &member.source) else {
1280 members.push(member);
1281 continue;
1282 };
1283 match there.source().get_project(&member.native).await {
1284 Ok(Some(_)) => members.push(member),
1285 Ok(None) => unread.push(SourceFailure {
1286 source: member.source.clone(),
1287 error: SourceError::Refused {
1288 message: format!(
1289 "{home} names {member} as a member project, and {} holds no such \
1290 project",
1291 member.source
1292 ),
1293 },
1294 }),
1295 Err(error) => unread.push(SourceFailure {
1296 source: member.source.clone(),
1297 error,
1298 }),
1299 }
1300 }
1301 (members, unread)
1302 }
1303
1304 pub async fn projects(
1310 &self,
1311 request: &ProjectRequest,
1312 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
1313 let names = self.resolve_selection(&request.sources)?;
1314 let query = shape("project-list", &names, &request.filters);
1315 let states = resumption(
1316 self,
1317 request.paging.token.as_ref(),
1318 &[StreamKind::Items],
1319 &query,
1320 )?;
1321 let budget = request.paging.limit.get();
1322
1323 let mut answer = Answer::new();
1324 let mut with_projects = Vec::new();
1330 for source in answer.split(self, &names) {
1331 if source.source().capabilities().projects.is_native() {
1332 with_projects.push(source);
1333 } else {
1334 answer.unreachable_predicate(source, Predicate::Project);
1335 }
1336 }
1337 let (ready, starts) = walking(with_projects, &states, StreamKind::Items);
1338
1339 let shapes: Vec<ProjectShape> = ready
1340 .iter()
1341 .map(|source| shape_projects(&source.source().capabilities(), &request.filters))
1342 .collect();
1343 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
1344 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
1345
1346 let walks = ready
1347 .iter()
1348 .enumerate()
1349 .map(|(index, source)| {
1350 fetch_projects(
1351 source,
1352 &shapes[index],
1353 &starts[index],
1354 budget,
1355 &counters[index],
1356 )
1357 })
1358 .collect();
1359
1360 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
1361 answer.finish(
1362 streams,
1363 budget,
1364 owed(&states),
1365 &query,
1366 |name, project: Project| Qualified {
1367 id: GlobalId::new(name.clone(), project.id.clone()),
1368 item: project,
1369 },
1370 )
1371 }
1372
1373 pub async fn documents(
1385 &self,
1386 request: &DocumentRequest,
1387 ) -> Result<QueryResponse<Qualified<Document>>, EngineError> {
1388 let mut names = self.resolve_selection(&request.sources)?;
1389 if let ProjectSelector::Qualified(id) = &request.project {
1392 self.known(&id.source)?;
1393 names.retain(|name| name == &id.source);
1394 }
1395 let query = shape(
1396 "document-list",
1397 &names,
1398 &(&request.filters, &request.project),
1399 );
1400 let states = resumption(
1401 self,
1402 request.paging.token.as_ref(),
1403 &[StreamKind::Items],
1404 &query,
1405 )?;
1406 let budget = request.paging.limit.get();
1407
1408 let mut answer = Answer::new();
1409 let mut with_documents = Vec::new();
1410 for source in answer.split(self, &names) {
1411 if source.source().capabilities().documents.is_native() {
1412 with_documents.push(source);
1413 } else {
1414 answer.unreachable_predicate(source, Predicate::Document);
1415 }
1416 }
1417 let (ready, starts) = walking(with_documents, &states, StreamKind::Items);
1418
1419 let shapes: Vec<DocumentShape> = ready
1420 .iter()
1421 .map(|source| {
1422 shape_documents(
1423 &source.source().capabilities(),
1424 &request.filters,
1425 &project_filter(&request.project),
1426 )
1427 })
1428 .collect();
1429 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
1430 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
1431
1432 let walks = ready
1433 .iter()
1434 .enumerate()
1435 .map(|(index, source)| {
1436 fetch_documents(
1437 source,
1438 &shapes[index],
1439 &starts[index],
1440 budget,
1441 &counters[index],
1442 )
1443 })
1444 .collect();
1445
1446 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
1447 answer.finish(
1448 streams,
1449 budget,
1450 owed(&states),
1451 &query,
1452 |name, document: Document| Qualified {
1453 id: GlobalId::new(name.clone(), document.id.clone()),
1454 item: document,
1455 },
1456 )
1457 }
1458
1459 pub async fn labels(
1465 &self,
1466 request: &LabelRequest,
1467 ) -> Result<QueryResponse<Qualified<Label>>, EngineError> {
1468 let names = self.resolve_selection(&request.sources)?;
1469 let query = shape("label-list", &names, &());
1470 let states = resumption(
1471 self,
1472 request.paging.token.as_ref(),
1473 &[StreamKind::Items],
1474 &query,
1475 )?;
1476 let budget = request.paging.limit.get();
1477
1478 let mut answer = Answer::new();
1479 let (ready, starts) = walking(answer.split(self, &names), &states, StreamKind::Items);
1480 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
1481 let outcomes: Vec<Outcomes> = ready.iter().map(|_| Outcomes::default()).collect();
1482
1483 let walks = ready
1484 .iter()
1485 .enumerate()
1486 .map(|(index, source)| fetch_labels(source, &starts[index], budget, &counters[index]))
1487 .collect();
1488
1489 let streams = answer.collect(&ready, join_all(walks).await, &counters, outcomes);
1490 answer.finish(
1491 streams,
1492 budget,
1493 owed(&states),
1494 &query,
1495 |name, label: Label| Qualified {
1496 id: GlobalId::new(name.clone(), label.id.clone()),
1497 item: label,
1498 },
1499 )
1500 }
1501
1502 pub async fn search(
1508 &self,
1509 request: &SearchRequest,
1510 ) -> Result<QueryResponse<SearchHit>, EngineError> {
1511 let names = self.resolve_selection(&request.sources)?;
1512 let reads: &[StreamKind] = match request.kind {
1516 SearchKind::Tasks => &[StreamKind::Tasks],
1517 SearchKind::Projects => &[StreamKind::Projects],
1518 SearchKind::Both => &[StreamKind::Tasks, StreamKind::Projects],
1519 };
1520 let query = shape("search", &names, &request.text);
1525 let states = resumption(self, request.paging.token.as_ref(), reads, &query)?;
1526 let budget = request.paging.limit.get();
1527 let filters = Filters {
1528 text: Some(request.text.clone()),
1529 ..Filters::default()
1530 };
1531
1532 let mut answer = Answer::new();
1533
1534 let mut ready = Vec::new();
1537 let mut kinds = Vec::new();
1538 let mut starts = Vec::new();
1539 for source in answer.split(self, &names) {
1540 let mut streams = Vec::new();
1541 if matches!(request.kind, SearchKind::Tasks | SearchKind::Both) {
1542 streams.push(StreamKind::Tasks);
1543 }
1544 if matches!(request.kind, SearchKind::Projects | SearchKind::Both) {
1545 if source.source().capabilities().projects.is_native() {
1546 streams.push(StreamKind::Projects);
1547 } else {
1548 answer.unreachable_predicate(source, Predicate::Project);
1549 }
1550 }
1551 for stream in streams {
1552 if let Some(resume) = resume_at(&states, source.name(), stream) {
1553 ready.push(source);
1554 kinds.push(stream);
1555 starts.push(resume);
1556 }
1557 }
1558 }
1559
1560 let shapes: Vec<HitShape> = ready
1561 .iter()
1562 .zip(kinds.iter())
1563 .map(|(source, kind)| shape_hits(&source.source().capabilities(), &filters, *kind))
1564 .collect();
1565 let counters: Vec<AtomicU32> = ready.iter().map(|_| AtomicU32::new(0)).collect();
1566 let outcomes: Vec<Outcomes> = shapes.iter().map(|shape| shape.outcomes.clone()).collect();
1567
1568 let walks = ready
1569 .iter()
1570 .enumerate()
1571 .map(|(index, source)| {
1572 fetch_hits(
1573 source,
1574 &shapes[index],
1575 &starts[index],
1576 budget,
1577 &counters[index],
1578 )
1579 })
1580 .collect();
1581
1582 let streams =
1583 answer.collect_streams(&ready, &kinds, join_all(walks).await, &counters, outcomes);
1584 answer.finish(
1585 streams,
1586 budget,
1587 owed(&states),
1588 &query,
1589 |name, found: Found| match found {
1590 Found::Task(task) => SearchHit::Task(delivery::qualified_task(
1591 GlobalId::new(name.clone(), task.id.clone()),
1592 task,
1593 )),
1594 Found::Project(project) => SearchHit::Project(Qualified {
1595 id: GlobalId::new(name.clone(), project.id.clone()),
1596 item: project,
1597 }),
1598 },
1599 )
1600 }
1601
1602 pub async fn task(&self, id: &GlobalId) -> Result<QueryResponse<Qualified<Task>>, EngineError> {
1609 let name = self.known(&id.source)?;
1610 let mut answer = Answer::new();
1611 let selected = answer.split(self, std::slice::from_ref(&name));
1612 let Some(source) = selected.first() else {
1613 return answer.nothing();
1614 };
1615 let found = source.source().get_task(&id.native).await;
1616 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
1617 answer.one(source, found, |task| {
1618 delivery::qualified_task(qualified, task)
1619 })
1620 }
1621
1622 pub async fn project(
1628 &self,
1629 id: &GlobalId,
1630 ) -> Result<QueryResponse<Qualified<Project>>, EngineError> {
1631 let name = self.known(&id.source)?;
1632 let mut answer = Answer::new();
1633 let selected = answer.split(self, std::slice::from_ref(&name));
1634 let Some(source) = selected.first() else {
1635 return answer.nothing();
1636 };
1637 let found = source.source().get_project(&id.native).await;
1638 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
1639 answer.one(source, found, |project| Qualified {
1640 id: qualified,
1641 item: project,
1642 })
1643 }
1644
1645 pub async fn document(
1655 &self,
1656 id: &GlobalId,
1657 ) -> Result<QueryResponse<Qualified<Document>>, EngineError> {
1658 let name = self.known(&id.source)?;
1659 let mut answer = Answer::new();
1660 let selected = answer.split(self, std::slice::from_ref(&name));
1661 let Some(source) = selected.first() else {
1662 return answer.nothing();
1663 };
1664 if !source.source().capabilities().documents.is_native() {
1665 answer.unreachable_predicate(source, Predicate::Document);
1666 return answer.nothing();
1667 }
1668 let found = source.source().get_document(&id.native).await;
1669 let qualified = GlobalId::new(source.name().clone(), id.native.clone());
1670 answer.one(source, found, |document| Qualified {
1671 id: qualified,
1672 item: document,
1673 })
1674 }
1675
1676 pub async fn task_dependencies(
1683 &self,
1684 request: &DependencyRequest,
1685 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1686 self.dependencies(request, Entity::Task).await
1687 }
1688
1689 pub async fn project_dependencies(
1695 &self,
1696 request: &DependencyRequest,
1697 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1698 self.dependencies(request, Entity::Project).await
1699 }
1700
1701 async fn dependencies(
1704 &self,
1705 request: &DependencyRequest,
1706 entity: Entity,
1707 ) -> Result<QueryResponse<QualifiedEdge>, EngineError> {
1708 let name = self.known(&request.id.source)?;
1709 let query = shape(
1710 "dependencies",
1711 std::slice::from_ref(&name),
1712 &(entity, &request.id.native, request.direction),
1713 );
1714 let states = resumption(
1715 self,
1716 request.paging.token.as_ref(),
1717 &[StreamKind::Items],
1718 &query,
1719 )?;
1720 let budget = request.paging.limit.get();
1721
1722 let mut answer = Answer::new();
1723 let (ready, starts) = walking(
1724 answer.split(self, std::slice::from_ref(&name)),
1725 &states,
1726 StreamKind::Items,
1727 );
1728 let Some(source) = ready.first() else {
1729 return answer.nothing();
1730 };
1731
1732 let capabilities = source.source().capabilities();
1733 let support = match entity {
1734 Entity::Task => capabilities.task_dependencies,
1735 Entity::Project => capabilities.project_dependencies,
1736 };
1737 let emulating = request.direction == Direction::DependedOnBy && !support.answers_reverse();
1741 let mut outcomes = Outcomes::default();
1742 if request.direction == Direction::DependedOnBy {
1743 if emulating {
1744 outcomes.record(Predicate::ReverseDependencies, Outcome::Emulated);
1745 } else {
1746 outcomes.record(Predicate::ReverseDependencies, Outcome::PushedDown);
1747 }
1748 }
1749
1750 let counters = vec![AtomicU32::new(0)];
1751 let walked = fetch_edges(
1752 source,
1753 &request.id.native,
1754 request.direction,
1755 entity,
1756 emulating,
1757 &starts[0],
1758 budget,
1759 &counters[0],
1760 )
1761 .await;
1762
1763 let streams = answer.collect(&ready, vec![walked], &counters, vec![outcomes]);
1764 answer.finish(
1765 streams,
1766 budget,
1767 owed(&states),
1768 &query,
1769 |name, edge: DependencyEdge| QualifiedEdge {
1770 from: qualify_endpoint(name, edge.from),
1771 to: qualify_endpoint(name, edge.to),
1772 kind: edge.kind,
1773 },
1774 )
1775 }
1776
1777 fn resolve_selection(&self, asked: &[SourceName]) -> Result<Vec<SourceName>, EngineError> {
1779 if asked.is_empty() {
1780 if self.selection.is_empty() {
1781 return Err(EngineError::NoSources);
1782 }
1783 return Ok(self.selection.clone());
1784 }
1785 asked.iter().map(|name| self.known(name)).collect()
1786 }
1787
1788 fn known(&self, name: &SourceName) -> Result<SourceName, EngineError> {
1790 if self.has(name) {
1791 return Ok(name.clone());
1792 }
1793 if self.sources.is_empty() {
1794 return Err(EngineError::NoSources);
1795 }
1796 Err(EngineError::UnknownSource {
1797 name: name.to_string(),
1798 configured: self
1799 .listing()
1800 .iter()
1801 .map(|listing| listing.source.to_string())
1802 .collect::<Vec<_>>()
1803 .join(", "),
1804 })
1805 }
1806}
1807
1808fn qualify_endpoint(
1809 source: &SourceName,
1810 endpoint: onetaskgraph_plugin_api::DependencyEndpoint,
1811) -> QualifiedEndpoint {
1812 let kind = endpoint.kind;
1813 let is_qualified = endpoint.is_qualified();
1814 let endpoint_id = endpoint.into_id();
1815 QualifiedEndpoint {
1816 id: if is_qualified {
1817 endpoint_id
1818 .parse()
1819 .expect("plugin-api validates qualified dependency endpoints")
1820 } else {
1821 GlobalId::new(source.clone(), NativeId(endpoint_id))
1822 },
1823 kind,
1824 }
1825}
1826
1827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1829enum Entity {
1830 Task,
1832 Project,
1834}
1835
1836enum Found {
1838 Task(Task),
1840 Project(Project),
1842}
1843
1844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1846enum Outcome {
1847 PushedDown,
1849 AppliedLocally,
1851 Emulated,
1853 Unavailable,
1855}
1856
1857#[derive(Debug, Clone, Default, PartialEq)]
1869struct Outcomes(BTreeMap<Predicate, Outcome>);
1870
1871impl Outcomes {
1872 fn record(&mut self, predicate: Predicate, outcome: Outcome) {
1878 self.0.insert(predicate, outcome);
1879 }
1880
1881 fn record_all(&mut self, predicates: impl IntoIterator<Item = Predicate>, outcome: Outcome) {
1884 for predicate in predicates {
1885 self.record(predicate, outcome);
1886 }
1887 }
1888
1889 fn with(&self, outcome: Outcome) -> Vec<Predicate> {
1891 self.0
1892 .iter()
1893 .filter(|(_, recorded)| **recorded == outcome)
1894 .map(|(predicate, _)| *predicate)
1895 .collect()
1896 }
1897}
1898
1899struct TaskShape {
1901 pushed: TaskQuery,
1903 local: LocalTasks,
1905 outcomes: Outcomes,
1907}
1908
1909struct ProjectShape {
1911 pushed: ProjectQuery,
1913 local: LocalProjects,
1915 outcomes: Outcomes,
1917}
1918
1919struct DocumentShape {
1921 pushed: DocumentQuery,
1923 local: LocalDocuments,
1925 outcomes: Outcomes,
1927}
1928
1929struct HitShape {
1931 stream: StreamKind,
1933 tasks: TaskQuery,
1935 projects: ProjectQuery,
1937 local_tasks: LocalTasks,
1939 local_projects: LocalProjects,
1941 outcomes: Outcomes,
1943}
1944
1945struct Answer {
1951 plans: Vec<SourcePlan>,
1953 errors: Vec<SourceFailure>,
1955}
1956
1957impl Answer {
1958 fn new() -> Self {
1959 Self {
1960 plans: Vec::new(),
1961 errors: Vec::new(),
1962 }
1963 }
1964
1965 fn split<'a>(&mut self, engine: &'a Engine, names: &[SourceName]) -> Vec<&'a ResolvedSource> {
1970 let mut selected = Vec::new();
1971 for name in names {
1972 match engine.sources.iter().find(|source| source.name() == name) {
1973 Some(ConfiguredSource::Ready(source)) => selected.push(source),
1974 Some(ConfiguredSource::Unavailable(source)) => {
1975 self.errors.push(source.failure());
1976 }
1977 None => {}
1978 }
1979 }
1980 selected
1981 }
1982
1983 fn unreachable_predicate(&mut self, source: &ResolvedSource, predicate: Predicate) {
1985 let mut outcomes = Outcomes::default();
1986 outcomes.record(predicate, Outcome::Unavailable);
1987 self.plans.push(plan_for(source, outcomes, 0));
1988 }
1989
1990 fn collect<T>(
1992 &mut self,
1993 ready: &[&ResolvedSource],
1994 walked: Vec<Result<Fetched<T>, SourceError>>,
1995 counters: &[AtomicU32],
1996 outcomes: Vec<Outcomes>,
1997 ) -> Vec<Stream<T>> {
1998 let kinds = vec![StreamKind::Items; ready.len()];
1999 self.collect_streams(ready, &kinds, walked, counters, outcomes)
2000 }
2001
2002 fn collect_streams<T>(
2004 &mut self,
2005 ready: &[&ResolvedSource],
2006 kinds: &[StreamKind],
2007 walked: Vec<Result<Fetched<T>, SourceError>>,
2008 counters: &[AtomicU32],
2009 outcomes: Vec<Outcomes>,
2010 ) -> Vec<Stream<T>> {
2011 let mut streams = Vec::new();
2012 for (index, result) in walked.into_iter().enumerate() {
2013 let source = ready[index];
2014 let pages = counters[index].load(Ordering::Relaxed);
2015 self.plans
2016 .push(plan_for(source, outcomes[index].clone(), pages));
2017 match result {
2018 Ok(fetched) => streams.push(Stream {
2019 source: source.name().clone(),
2020 kind: kinds[index],
2021 fetched,
2022 }),
2023 Err(error) => self.errors.push(SourceFailure {
2026 source: source.name().clone(),
2027 error,
2028 }),
2029 }
2030 }
2031 streams
2032 }
2033
2034 fn one<T, U>(
2036 self,
2037 source: &ResolvedSource,
2038 found: Result<Option<T>, SourceError>,
2039 qualify: impl FnOnce(T) -> U,
2040 ) -> Result<QueryResponse<U>, EngineError> {
2041 Ok(self.one_response(source, found, qualify))
2042 }
2043
2044 fn one_response<T, U>(
2046 mut self,
2047 source: &ResolvedSource,
2048 found: Result<Option<T>, SourceError>,
2049 qualify: impl FnOnce(T) -> U,
2050 ) -> QueryResponse<U> {
2051 self.plans.push(plan_for(source, Outcomes::default(), 1));
2052 let items = match found {
2053 Ok(Some(item)) => vec![qualify(item)],
2054 Ok(None) => Vec::new(),
2055 Err(error) => {
2056 self.errors.push(SourceFailure {
2057 source: source.name().clone(),
2058 error,
2059 });
2060 Vec::new()
2061 }
2062 };
2063 QueryResponse {
2064 items,
2065 next: None,
2066 plan: QueryPlan {
2067 per_source: merge_plans(self.plans),
2068 },
2069 errors: self.errors,
2070 }
2071 }
2072
2073 fn nothing<U>(self) -> Result<QueryResponse<U>, EngineError> {
2075 Ok(QueryResponse {
2076 items: Vec::new(),
2077 next: None,
2078 plan: QueryPlan {
2079 per_source: merge_plans(self.plans),
2080 },
2081 errors: self.errors,
2082 })
2083 }
2084
2085 fn finish<T, U>(
2090 self,
2091 streams: Vec<Stream<T>>,
2092 budget: u32,
2093 first: Option<&Owed>,
2094 query: &str,
2095 qualify: impl Fn(&SourceName, T) -> U,
2096 ) -> Result<QueryResponse<U>, EngineError> {
2097 let (rows, states, owed) = merge(streams, budget, first);
2098 let next = (!states.is_empty()).then(|| PageToken::encode(query, owed, &states));
2099 Ok(QueryResponse {
2100 items: rows
2101 .into_iter()
2102 .map(|(name, item)| qualify(&name, item))
2103 .collect(),
2104 next,
2105 plan: QueryPlan {
2106 per_source: merge_plans(self.plans),
2107 },
2108 errors: self.errors,
2109 })
2110 }
2111}
2112
2113fn plan_for(source: &ResolvedSource, outcomes: Outcomes, pages: u32) -> SourcePlan {
2116 SourcePlan {
2117 source: source.name().clone(),
2118 kind: source.kind().to_owned(),
2119 pushed_down: outcomes.with(Outcome::PushedDown),
2120 applied_locally: outcomes.with(Outcome::AppliedLocally),
2121 emulated: outcomes.with(Outcome::Emulated),
2122 unavailable: outcomes.with(Outcome::Unavailable),
2123 pages_fetched: pages,
2124 }
2125}
2126
2127fn merge_plans(plans: Vec<SourcePlan>) -> Vec<SourcePlan> {
2132 let mut merged: Vec<SourcePlan> = Vec::new();
2133 for plan in plans {
2134 if let Some(existing) = merged
2135 .iter_mut()
2136 .find(|existing| existing.source == plan.source)
2137 {
2138 existing.pushed_down.extend(plan.pushed_down);
2139 existing.applied_locally.extend(plan.applied_locally);
2140 existing.emulated.extend(plan.emulated);
2141 existing.unavailable.extend(plan.unavailable);
2142 existing.pages_fetched = existing.pages_fetched.saturating_add(plan.pages_fetched);
2143 for list in [
2144 &mut existing.pushed_down,
2145 &mut existing.applied_locally,
2146 &mut existing.emulated,
2147 &mut existing.unavailable,
2148 ] {
2149 list.sort_unstable();
2150 list.dedup();
2151 }
2152 } else {
2153 merged.push(plan);
2154 }
2155 }
2156 merged
2157}
2158
2159fn walking<'a>(
2165 selected: Vec<&'a ResolvedSource>,
2166 states: &Option<Resumption>,
2167 kind: StreamKind,
2168) -> (Vec<&'a ResolvedSource>, Vec<Resume>) {
2169 let mut ready = Vec::new();
2170 let mut starts = Vec::new();
2171 for source in selected {
2172 if let Some(resume) = resume_at(states, source.name(), kind) {
2173 ready.push(source);
2174 starts.push(resume);
2175 }
2176 }
2177 (ready, starts)
2178}
2179
2180fn shape(verb: &str, sources: &[SourceName], filters: &impl std::fmt::Debug) -> String {
2198 let names: Vec<&str> = sources.iter().map(SourceName::as_str).collect();
2199 fingerprint(&format!("{verb}|{names:?}|{filters:?}"))
2200}
2201
2202fn fingerprint(text: &str) -> String {
2204 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2205 for byte in text.as_bytes() {
2206 hash ^= u64::from(*byte);
2207 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2208 }
2209 format!("{hash:016x}")
2210}
2211
2212fn owed(document: &Option<Resumption>) -> Option<&Owed> {
2217 document.as_ref()?.owed.as_ref()
2218}
2219
2220fn resume_at(states: &Option<Resumption>, source: &SourceName, kind: StreamKind) -> Option<Resume> {
2222 match states {
2223 None => Some(Resume::default()),
2224 Some(document) => document
2225 .streams
2226 .iter()
2227 .find(|state| &state.source == source && state.stream == kind)
2228 .map(|state| state.resume.clone()),
2229 }
2230}
2231
2232fn resumption(
2263 engine: &Engine,
2264 token: Option<&PageToken>,
2265 reads: &[StreamKind],
2266 query: &str,
2267) -> Result<Option<Resumption>, EngineError> {
2268 let Some(document) = token.map(PageToken::decode) else {
2269 return Ok(None);
2270 };
2271
2272 if document.query != query {
2279 return Err(EngineError::Token {
2280 message: "this page token was written by a different query — resume the walk it \
2281 came from, or drop --page to start this one from the beginning"
2282 .to_owned(),
2283 });
2284 }
2285 let states = &document.streams;
2286
2287 let mut seen: Vec<(&SourceName, StreamKind)> = Vec::new();
2288 for state in states {
2289 if !reads.contains(&state.stream) {
2290 return Err(EngineError::Token {
2291 message: format!(
2292 "this page token resumes {}, which this command does not read — it \
2293 was written by a different query",
2294 state.stream.describe()
2295 ),
2296 });
2297 }
2298 let ceiling = engine
2299 .ready()
2300 .find(|source| source.name() == &state.source)
2301 .map(ceiling);
2302 if ceiling.is_none() && !engine.has(&state.source) {
2303 return Err(EngineError::Token {
2304 message: format!(
2305 "this page token resumes a source called {:?}, which this \
2306 configuration does not have",
2307 state.source.as_str()
2308 ),
2309 });
2310 }
2311 if let Some(ceiling) = ceiling
2312 && state.resume.skip >= ceiling
2313 {
2314 return Err(EngineError::Token {
2315 message: format!(
2316 "this page token resumes {} rows into a page of source {:?}, which \
2317 serves at most {ceiling}",
2318 state.resume.skip,
2319 state.source.as_str()
2320 ),
2321 });
2322 }
2323 if seen.contains(&(&state.source, state.stream)) {
2324 return Err(EngineError::Token {
2325 message: format!(
2326 "this page token gives source {:?} two places to resume from",
2327 state.source.as_str()
2328 ),
2329 });
2330 }
2331 seen.push((&state.source, state.stream));
2332 }
2333
2334 if let Some(owed) = &document.owed
2339 && !document
2340 .streams
2341 .iter()
2342 .any(|state| state.source == owed.source && state.stream == owed.stream)
2343 {
2344 return Err(EngineError::Token {
2345 message: format!(
2346 "this page token owes the next row to a stream it does not resume, \
2347 {:?}'s {}",
2348 owed.source.as_str(),
2349 owed.stream.describe()
2350 ),
2351 });
2352 }
2353
2354 Ok(Some(document))
2355}
2356
2357fn project_filter(selector: &ProjectSelector) -> ProjectFilter {
2363 match selector {
2364 ProjectSelector::Any => ProjectFilter::Any,
2365 ProjectSelector::Orphans => ProjectFilter::Orphans,
2366 ProjectSelector::Native(id) => ProjectFilter::Is(id.clone()),
2367 ProjectSelector::Qualified(id) => ProjectFilter::Is(id.native.clone()),
2368 }
2369}
2370
2371fn text_predicates(fields: TextFields) -> Vec<Predicate> {
2373 match fields {
2374 TextFields::Title => vec![Predicate::SearchTitle],
2375 TextFields::Content => vec![Predicate::SearchContent],
2376 TextFields::TitleOrContent => vec![Predicate::SearchTitle, Predicate::SearchContent],
2377 }
2378}
2379
2380fn searches_natively(capabilities: &Capabilities, fields: TextFields) -> bool {
2387 match fields {
2388 TextFields::Title => capabilities.search_title.is_native(),
2389 TextFields::Content => capabilities.search_content.is_native(),
2390 TextFields::TitleOrContent => {
2391 capabilities.search_title.is_native() && capabilities.search_content.is_native()
2392 }
2393 }
2394}
2395
2396fn shape_tasks(
2398 capabilities: &Capabilities,
2399 filters: &Filters,
2400 project: &ProjectFilter,
2401 priorities: &[Priority],
2402 commented_since: Option<DateTime<Utc>>,
2403 metadata: &[MetadataMatch],
2404 origin: Option<&GlobalId>,
2405) -> TaskShape {
2406 let mut pushed = TaskQuery::default();
2407 let mut local = LocalTasks::default();
2408 let mut outcomes = Outcomes::default();
2409
2410 if !filters.labels.is_empty() {
2411 if capabilities.filter_by_label.is_native() {
2412 pushed.labels = filters.labels.clone();
2413 outcomes.record(Predicate::Label, Outcome::PushedDown);
2414 } else {
2415 local.labels = Some(filters.labels.clone());
2416 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
2417 }
2418 }
2419 if !filters.statuses.is_empty() {
2420 if capabilities.filter_by_status.is_native() {
2421 pushed.statuses.clone_from(&filters.statuses);
2422 outcomes.record(Predicate::Status, Outcome::PushedDown);
2423 } else {
2424 local.statuses.clone_from(&filters.statuses);
2425 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
2426 }
2427 }
2428 if !priorities.is_empty() {
2429 if capabilities.filter_by_priority.is_native() {
2430 pushed.priorities = priorities.to_vec();
2431 outcomes.record(Predicate::Priority, Outcome::PushedDown);
2432 } else {
2433 local.priorities = priorities.to_vec();
2434 outcomes.record(Predicate::Priority, Outcome::AppliedLocally);
2435 }
2436 }
2437 if let Some(since) = commented_since {
2438 if capabilities.filter_by_comment_activity.is_native() {
2439 pushed.commented_since = Some(since);
2440 outcomes.record(Predicate::CommentedSince, Outcome::PushedDown);
2441 } else {
2442 local.commented_since = Some(since);
2443 outcomes.record(Predicate::CommentedSince, Outcome::AppliedLocally);
2444 }
2445 }
2446 if !metadata.is_empty() {
2447 if capabilities.filter_by_metadata.is_native() {
2448 pushed.metadata = metadata.to_vec();
2449 outcomes.record(Predicate::Metadata, Outcome::PushedDown);
2450 } else {
2451 local.metadata = metadata.to_vec();
2452 outcomes.record(Predicate::Metadata, Outcome::AppliedLocally);
2453 }
2454 }
2455 if let Some(origin) = origin {
2456 if capabilities.filter_by_origin.is_native() {
2457 pushed.origin = Some(origin.to_string());
2460 outcomes.record(Predicate::Origin, Outcome::PushedDown);
2461 } else {
2462 local.origin = Some(origin.clone());
2463 outcomes.record(Predicate::Origin, Outcome::AppliedLocally);
2464 }
2465 }
2466 if let Some(text) = &filters.text {
2467 let predicates = text_predicates(text.fields);
2468 if searches_natively(capabilities, text.fields) {
2469 pushed.text = Some(text.clone());
2470 outcomes.record_all(predicates, Outcome::PushedDown);
2471 } else {
2472 local.text = Some(text.clone());
2473 outcomes.record_all(predicates, Outcome::AppliedLocally);
2474 }
2475 }
2476 match project {
2477 ProjectFilter::Any => {}
2478 ProjectFilter::Orphans => {
2479 if capabilities.orphan_tasks.is_native() {
2480 pushed.project = ProjectFilter::Orphans;
2481 outcomes.record(Predicate::Project, Outcome::PushedDown);
2482 } else {
2483 local.project = Some(ProjectFilter::Orphans);
2484 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
2485 }
2486 }
2487 ProjectFilter::Is(id) => {
2488 if capabilities.projects.is_native() {
2489 pushed.project = ProjectFilter::Is(id.clone());
2490 outcomes.record(Predicate::Project, Outcome::PushedDown);
2491 } else {
2492 local.project = Some(ProjectFilter::Is(id.clone()));
2493 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
2494 }
2495 }
2496 }
2497
2498 TaskShape {
2499 pushed,
2500 local,
2501 outcomes,
2502 }
2503}
2504
2505fn shape_projects(capabilities: &Capabilities, filters: &Filters) -> ProjectShape {
2507 let mut pushed = ProjectQuery::default();
2508 let mut local = LocalProjects::default();
2509 let mut outcomes = Outcomes::default();
2510
2511 if !filters.labels.is_empty() {
2512 if capabilities.filter_by_label.is_native() {
2513 pushed.labels = filters.labels.clone();
2514 outcomes.record(Predicate::Label, Outcome::PushedDown);
2515 } else {
2516 local.labels = Some(filters.labels.clone());
2517 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
2518 }
2519 }
2520 if !filters.statuses.is_empty() {
2521 if capabilities.filter_by_status.is_native() {
2522 pushed.statuses.clone_from(&filters.statuses);
2523 outcomes.record(Predicate::Status, Outcome::PushedDown);
2524 } else {
2525 local.statuses.clone_from(&filters.statuses);
2526 outcomes.record(Predicate::Status, Outcome::AppliedLocally);
2527 }
2528 }
2529 if let Some(text) = &filters.text {
2530 let predicates = text_predicates(text.fields);
2531 if searches_natively(capabilities, text.fields) {
2532 pushed.text = Some(text.clone());
2533 outcomes.record_all(predicates, Outcome::PushedDown);
2534 } else {
2535 local.text = Some(text.clone());
2536 outcomes.record_all(predicates, Outcome::AppliedLocally);
2537 }
2538 }
2539
2540 ProjectShape {
2541 pushed,
2542 local,
2543 outcomes,
2544 }
2545}
2546
2547fn shape_documents(
2552 capabilities: &Capabilities,
2553 filters: &DocumentFilters,
2554 project: &ProjectFilter,
2555) -> DocumentShape {
2556 let mut pushed = DocumentQuery::default();
2557 let mut local = LocalDocuments::default();
2558 let mut outcomes = Outcomes::default();
2559
2560 if !filters.labels.is_empty() {
2561 if capabilities.filter_by_label.is_native() {
2562 pushed.labels = filters.labels.clone();
2563 outcomes.record(Predicate::Label, Outcome::PushedDown);
2564 } else {
2565 local.labels = Some(filters.labels.clone());
2566 outcomes.record(Predicate::Label, Outcome::AppliedLocally);
2567 }
2568 }
2569 if let Some(text) = &filters.text {
2570 let predicates = text_predicates(text.fields);
2571 if searches_natively(capabilities, text.fields) {
2572 pushed.text = Some(text.clone());
2573 outcomes.record_all(predicates, Outcome::PushedDown);
2574 } else {
2575 local.text = Some(text.clone());
2576 outcomes.record_all(predicates, Outcome::AppliedLocally);
2577 }
2578 }
2579 match project {
2580 ProjectFilter::Any => {}
2581 ProjectFilter::Orphans => {
2582 if capabilities.orphan_tasks.is_native() {
2583 pushed.project = ProjectFilter::Orphans;
2584 outcomes.record(Predicate::Project, Outcome::PushedDown);
2585 } else {
2586 local.project = Some(ProjectFilter::Orphans);
2587 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
2588 }
2589 }
2590 ProjectFilter::Is(id) => {
2591 if capabilities.projects.is_native() {
2592 pushed.project = ProjectFilter::Is(id.clone());
2593 outcomes.record(Predicate::Project, Outcome::PushedDown);
2594 } else {
2595 local.project = Some(ProjectFilter::Is(id.clone()));
2596 outcomes.record(Predicate::Project, Outcome::AppliedLocally);
2597 }
2598 }
2599 }
2600
2601 DocumentShape {
2602 pushed,
2603 local,
2604 outcomes,
2605 }
2606}
2607
2608fn shape_hits(capabilities: &Capabilities, filters: &Filters, stream: StreamKind) -> HitShape {
2610 match stream {
2611 StreamKind::Projects => {
2612 let shaped = shape_projects(capabilities, filters);
2613 HitShape {
2614 stream,
2615 tasks: TaskQuery::default(),
2616 projects: shaped.pushed,
2617 local_tasks: LocalTasks::default(),
2618 local_projects: shaped.local,
2619 outcomes: shaped.outcomes,
2620 }
2621 }
2622 StreamKind::Items | StreamKind::Tasks => {
2623 let shaped = shape_tasks(
2624 capabilities,
2625 filters,
2626 &ProjectFilter::Any,
2627 &[],
2628 None,
2629 &[],
2630 None,
2631 );
2632 HitShape {
2633 stream,
2634 tasks: shaped.pushed,
2635 projects: ProjectQuery::default(),
2636 local_tasks: shaped.local,
2637 local_projects: LocalProjects::default(),
2638 outcomes: shaped.outcomes,
2639 }
2640 }
2641 }
2642}
2643
2644fn page_size(compensating: bool, budget: u32, ceiling: u32) -> u32 {
2651 if compensating {
2652 ceiling
2653 } else {
2654 budget.min(ceiling)
2655 }
2656}
2657
2658fn ceiling(source: &ResolvedSource) -> u32 {
2660 source.source().capabilities().max_page_size.max(1)
2661}
2662
2663async fn fetch_tasks(
2665 source: &ResolvedSource,
2666 shape: &TaskShape,
2667 start: &Resume,
2668 budget: u32,
2669 calls: &AtomicU32,
2670) -> Result<Fetched<Task>, SourceError> {
2671 let compensating = shape.local != LocalTasks::default();
2672 walk(
2673 start,
2674 budget,
2675 page_size(compensating, budget, ceiling(source)),
2676 |task| shape.local.keeps(task),
2677 |cursor, limit| async move {
2678 calls.fetch_add(1, Ordering::Relaxed);
2679 let request = PageRequest { cursor, limit };
2680 let page = source.source().query_tasks(&shape.pushed, &request).await?;
2681 match shape.local.commented_since {
2682 None => Ok(page),
2683 Some(since) => comment::commented_since(source, &shape.local, page, since).await,
2684 }
2685 },
2686 )
2687 .await
2688}
2689
2690async fn fetch_projects(
2692 source: &ResolvedSource,
2693 shape: &ProjectShape,
2694 start: &Resume,
2695 budget: u32,
2696 calls: &AtomicU32,
2697) -> Result<Fetched<Project>, SourceError> {
2698 let compensating = shape.local != LocalProjects::default();
2699 walk(
2700 start,
2701 budget,
2702 page_size(compensating, budget, ceiling(source)),
2703 |project| shape.local.keeps(project),
2704 |cursor, limit| async move {
2705 calls.fetch_add(1, Ordering::Relaxed);
2706 let request = PageRequest { cursor, limit };
2707 source
2708 .source()
2709 .query_projects(&shape.pushed, &request)
2710 .await
2711 },
2712 )
2713 .await
2714}
2715
2716async fn fetch_documents(
2718 source: &ResolvedSource,
2719 shape: &DocumentShape,
2720 start: &Resume,
2721 budget: u32,
2722 calls: &AtomicU32,
2723) -> Result<Fetched<Document>, SourceError> {
2724 let compensating = shape.local != LocalDocuments::default();
2725 walk(
2726 start,
2727 budget,
2728 page_size(compensating, budget, ceiling(source)),
2729 |document| shape.local.keeps(document),
2730 |cursor, limit| async move {
2731 calls.fetch_add(1, Ordering::Relaxed);
2732 let request = PageRequest { cursor, limit };
2733 source
2734 .source()
2735 .query_documents(&shape.pushed, &request)
2736 .await
2737 },
2738 )
2739 .await
2740}
2741
2742async fn fetch_labels(
2744 source: &ResolvedSource,
2745 start: &Resume,
2746 budget: u32,
2747 calls: &AtomicU32,
2748) -> Result<Fetched<Label>, SourceError> {
2749 walk(
2750 start,
2751 budget,
2752 page_size(false, budget, ceiling(source)),
2753 |_| true,
2754 |cursor, limit| async move {
2755 calls.fetch_add(1, Ordering::Relaxed);
2756 let request = PageRequest { cursor, limit };
2757 source.source().labels(&request).await
2758 },
2759 )
2760 .await
2761}
2762
2763async fn fetch_hits(
2765 source: &ResolvedSource,
2766 shape: &HitShape,
2767 start: &Resume,
2768 budget: u32,
2769 calls: &AtomicU32,
2770) -> Result<Fetched<Found>, SourceError> {
2771 let ceiling = ceiling(source);
2772 match shape.stream {
2773 StreamKind::Projects => {
2774 let compensating = shape.local_projects != LocalProjects::default();
2775 walk(
2776 start,
2777 budget,
2778 page_size(compensating, budget, ceiling),
2779 |found| match found {
2780 Found::Project(project) => shape.local_projects.keeps(project),
2781 Found::Task(_) => true,
2782 },
2783 |cursor, limit| async move {
2784 calls.fetch_add(1, Ordering::Relaxed);
2785 let request = PageRequest { cursor, limit };
2786 let page = source
2787 .source()
2788 .query_projects(&shape.projects, &request)
2789 .await?;
2790 Ok(Page {
2791 items: page.items.into_iter().map(Found::Project).collect(),
2792 next: page.next,
2793 })
2794 },
2795 )
2796 .await
2797 }
2798 StreamKind::Items | StreamKind::Tasks => {
2799 let compensating = shape.local_tasks != LocalTasks::default();
2800 walk(
2801 start,
2802 budget,
2803 page_size(compensating, budget, ceiling),
2804 |found| match found {
2805 Found::Task(task) => shape.local_tasks.keeps(task),
2806 Found::Project(_) => true,
2807 },
2808 |cursor, limit| async move {
2809 calls.fetch_add(1, Ordering::Relaxed);
2810 let request = PageRequest { cursor, limit };
2811 let page = source.source().query_tasks(&shape.tasks, &request).await?;
2812 Ok(Page {
2813 items: page.items.into_iter().map(Found::Task).collect(),
2814 next: page.next,
2815 })
2816 },
2817 )
2818 .await
2819 }
2820 }
2821}
2822
2823async fn forward_edges(
2825 source: &ResolvedSource,
2826 entity: Entity,
2827 id: &NativeId,
2828 request: &PageRequest,
2829) -> Result<Page<DependencyEdge>, SourceError> {
2830 match entity {
2831 Entity::Task => {
2832 source
2833 .source()
2834 .task_dependencies(id, Direction::DependsOn, request)
2835 .await
2836 }
2837 Entity::Project => {
2838 source
2839 .source()
2840 .project_dependencies(id, Direction::DependsOn, request)
2841 .await
2842 }
2843 }
2844}
2845
2846#[expect(
2855 clippy::too_many_arguments,
2856 reason = "every argument is one axis of one walk — the source, the item, the \
2857 direction, which of its two graphs, whether the reverse is emulated, where \
2858 to resume, how many rows to return and where to count calls. Grouping them \
2859 into a struct would name the same eight values one indirection further from \
2860 the loop that reads them."
2861)]
2862async fn fetch_edges(
2863 source: &ResolvedSource,
2864 native: &NativeId,
2865 direction: Direction,
2866 entity: Entity,
2867 emulating: bool,
2868 start: &Resume,
2869 budget: u32,
2870 calls: &AtomicU32,
2871) -> Result<Fetched<DependencyEdge>, SourceError> {
2872 let ceiling = ceiling(source);
2873 if !emulating {
2874 return walk(
2875 start,
2876 budget,
2877 page_size(false, budget, ceiling),
2878 |_| true,
2879 |cursor, limit| async move {
2880 calls.fetch_add(1, Ordering::Relaxed);
2881 let request = PageRequest { cursor, limit };
2882 match entity {
2883 Entity::Task => {
2884 source
2885 .source()
2886 .task_dependencies(native, direction, &request)
2887 .await
2888 }
2889 Entity::Project => {
2890 source
2891 .source()
2892 .project_dependencies(native, direction, &request)
2893 .await
2894 }
2895 }
2896 },
2897 )
2898 .await;
2899 }
2900
2901 walk(
2902 start,
2903 budget,
2904 ceiling,
2905 |_| true,
2906 |cursor, limit| async move {
2907 calls.fetch_add(1, Ordering::Relaxed);
2908 let request = PageRequest { cursor, limit };
2909 let (ids, next) = match entity {
2910 Entity::Task => {
2911 let page = source
2912 .source()
2913 .query_tasks(&TaskQuery::default(), &request)
2914 .await?;
2915 let ids: Vec<NativeId> = page.items.into_iter().map(|task| task.id).collect();
2916 (ids, page.next)
2917 }
2918 Entity::Project => {
2919 let page = source
2920 .source()
2921 .query_projects(&ProjectQuery::default(), &request)
2922 .await?;
2923 let ids: Vec<NativeId> =
2924 page.items.into_iter().map(|project| project.id).collect();
2925 (ids, page.next)
2926 }
2927 };
2928
2929 let mut edges = Vec::new();
2930 for id in ids {
2931 let mut inner: Option<Cursor> = None;
2932 loop {
2933 calls.fetch_add(1, Ordering::Relaxed);
2934 let request = PageRequest {
2935 cursor: inner.clone(),
2936 limit,
2937 };
2938 let page = forward_edges(source, entity, &id, &request).await?;
2939 fits(page.items.len(), limit)?;
2943 edges.extend(page.items.into_iter().filter(|edge| &edge.to == native));
2944 unrepeated(
2945 page.next.as_ref(),
2946 inner.as_ref(),
2947 "its forward edges were being scanned",
2948 )?;
2949 match page.next {
2950 Some(cursor) => inner = Some(cursor),
2951 None => break,
2952 }
2953 }
2954 }
2955
2956 Ok(Page { items: edges, next })
2957 },
2958 )
2959 .await
2960}