Skip to main content

onetaskgraph_plugin_api/
query.rs

1//! What a caller asks a source for, and how a source hands back more than fits
2//! in one answer.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
6
7use crate::{NativeId, StatusCategory};
8
9/// A filter over a source's tasks.
10///
11/// Every field narrows; an empty or `None` field means unfiltered.
12#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
13pub struct TaskQuery {
14    /// Free-text search, when the caller asked for one.
15    pub text: Option<TextQuery>,
16    /// Label membership.
17    pub labels: LabelFilter,
18    /// Status categories to keep. Empty means unfiltered.
19    pub statuses: Vec<StatusCategory>,
20    /// Which project the task belongs to.
21    pub project: ProjectFilter,
22}
23
24/// A filter over a source's projects.
25#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
26pub struct ProjectQuery {
27    /// Free-text search, when the caller asked for one.
28    pub text: Option<TextQuery>,
29    /// Label membership.
30    pub labels: LabelFilter,
31    /// Status categories to keep. Empty means unfiltered.
32    pub statuses: Vec<StatusCategory>,
33}
34
35/// A filter over a source's documents.
36///
37/// No statuses, deliberately: a [`Document`](crate::Document) is not work and carries no
38/// status, so there is nothing here for a status filter to compare against.
39#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
40pub struct DocumentQuery {
41    /// Free-text search, when the caller asked for one.
42    pub text: Option<TextQuery>,
43    /// Label membership.
44    pub labels: LabelFilter,
45    /// Which project the document lives in.
46    pub project: ProjectFilter,
47}
48
49/// A free-text search and the fields it searches.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub struct TextQuery {
52    /// What the user typed.
53    pub terms: String,
54    /// Where to look for it.
55    pub fields: TextFields,
56}
57
58/// Which fields a [`TextQuery`] searches.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
60#[serde(rename_all = "kebab-case")]
61pub enum TextFields {
62    /// Titles only.
63    Title,
64    /// Bodies only.
65    Content,
66    /// Either one matching is a match.
67    TitleOrContent,
68}
69
70/// Label membership, by **name** rather than by id.
71///
72/// A label id is per-source; a user filtering across sources types a word. Names
73/// are matched case-insensitively for the same reason.
74#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
75pub struct LabelFilter {
76    /// Keep an item carrying at least one of these.
77    pub any_of: Vec<String>,
78    /// Keep an item carrying all of these.
79    pub all_of: Vec<String>,
80    /// Drop an item carrying any of these.
81    pub none_of: Vec<String>,
82}
83
84impl LabelFilter {
85    /// Whether this filter constrains anything at all.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.any_of.is_empty() && self.all_of.is_empty() && self.none_of.is_empty()
89    }
90}
91
92/// Which project a task must belong to.
93#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "kebab-case")]
95pub enum ProjectFilter {
96    /// No constraint.
97    #[default]
98    Any,
99    /// Only tasks belonging to no project.
100    Orphans,
101    /// Only tasks belonging to this project.
102    Is(NativeId),
103}
104
105/// One step of a walk through a result set.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
107pub struct PageRequest {
108    /// Where to resume, or `None` to start at the beginning.
109    pub cursor: Option<Cursor>,
110    /// The most items to return, at least 1. A source may return fewer, never more.
111    #[serde(deserialize_with = "non_zero_limit")]
112    // llmlint: ignore[invalid_states_unrepresentable] this field's wire shape is frozen by the plugin contract every source is written against; only the contract's owner may change it, and tightening it is post-build follow-up.
113    pub limit: u32,
114}
115
116/// Reject a zero page size where a request is read, so an ask for no rows never reaches a
117/// source as if it were an ask for one.
118fn non_zero_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
119    let value = u32::deserialize(deserializer)?;
120    if value == 0 {
121        return Err(D::Error::custom(
122            "limit must be at least 1; a page of no rows is not a page",
123        ));
124    }
125    Ok(value)
126}
127
128/// One page of results, and where to pick up.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
130pub struct Page<T> {
131    /// This page's items, in the source's stable order.
132    pub items: Vec<T>,
133    /// The cursor for the next page, or `None` when the walk is exhausted.
134    pub next: Option<Cursor>,
135}
136
137impl<T> Page<T> {
138    /// The last page of a walk: these items and nothing after them.
139    #[must_use]
140    pub fn last(items: Vec<T>) -> Self {
141        Self { items, next: None }
142    }
143}
144
145/// A plugin-defined resume token. The engine stores and returns one; it never
146/// interprets one.
147#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
148#[serde(transparent)]
149pub struct Cursor(pub String);