onetaskgraph_core/engine/local.rs
1//! The predicates the engine applies itself, over the wider set a source returned.
2//!
3//! Every function here runs only for a predicate the source declared
4//! [`Unsupported`](onetaskgraph_plugin_api::Support::Unsupported), which is the engine's
5//! side of the contract — rule 3. What makes narrowing here sound is the source's side,
6//! rule 2: a source *ignores* such a predicate and returns the wider set. So the rows
7//! this drops are rows the caller asked not to see, and the rows it keeps are all the
8//! rows there were. A source that narrowed for a predicate it declared unsupported would
9//! break rule 2, and nothing above the plugin could tell.
10//!
11//! This is not a copy of a plugin's filtering for the sake of it. A source that applies
12//! a predicate natively never reaches this code, and a plugin's own evaluation is behind
13//! its own crate boundary — the engine may not reach into one, and the moment it did,
14//! the answer would depend on which plugin happened to be first in the list.
15
16use onetaskgraph_plugin_api::{
17 Document, Label, LabelFilter, NativeId, Priority, Project, ProjectFilter, StatusCategory, Task,
18 TextFields, TextQuery,
19};
20
21/// The task predicates this source left to the engine.
22///
23/// Each field is `None`/empty when the source applied that predicate itself, so a
24/// filter built from a fully native source keeps everything and costs one comparison
25/// per row.
26#[derive(Debug, Clone, Default, PartialEq)]
27pub(crate) struct LocalTasks {
28 /// Label membership, when the source does not filter by label.
29 pub labels: Option<LabelFilter>,
30 /// Status categories, when the source does not filter by status.
31 pub statuses: Vec<StatusCategory>,
32 /// Free text, when the source does not search every field the query names.
33 pub text: Option<TextQuery>,
34 /// The owning project, when the source does not filter by it.
35 pub project: Option<ProjectFilter>,
36 /// Priorities to keep, when the source does not filter by priority.
37 pub priorities: Vec<Priority>,
38}
39
40impl LocalTasks {
41 /// Whether `task` survives every predicate left to the engine.
42 pub fn keeps(&self, task: &Task) -> bool {
43 if let Some(filter) = &self.labels
44 && !labels_match(&task.labels, filter)
45 {
46 return false;
47 }
48 if !status_matches(task.status.category, &self.statuses) {
49 return false;
50 }
51 if !self.priorities.is_empty() && !self.priorities.contains(&task.priority) {
52 return false;
53 }
54 if let Some(query) = &self.text
55 && !text_matches(&task.title, task.content.as_deref(), query)
56 {
57 return false;
58 }
59 match &self.project {
60 None | Some(ProjectFilter::Any) => true,
61 Some(ProjectFilter::Orphans) => task.project.is_none(),
62 Some(ProjectFilter::Is(id)) => task.project.as_ref() == Some(id),
63 }
64 }
65}
66
67/// The project predicates this source left to the engine.
68#[derive(Debug, Clone, Default, PartialEq)]
69pub(crate) struct LocalProjects {
70 /// Label membership, when the source does not filter by label.
71 pub labels: Option<LabelFilter>,
72 /// Status categories, when the source does not filter by status.
73 pub statuses: Vec<StatusCategory>,
74 /// Free text, when the source does not search every field the query names.
75 pub text: Option<TextQuery>,
76}
77
78impl LocalProjects {
79 /// Whether `project` survives every predicate left to the engine.
80 pub fn keeps(&self, project: &Project) -> bool {
81 if let Some(filter) = &self.labels
82 && !labels_match(&project.labels, filter)
83 {
84 return false;
85 }
86 if !status_matches(project.status.category, &self.statuses) {
87 return false;
88 }
89 match &self.text {
90 None => true,
91 Some(query) => text_matches(&project.title, project.content.as_deref(), query),
92 }
93 }
94}
95
96/// The document predicates this source left to the engine.
97///
98/// No statuses, deliberately: a document is not work and carries none, so a status filter
99/// has nothing here to compare against.
100#[derive(Debug, Clone, Default, PartialEq)]
101pub(crate) struct LocalDocuments {
102 /// Label membership, when the source does not filter by label.
103 pub labels: Option<LabelFilter>,
104 /// Free text, when the source does not search every field the query names.
105 pub text: Option<TextQuery>,
106 /// The owning project, when the source does not filter by it.
107 pub project: Option<ProjectFilter>,
108}
109
110impl LocalDocuments {
111 /// Whether `document` survives every predicate left to the engine.
112 pub fn keeps(&self, document: &Document) -> bool {
113 if let Some(filter) = &self.labels
114 && !labels_match(&document.labels, filter)
115 {
116 return false;
117 }
118 if let Some(query) = &self.text
119 && !text_matches(&document.title, document.content.as_deref(), query)
120 {
121 return false;
122 }
123 match &self.project {
124 None | Some(ProjectFilter::Any) => true,
125 Some(ProjectFilter::Orphans) => document.project.is_none(),
126 Some(ProjectFilter::Is(id)) => document.project.as_ref() == Some(id),
127 }
128 }
129}
130
131/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
132///
133/// By name rather than by id because a label id is per-source and a user filtering
134/// across sources types a word — the reason [`LabelFilter`] is spelled in names at all.
135pub(crate) fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
136 let held: Vec<String> = labels
137 .iter()
138 .map(|label| label.name.to_lowercase())
139 .collect();
140 let holds = |name: &String| held.contains(&name.to_lowercase());
141
142 if !filter.any_of.is_empty() && !filter.any_of.iter().any(holds) {
143 return false;
144 }
145 if !filter.all_of.iter().all(holds) {
146 return false;
147 }
148 !filter.none_of.iter().any(holds)
149}
150
151/// An empty list is unfiltered rather than "keeps nothing", which is what makes
152/// `statuses: Vec<StatusCategory>` able to spell "no status filter at all".
153pub(crate) fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
154 statuses.is_empty() || statuses.contains(&category)
155}
156
157/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
158pub(crate) fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
159 let terms = query.terms.to_lowercase();
160 let in_title = title.to_lowercase().contains(&terms);
161 let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
162 match query.fields {
163 TextFields::Title => in_title,
164 TextFields::Content => in_content,
165 TextFields::TitleOrContent => in_title || in_content,
166 }
167}
168
169/// Which project a task must belong to, as a command line names it.
170///
171/// Qualified (`work:PROJ-1`) or bare (`PROJ-1`), because both are things a user types
172/// and they mean different queries: a qualified id names one project of one source and
173/// restricts the query to it, while a bare one is a native id every selected source is
174/// asked about. Which of the two a string is depends on whether its prefix names a
175/// **configured source**, so a native id full of colons is still a native id.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub enum ProjectSelector {
178 /// No constraint.
179 #[default]
180 Any,
181 /// Only tasks belonging to no project at all.
182 Orphans,
183 /// One project of one source.
184 Qualified(crate::GlobalId),
185 /// A native id, asked of every selected source.
186 Native(NativeId),
187}