net/adapter/net/cortex/tasks/
query.rs1use std::collections::HashSet;
22
23pub(super) use super::super::needle::AsciiInsensitiveNeedle as TitleNeedle;
24use super::state::TasksState;
25use super::types::{Task, TaskId, TaskStatus};
26
27#[derive(Debug, Clone, Default)]
32pub(super) struct TasksFilterSpec {
33 pub status: Option<TaskStatus>,
34 pub id_in: Option<HashSet<TaskId>>,
35 pub created_after_ns: Option<u64>,
36 pub created_before_ns: Option<u64>,
37 pub updated_after_ns: Option<u64>,
38 pub updated_before_ns: Option<u64>,
39 pub title_contains: Option<TitleNeedle>,
40 pub order_by: Option<OrderBy>,
41 pub limit: Option<usize>,
42}
43
44impl TasksFilterSpec {
45 pub(super) fn matches(&self, t: &Task) -> bool {
47 if let Some(s) = self.status {
48 if t.status != s {
49 return false;
50 }
51 }
52 if let Some(ids) = &self.id_in {
53 if !ids.contains(&t.id) {
54 return false;
55 }
56 }
57 if let Some(ns) = self.created_after_ns {
66 if t.created_ns < ns {
67 return false;
68 }
69 }
70 if let Some(ns) = self.created_before_ns {
71 if t.created_ns > ns {
72 return false;
73 }
74 }
75 if let Some(ns) = self.updated_after_ns {
76 if t.updated_ns < ns {
77 return false;
78 }
79 }
80 if let Some(ns) = self.updated_before_ns {
81 if t.updated_ns > ns {
82 return false;
83 }
84 }
85 if let Some(needle) = &self.title_contains {
86 if !needle.matches(&t.title) {
87 return false;
88 }
89 }
90 true
91 }
92
93 pub(super) fn execute(&self, state: &TasksState) -> Vec<Task> {
95 let mut out: Vec<Task> = state
96 .tasks
97 .values()
98 .filter(|t| self.matches(t))
99 .cloned()
100 .collect();
101 if let Some(order) = self.order_by {
102 sort_tasks(&mut out, order);
103 }
104 if let Some(limit) = self.limit {
105 out.truncate(limit);
106 }
107 out
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum OrderBy {
114 IdAsc,
116 IdDesc,
118 CreatedAsc,
120 CreatedDesc,
122 UpdatedAsc,
124 UpdatedDesc,
126}
127
128pub struct TasksQuery<'a> {
132 state: &'a TasksState,
133 spec: TasksFilterSpec,
134}
135
136impl TasksState {
137 pub fn query(&self) -> TasksQuery<'_> {
139 TasksQuery {
140 state: self,
141 spec: TasksFilterSpec::default(),
142 }
143 }
144}
145
146impl<'a> TasksQuery<'a> {
147 pub fn where_status(mut self, status: TaskStatus) -> Self {
149 self.spec.status = Some(status);
150 self
151 }
152
153 pub fn where_id_in(mut self, ids: impl IntoIterator<Item = TaskId>) -> Self {
155 self.spec.id_in = Some(ids.into_iter().collect());
156 self
157 }
158
159 pub fn created_after(mut self, ns: u64) -> Self {
161 self.spec.created_after_ns = Some(ns);
162 self
163 }
164
165 pub fn created_before(mut self, ns: u64) -> Self {
167 self.spec.created_before_ns = Some(ns);
168 self
169 }
170
171 pub fn updated_after(mut self, ns: u64) -> Self {
173 self.spec.updated_after_ns = Some(ns);
174 self
175 }
176
177 pub fn updated_before(mut self, ns: u64) -> Self {
179 self.spec.updated_before_ns = Some(ns);
180 self
181 }
182
183 pub fn title_contains(mut self, needle: impl Into<String>) -> Self {
185 self.spec.title_contains = Some(TitleNeedle::new(needle));
186 self
187 }
188
189 pub fn order_by(mut self, order: OrderBy) -> Self {
191 self.spec.order_by = Some(order);
192 self
193 }
194
195 pub fn limit(mut self, n: usize) -> Self {
197 self.spec.limit = Some(n);
198 self
199 }
200
201 pub fn collect(self) -> Vec<Task> {
203 self.spec.execute(self.state)
204 }
205
206 pub fn count(self) -> usize {
208 self.state
209 .tasks
210 .values()
211 .filter(|t| self.spec.matches(t))
212 .count()
213 }
214
215 pub fn first(mut self) -> Option<Task> {
218 self.spec.limit = Some(1);
220 self.collect().into_iter().next()
221 }
222
223 pub fn exists(self) -> bool {
225 self.state.tasks.values().any(|t| self.spec.matches(t))
226 }
227}
228
229pub(super) fn sort_tasks(tasks: &mut [Task], order: OrderBy) {
230 match order {
231 OrderBy::IdAsc => tasks.sort_by_key(|t| t.id),
232 OrderBy::IdDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.id)),
233 OrderBy::CreatedAsc => tasks.sort_by_key(|t| t.created_ns),
234 OrderBy::CreatedDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.created_ns)),
235 OrderBy::UpdatedAsc => tasks.sort_by_key(|t| t.updated_ns),
236 OrderBy::UpdatedDesc => tasks.sort_by_key(|t| std::cmp::Reverse(t.updated_ns)),
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::super::types::{Task, TaskStatus};
243 use super::*;
244
245 fn mk(id: TaskId, title: &str, status: TaskStatus, created: u64, updated: u64) -> Task {
246 Task {
247 id,
248 title: title.to_string(),
249 status,
250 created_ns: created,
251 updated_ns: updated,
252 }
253 }
254
255 fn state_with(tasks: impl IntoIterator<Item = Task>) -> TasksState {
256 let mut s = TasksState::new();
257 for t in tasks {
258 s.tasks.insert(t.id, t);
259 }
260 s
261 }
262
263 fn sample() -> TasksState {
264 state_with([
265 mk(1, "Write plan", TaskStatus::Pending, 100, 100),
266 mk(2, "Ship adapter", TaskStatus::Completed, 200, 250),
267 mk(3, "Review PR", TaskStatus::Pending, 300, 310),
268 mk(4, "Update docs", TaskStatus::Pending, 400, 410),
269 mk(5, "Deploy v1", TaskStatus::Completed, 500, 520),
270 ])
271 }
272
273 #[test]
274 fn test_no_filters_returns_all() {
275 let s = sample();
276 assert_eq!(s.query().count(), 5);
277 }
278
279 #[test]
280 fn test_where_status_pending() {
281 let s = sample();
282 let mut ids: Vec<_> = s
283 .query()
284 .where_status(TaskStatus::Pending)
285 .collect()
286 .iter()
287 .map(|t| t.id)
288 .collect();
289 ids.sort();
290 assert_eq!(ids, vec![1, 3, 4]);
291 }
292
293 #[test]
294 fn test_where_id_in() {
295 let s = sample();
296 let mut ids: Vec<_> = s
297 .query()
298 .where_id_in([2, 4, 99])
299 .collect()
300 .iter()
301 .map(|t| t.id)
302 .collect();
303 ids.sort();
304 assert_eq!(ids, vec![2, 4]);
305 }
306
307 #[test]
308 fn test_created_after() {
309 let s = sample();
310 let mut ids: Vec<_> = s
311 .query()
312 .created_after(300)
313 .collect()
314 .iter()
315 .map(|t| t.id)
316 .collect();
317 ids.sort();
318 assert_eq!(ids, vec![3, 4, 5]);
323 }
324
325 #[test]
326 fn test_created_before() {
327 let s = sample();
328 let mut ids: Vec<_> = s
329 .query()
330 .created_before(300)
331 .collect()
332 .iter()
333 .map(|t| t.id)
334 .collect();
335 ids.sort();
336 assert_eq!(ids, vec![1, 2, 3]);
338 }
339
340 #[test]
360 fn cr20_paginate_by_last_seen_ns_re_delivers_boundary_event() {
361 let s = sample();
362
363 let page_1: Vec<_> = s.query().created_after(100).collect();
365 let last_seen = page_1
366 .iter()
367 .map(|t| t.created_ns)
368 .max()
369 .expect("non-empty result");
370
371 let page_2: Vec<_> = s.query().created_after(last_seen).collect();
374
375 let boundary_count = page_2.iter().filter(|t| t.created_ns == last_seen).count();
379 assert!(
380 boundary_count >= 1,
381 "CR-20: with inclusive `created_after`, paginating by \
382 last_seen_ns re-delivers the boundary event. The naive \
383 paginator pattern (`cutoff = last_seen_ns`) MUST advance \
384 past the boundary explicitly (e.g. `cutoff = last_seen_ns + \
385 1`) or use an id-based cursor instead. This test pins the \
386 documented behavior — fix it only if you also update the \
387 paginate-helper docs and the receiver-side dedup expectations."
388 );
389 }
390
391 #[test]
392 fn test_updated_after_and_before() {
393 let s = sample();
394 let mut ids: Vec<_> = s
395 .query()
396 .updated_after(250)
397 .updated_before(500)
398 .collect()
399 .iter()
400 .map(|t| t.id)
401 .collect();
402 ids.sort();
403 assert_eq!(ids, vec![2, 3, 4]);
407 }
408
409 #[test]
410 fn test_title_contains_case_insensitive() {
411 let s = sample();
412 let mut ids: Vec<_> = s
413 .query()
414 .title_contains("DEPLOY")
415 .collect()
416 .iter()
417 .map(|t| t.id)
418 .collect();
419 ids.sort();
420 assert_eq!(ids, vec![5]);
421
422 let ids_plural: Vec<_> = s.query().title_contains("e").collect();
423 assert_eq!(ids_plural.len(), 5);
425 }
426
427 #[test]
428 fn test_order_by_id_asc_desc() {
429 let s = sample();
430 let asc: Vec<_> = s
431 .query()
432 .order_by(OrderBy::IdAsc)
433 .collect()
434 .iter()
435 .map(|t| t.id)
436 .collect();
437 assert_eq!(asc, vec![1, 2, 3, 4, 5]);
438
439 let desc: Vec<_> = s
440 .query()
441 .order_by(OrderBy::IdDesc)
442 .collect()
443 .iter()
444 .map(|t| t.id)
445 .collect();
446 assert_eq!(desc, vec![5, 4, 3, 2, 1]);
447 }
448
449 #[test]
450 fn test_order_by_created() {
451 let s = sample();
452 let asc: Vec<_> = s
453 .query()
454 .order_by(OrderBy::CreatedAsc)
455 .collect()
456 .iter()
457 .map(|t| t.id)
458 .collect();
459 assert_eq!(asc, vec![1, 2, 3, 4, 5]);
460 }
461
462 #[test]
463 fn test_order_by_updated_desc() {
464 let s = sample();
465 let desc: Vec<_> = s
466 .query()
467 .order_by(OrderBy::UpdatedDesc)
468 .collect()
469 .iter()
470 .map(|t| t.id)
471 .collect();
472 assert_eq!(desc, vec![5, 4, 3, 2, 1]);
474 }
475
476 #[test]
477 fn test_limit_truncates_after_order() {
478 let s = sample();
479 let top2: Vec<_> = s
480 .query()
481 .order_by(OrderBy::CreatedDesc)
482 .limit(2)
483 .collect()
484 .iter()
485 .map(|t| t.id)
486 .collect();
487 assert_eq!(top2, vec![5, 4]);
488 }
489
490 #[test]
491 fn test_composed_filters() {
492 let s = sample();
493 let ids: Vec<_> = s
495 .query()
496 .where_status(TaskStatus::Pending)
497 .created_after(200)
498 .order_by(OrderBy::IdAsc)
499 .collect()
500 .iter()
501 .map(|t| t.id)
502 .collect();
503 assert_eq!(ids, vec![3, 4]);
504 }
505
506 #[test]
507 fn test_first_returns_ordered_head() {
508 let s = sample();
509 let first = s
510 .query()
511 .where_status(TaskStatus::Pending)
512 .order_by(OrderBy::CreatedDesc)
513 .first()
514 .unwrap();
515 assert_eq!(first.id, 4);
517 }
518
519 #[test]
520 fn test_first_none_when_no_match() {
521 let s = sample();
522 assert!(s.query().title_contains("unicorn").first().is_none());
523 }
524
525 #[test]
526 fn test_count_ignores_limit() {
527 let s = sample();
528 let q = s.query().where_status(TaskStatus::Pending).limit(1);
529 assert_eq!(q.count(), 3);
531 }
532
533 #[test]
534 fn test_exists_short_circuits() {
535 let s = sample();
536 assert!(s.query().where_status(TaskStatus::Completed).exists());
537 assert!(!s.query().title_contains("unicorn").exists());
538 }
539
540 #[test]
541 fn test_empty_state_queries_return_empty() {
542 let s = TasksState::new();
543 assert_eq!(s.query().count(), 0);
544 assert!(s.query().first().is_none());
545 assert!(!s.query().exists());
546 }
547}