Skip to main content

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}