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