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 onetaskgraph_plugin_api::{
17    Label, LabelFilter, NativeId, Project, ProjectFilter, StatusCategory, Task, TextFields,
18    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}
37
38impl LocalTasks {
39    /// Whether `task` survives every predicate left to the engine.
40    pub fn keeps(&self, task: &Task) -> bool {
41        if let Some(filter) = &self.labels
42            && !labels_match(&task.labels, filter)
43        {
44            return false;
45        }
46        if !status_matches(task.status.category, &self.statuses) {
47            return false;
48        }
49        if let Some(query) = &self.text
50            && !text_matches(&task.title, task.content.as_deref(), query)
51        {
52            return false;
53        }
54        match &self.project {
55            None | Some(ProjectFilter::Any) => true,
56            Some(ProjectFilter::Orphans) => task.project.is_none(),
57            Some(ProjectFilter::Is(id)) => task.project.as_ref() == Some(id),
58        }
59    }
60}
61
62/// The project predicates this source left to the engine.
63#[derive(Debug, Clone, Default, PartialEq)]
64pub(crate) struct LocalProjects {
65    /// Label membership, when the source does not filter by label.
66    pub labels: Option<LabelFilter>,
67    /// Status categories, when the source does not filter by status.
68    pub statuses: Vec<StatusCategory>,
69    /// Free text, when the source does not search every field the query names.
70    pub text: Option<TextQuery>,
71}
72
73impl LocalProjects {
74    /// Whether `project` survives every predicate left to the engine.
75    pub fn keeps(&self, project: &Project) -> bool {
76        if let Some(filter) = &self.labels
77            && !labels_match(&project.labels, filter)
78        {
79            return false;
80        }
81        if !status_matches(project.status.category, &self.statuses) {
82            return false;
83        }
84        match &self.text {
85            None => true,
86            Some(query) => text_matches(&project.title, project.content.as_deref(), query),
87        }
88    }
89}
90
91/// Whether `labels` satisfies `filter`, matching by name, case-insensitively.
92///
93/// By name rather than by id because a label id is per-source and a user filtering
94/// across sources types a word — the reason [`LabelFilter`] is spelled in names at all.
95pub(crate) fn labels_match(labels: &[Label], filter: &LabelFilter) -> bool {
96    let held: Vec<String> = labels
97        .iter()
98        .map(|label| label.name.to_lowercase())
99        .collect();
100    let holds = |name: &String| held.contains(&name.to_lowercase());
101
102    if !filter.any_of.is_empty() && !filter.any_of.iter().any(holds) {
103        return false;
104    }
105    if !filter.all_of.iter().all(holds) {
106        return false;
107    }
108    !filter.none_of.iter().any(holds)
109}
110
111/// An empty list is unfiltered rather than "keeps nothing", which is what makes
112/// `statuses: Vec<StatusCategory>` able to spell "no status filter at all".
113pub(crate) fn status_matches(category: StatusCategory, statuses: &[StatusCategory]) -> bool {
114    statuses.is_empty() || statuses.contains(&category)
115}
116
117/// Whether `title`/`content` satisfies `query`, matching case-insensitively.
118pub(crate) fn text_matches(title: &str, content: Option<&str>, query: &TextQuery) -> bool {
119    let terms = query.terms.to_lowercase();
120    let in_title = title.to_lowercase().contains(&terms);
121    let in_content = content.is_some_and(|body| body.to_lowercase().contains(&terms));
122    match query.fields {
123        TextFields::Title => in_title,
124        TextFields::Content => in_content,
125        TextFields::TitleOrContent => in_title || in_content,
126    }
127}
128
129/// Which project a task must belong to, as a command line names it.
130///
131/// Qualified (`work:PROJ-1`) or bare (`PROJ-1`), because both are things a user types
132/// and they mean different queries: a qualified id names one project of one source and
133/// restricts the query to it, while a bare one is a native id every selected source is
134/// asked about. Which of the two a string is depends on whether its prefix names a
135/// **configured source**, so a native id full of colons is still a native id.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub enum ProjectSelector {
138    /// No constraint.
139    #[default]
140    Any,
141    /// Only tasks belonging to no project at all.
142    Orphans,
143    /// One project of one source.
144    Qualified(crate::GlobalId),
145    /// A native id, asked of every selected source.
146    Native(NativeId),
147}