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