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