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);