1use crate::core::error::ThingsError;
9use crate::core::reader::pool::ReaderPool;
10use crate::core::types::{
11 Area, ChecklistItem, Heading, Project, ProjectFull, StartBucket, Tag, TaskStatus,
12 TodoFull, TodoSummary,
13};
14
15pub(crate) const SUMMARY_COLS: &str =
21 "t.uuid, t.title, t.status, t.start, t.project, t.area, t.heading, \
22 t.startDate, t.deadline, t.creationDate, t.userModificationDate";
23
24pub(crate) const SUMMARY_COLS_LEN: usize = 11;
29
30pub(crate) fn row_to_summary(r: &rusqlite::Row<'_>) -> rusqlite::Result<TodoSummary> {
31 use crate::core::reader::dates::decode_things_date;
32 Ok(TodoSummary {
33 id: r.get::<_, String>(0)?,
34 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
35 status: TaskStatus::from_sqlite(r.get::<_, i64>(2)?),
36 start: StartBucket::from_sqlite(r.get::<_, i64>(3)?),
37 project_id: r.get::<_, Option<String>>(4)?,
38 area_id: r.get::<_, Option<String>>(5)?,
39 heading_id: r.get::<_, Option<String>>(6)?,
40 tags: Vec::new(),
41 scheduled: r.get::<_, Option<i64>>(7)?.and_then(decode_things_date),
42 deadline: r.get::<_, Option<i64>>(8)?.and_then(decode_things_date),
43 creation_date: r.get::<_, Option<f64>>(9)?.map(unix_to_iso),
44 modification_date: r.get::<_, Option<f64>>(10)?.map(unix_to_iso),
45 })
46}
47
48pub struct ListInboxParams {
49 pub include_completed: bool,
50 pub limit: u32,
51}
52
53impl Default for ListInboxParams {
54 fn default() -> Self {
55 Self {
56 include_completed: false,
57 limit: 200,
58 }
59 }
60}
61
62pub async fn list_inbox(
63 pool: &ReaderPool,
64 params: ListInboxParams,
65) -> Result<Vec<TodoSummary>, ThingsError> {
66 let status_filter: &'static str = if params.include_completed {
67 ""
68 } else {
69 " AND status = 0"
70 };
71 let sql = format!(
72 r#"
73 SELECT {SUMMARY_COLS}
74 FROM TMTask AS t
75 WHERE t.trashed = 0
76 AND t.type = 0
77 AND t.start = 0
78 {status_filter}
79 ORDER BY t.creationDate DESC
80 LIMIT ?1
81 "#,
82 );
83 let limit = params.limit as i64;
84 let rows = pool
85 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
86 let mut stmt = c.prepare_cached(&sql)?;
87 let iter = stmt.query_map([limit], row_to_summary)?;
88 iter.collect()
89 })
90 .await?;
91 attach_tags(pool, rows).await
92}
93
94pub struct ListTodayParams {
95 pub limit: u32,
96}
97
98impl Default for ListTodayParams {
99 fn default() -> Self {
100 Self { limit: 200 }
101 }
102}
103
104pub async fn list_today(
105 pool: &ReaderPool,
106 params: ListTodayParams,
107) -> Result<Vec<TodoSummary>, ThingsError> {
108 use crate::core::reader::dates::today_packed_utc;
109 let today = today_packed_utc();
110 let sql = format!(
111 r#"
112 SELECT {SUMMARY_COLS}
113 FROM TMTask AS t
114 WHERE t.trashed = 0
115 AND t.type = 0
116 AND t.status = 0
117 AND t.start = 1
118 AND t.startDate > 0
119 AND t.startDate <= ?1
120 ORDER BY t.todayIndex IS NULL, t.todayIndex, t.userModificationDate DESC
121 LIMIT ?2
122 "#,
123 );
124 let limit = params.limit as i64;
125 let rows = pool
126 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
127 let mut stmt = c.prepare_cached(&sql)?;
128 let iter = stmt.query_map([today, limit], row_to_summary)?;
129 iter.collect()
130 })
131 .await?;
132 attach_tags(pool, rows).await
133}
134
135pub struct ListUpcomingParams {
136 pub from_iso: Option<String>,
137 pub to_iso: Option<String>,
138 pub limit: u32,
139}
140
141impl Default for ListUpcomingParams {
142 fn default() -> Self {
143 Self {
144 from_iso: None,
145 to_iso: None,
146 limit: 200,
147 }
148 }
149}
150
151pub async fn list_upcoming(
152 pool: &ReaderPool,
153 params: ListUpcomingParams,
154) -> Result<Vec<TodoSummary>, ThingsError> {
155 use crate::core::reader::dates::{pack_things_date, parse_iso_date, today_packed_utc};
156
157 let lower = match params.from_iso.as_deref() {
158 None => today_packed_utc(),
159 Some(s) => parse_iso_date(s)
160 .map(|(y, m, d)| pack_things_date(y, m, d))
161 .ok_or_else(|| ThingsError::InvalidInput {
162 field: "from".into(),
163 reason: format!("expected YYYY-MM-DD, got {s:?}"),
164 })?,
165 };
166 let upper: i64 = match params.to_iso.as_deref() {
167 None => i64::MAX,
168 Some(s) => parse_iso_date(s)
169 .map(|(y, m, d)| pack_things_date(y, m, d))
170 .ok_or_else(|| ThingsError::InvalidInput {
171 field: "to".into(),
172 reason: format!("expected YYYY-MM-DD, got {s:?}"),
173 })?,
174 };
175
176 let sql = format!(
177 r#"
178 SELECT {SUMMARY_COLS}
179 FROM TMTask AS t
180 WHERE t.trashed = 0
181 AND t.type = 0
182 AND t.status = 0
183 AND (
184 (t.startDate > 0 AND t.startDate > ?1 AND t.startDate <= ?2)
185 OR (t.deadline > 0 AND t.deadline > ?1 AND t.deadline <= ?2)
186 )
187 ORDER BY
188 CASE
189 WHEN t.startDate > 0 AND t.deadline > 0 THEN MIN(t.startDate, t.deadline)
190 WHEN t.startDate > 0 THEN t.startDate
191 ELSE t.deadline
192 END
193 LIMIT ?3
194 "#,
195 );
196 let limit = params.limit as i64;
197 let rows = pool
198 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
199 let mut stmt = c.prepare_cached(&sql)?;
200 let iter = stmt.query_map([lower, upper, limit], row_to_summary)?;
201 iter.collect()
202 })
203 .await?;
204 attach_tags(pool, rows).await
205}
206
207pub struct ListAnytimeParams {
208 pub area_id: Option<String>,
209 pub limit: u32,
210}
211
212impl Default for ListAnytimeParams {
213 fn default() -> Self {
214 Self {
215 area_id: None,
216 limit: 200,
217 }
218 }
219}
220
221pub async fn list_anytime(
222 pool: &ReaderPool,
223 params: ListAnytimeParams,
224) -> Result<Vec<TodoSummary>, ThingsError> {
225 let sql = format!(
226 r#"
227 SELECT {SUMMARY_COLS}
228 FROM TMTask AS t
229 LEFT JOIN TMTask AS p
230 ON p.uuid = t.project AND p.type = 1
231 WHERE t.trashed = 0
232 AND t.type = 0
233 AND t.status = 0
234 AND t.start = 1
235 AND (t.startDate IS NULL OR t.startDate = 0)
236 AND (?1 IS NULL OR t.area = ?1 OR p.area = ?1)
237 ORDER BY t.userModificationDate DESC
238 LIMIT ?2
239 "#,
240 );
241 let limit = params.limit as i64;
242 let area = params.area_id;
243 let rows = pool
244 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
245 let mut stmt = c.prepare_cached(&sql)?;
246 let iter = stmt.query_map(
247 rusqlite::params![area, limit],
248 row_to_summary,
249 )?;
250 iter.collect()
251 })
252 .await?;
253 attach_tags(pool, rows).await
254}
255
256pub struct ListSomedayParams {
257 pub limit: u32,
258}
259
260impl Default for ListSomedayParams {
261 fn default() -> Self {
262 Self { limit: 200 }
263 }
264}
265
266pub async fn list_someday(
267 pool: &ReaderPool,
268 params: ListSomedayParams,
269) -> Result<Vec<TodoSummary>, ThingsError> {
270 let sql = format!(
271 r#"
272 SELECT {SUMMARY_COLS}
273 FROM TMTask AS t
274 WHERE t.trashed = 0
275 AND t.type = 0
276 AND t.status = 0
277 AND t.start = 2
278 ORDER BY t.userModificationDate DESC
279 LIMIT ?1
280 "#,
281 );
282 let limit = params.limit as i64;
283 let rows = pool
284 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
285 let mut stmt = c.prepare_cached(&sql)?;
286 let iter = stmt.query_map([limit], row_to_summary)?;
287 iter.collect()
288 })
289 .await?;
290 attach_tags(pool, rows).await
291}
292
293pub struct ListLogbookParams {
294 pub from_iso: Option<String>,
295 pub to_iso: Option<String>,
296 pub limit: u32,
297}
298
299impl Default for ListLogbookParams {
300 fn default() -> Self {
301 Self {
302 from_iso: None,
303 to_iso: None,
304 limit: 100,
305 }
306 }
307}
308
309pub async fn list_logbook(
310 pool: &ReaderPool,
311 params: ListLogbookParams,
312) -> Result<Vec<TodoSummary>, ThingsError> {
313 use crate::core::reader::dates::{parse_iso_date, ymd_to_unix_utc};
314 let from_unix: Option<f64> = match params.from_iso.as_deref() {
315 None => None,
316 Some(s) => Some(
317 parse_iso_date(s)
318 .map(|(y, m, d)| ymd_to_unix_utc(y, m, d) as f64)
319 .ok_or_else(|| ThingsError::InvalidInput {
320 field: "from".into(),
321 reason: format!("expected YYYY-MM-DD, got {s:?}"),
322 })?,
323 ),
324 };
325 let to_unix: Option<f64> = match params.to_iso.as_deref() {
326 None => None,
327 Some(s) => Some(
328 parse_iso_date(s)
329 .map(|(y, m, d)| (ymd_to_unix_utc(y, m, d) + 86_400) as f64)
331 .ok_or_else(|| ThingsError::InvalidInput {
332 field: "to".into(),
333 reason: format!("expected YYYY-MM-DD, got {s:?}"),
334 })?,
335 ),
336 };
337
338 let sql = format!(
339 r#"
340 SELECT {SUMMARY_COLS}
341 FROM TMTask AS t
342 WHERE t.trashed = 0
343 AND t.type = 0
344 AND t.status IN (2, 3)
345 AND (?1 IS NULL OR t.stopDate >= ?1)
346 AND (?2 IS NULL OR t.stopDate < ?2)
347 ORDER BY t.stopDate DESC
348 LIMIT ?3
349 "#,
350 );
351 let limit = params.limit as i64;
352 let rows = pool
353 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
354 let mut stmt = c.prepare_cached(&sql)?;
355 let iter = stmt.query_map(
356 rusqlite::params![from_unix, to_unix, limit],
357 row_to_summary,
358 )?;
359 iter.collect()
360 })
361 .await?;
362 attach_tags(pool, rows).await
363}
364
365pub struct ListTrashParams {
366 pub limit: u32,
367}
368
369impl Default for ListTrashParams {
370 fn default() -> Self {
371 Self { limit: 100 }
372 }
373}
374
375pub async fn list_trash(
376 pool: &ReaderPool,
377 params: ListTrashParams,
378) -> Result<Vec<TodoSummary>, ThingsError> {
379 let sql = format!(
380 r#"
381 SELECT {SUMMARY_COLS}
382 FROM TMTask AS t
383 WHERE t.trashed = 1
384 AND t.type = 0
385 ORDER BY t.userModificationDate DESC
386 LIMIT ?1
387 "#,
388 );
389 let limit = params.limit as i64;
390 let rows = pool
391 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
392 let mut stmt = c.prepare_cached(&sql)?;
393 let iter = stmt.query_map([limit], row_to_summary)?;
394 iter.collect()
395 })
396 .await?;
397 attach_tags(pool, rows).await
398}
399
400async fn attach_tags(
402 pool: &ReaderPool,
403 mut rows: Vec<TodoSummary>,
404) -> Result<Vec<TodoSummary>, ThingsError> {
405 let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
406 let tag_map = fetch_tags_for_tasks(pool, ids).await?;
407 for row in rows.iter_mut() {
408 if let Some(v) = tag_map.get(&row.id) {
409 row.tags = v.clone();
410 }
411 }
412 Ok(rows)
413}
414
415async fn fetch_tags_for_tasks(
416 pool: &ReaderPool,
417 task_ids: Vec<String>,
418) -> Result<std::collections::HashMap<String, Vec<String>>, ThingsError> {
419 if task_ids.is_empty() {
420 return Ok(Default::default());
421 }
422 let placeholders = (0..task_ids.len())
423 .map(|_| "?")
424 .collect::<Vec<_>>()
425 .join(",");
426 let sql = format!(
427 r#"
428 SELECT tt.tasks, tg.title
429 FROM TMTaskTag AS tt
430 JOIN TMTag AS tg ON tg.uuid = tt.tags
431 WHERE tt.tasks IN ({placeholders})
432 ORDER BY tt.tasks, tg.title
433 "#,
434 );
435 let pairs = pool
436 .with_conn(move |c| -> rusqlite::Result<Vec<(String, String)>> {
437 let mut stmt = c.prepare_cached(&sql)?;
438 let params = rusqlite::params_from_iter(task_ids.iter());
439 let iter = stmt.query_map(params, |r| Ok((r.get(0)?, r.get(1)?)))?;
440 iter.collect()
441 })
442 .await?;
443 let mut out: std::collections::HashMap<String, Vec<String>> = Default::default();
444 for (task, tag) in pairs {
445 out.entry(task).or_default().push(tag);
446 }
447 Ok(out)
448}
449
450pub async fn get_tags_for_task(
454 pool: &ReaderPool,
455 id: String,
456) -> Result<Vec<String>, ThingsError> {
457 let tag_map = fetch_tags_for_tasks(pool, vec![id.clone()]).await?;
458 Ok(tag_map.get(&id).cloned().unwrap_or_default())
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub enum ProjectStatusFilter {
463 Open,
464 Done,
465 All,
466}
467
468impl Default for ProjectStatusFilter {
469 fn default() -> Self {
470 Self::Open
471 }
472}
473
474#[derive(Default)]
475pub struct ListProjectsParams {
476 pub area_id: Option<String>,
477 pub status: ProjectStatusFilter,
478}
479
480pub async fn list_projects(
481 pool: &ReaderPool,
482 params: ListProjectsParams,
483) -> Result<Vec<Project>, ThingsError> {
484 let status_clause = match params.status {
485 ProjectStatusFilter::Open => " AND t.status = 0",
486 ProjectStatusFilter::Done => " AND t.status IN (2, 3)",
487 ProjectStatusFilter::All => "",
488 };
489 let sql = format!(
490 r#"
491 SELECT t.uuid, t.title, t.area, t.status, t.notes
492 FROM TMTask AS t
493 WHERE t.trashed = 0
494 AND t.type = 1
495 AND (?1 IS NULL OR t.area = ?1)
496 {status_clause}
497 ORDER BY t.userModificationDate DESC
498 "#,
499 );
500 let area = params.area_id;
501 let rows = pool
502 .with_conn(move |c| -> rusqlite::Result<Vec<Project>> {
503 let mut stmt = c.prepare_cached(&sql)?;
504 let iter = stmt.query_map(rusqlite::params![area], |r| {
505 Ok(Project {
506 id: r.get::<_, String>(0)?,
507 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
508 area_id: r.get::<_, Option<String>>(2)?,
509 status: TaskStatus::from_sqlite(r.get::<_, i64>(3)?),
510 notes: r.get::<_, Option<String>>(4)?,
511 tags: Vec::new(),
512 })
513 })?;
514 iter.collect()
515 })
516 .await?;
517 let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
518 let tag_map = fetch_tags_for_tasks(pool, ids).await?;
519 let mut with_tags = rows;
520 for row in with_tags.iter_mut() {
521 if let Some(v) = tag_map.get(&row.id) {
522 row.tags = v.clone();
523 }
524 }
525 Ok(with_tags)
526}
527
528pub async fn list_areas(pool: &ReaderPool) -> Result<Vec<Area>, ThingsError> {
529 let sql = r#"
530 SELECT a.uuid, a.title
531 FROM TMArea AS a
532 ORDER BY a."index", a.title
533 "#;
534 let rows = pool
535 .with_conn(move |c| -> rusqlite::Result<Vec<Area>> {
536 let mut stmt = c.prepare_cached(sql)?;
537 let iter = stmt.query_map([], |r| {
538 Ok(Area {
539 id: r.get::<_, String>(0)?,
540 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
541 })
542 })?;
543 iter.collect()
544 })
545 .await?;
546 Ok(rows)
547}
548
549pub async fn list_tags(pool: &ReaderPool) -> Result<Vec<Tag>, ThingsError> {
550 let sql = r#"
551 SELECT g.uuid, g.title, g.parent, g.shortcut
552 FROM TMTag AS g
553 ORDER BY g."index", g.title
554 "#;
555 let rows = pool
556 .with_conn(move |c| -> rusqlite::Result<Vec<Tag>> {
557 let mut stmt = c.prepare_cached(sql)?;
558 let iter = stmt.query_map([], |r| {
559 Ok(Tag {
560 id: r.get::<_, String>(0)?,
561 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
562 parent_id: r.get::<_, Option<String>>(2)?,
563 shortcut: r.get::<_, Option<String>>(3)?,
564 })
565 })?;
566 iter.collect()
567 })
568 .await?;
569 Ok(rows)
570}
571
572pub async fn get_todo(
573 pool: &ReaderPool,
574 id: String,
575) -> Result<Option<TodoFull>, ThingsError> {
576 let id_for_summary = id.clone();
577 let summary_sql = format!(
578 r#"
579 SELECT {SUMMARY_COLS}
580 FROM TMTask AS t
581 WHERE t.uuid = ?1 AND t.type = 0
582 "#,
583 );
584 let detail_sql = r#"
585 SELECT t.notes, t.stopDate, t.rt1_recurrenceRule IS NOT NULL AS is_repeating
586 FROM TMTask AS t
587 WHERE t.uuid = ?1 AND t.type = 0
588 "#;
589 let summary_opt = pool
590 .with_conn(move |c| -> rusqlite::Result<Option<TodoSummary>> {
591 let mut stmt = c.prepare_cached(&summary_sql)?;
592 let mut rows = stmt.query([id_for_summary.as_str()])?;
593 if let Some(row) = rows.next()? {
594 Ok(Some(row_to_summary(row)?))
595 } else {
596 Ok(None)
597 }
598 })
599 .await?;
600 let summary = match summary_opt {
601 Some(s) => s,
602 None => return Ok(None),
603 };
604
605 let id_for_detail = id.clone();
606 let (notes, completion_date, is_repeating) = pool
607 .with_conn(move |c| -> rusqlite::Result<(Option<String>, Option<String>, bool)> {
608 let mut stmt = c.prepare_cached(detail_sql)?;
609 let mut rows = stmt.query([id_for_detail.as_str()])?;
610 if let Some(row) = rows.next()? {
611 let notes: Option<String> = row.get(0)?;
612 let stop_date: Option<f64> = row.get(1)?;
613 let is_repeating: bool = row.get::<_, i64>(2)? != 0;
614 Ok((notes, stop_date.map(unix_to_iso), is_repeating))
615 } else {
616 Ok((None, None, false))
617 }
618 })
619 .await?;
620
621 let id_for_checklist = id.clone();
622 let checklist = pool
623 .with_conn(move |c| -> rusqlite::Result<Vec<ChecklistItem>> {
624 let mut stmt = c.prepare_cached(
625 r#"
626 SELECT c.uuid, c.title, c.status
627 FROM TMChecklistItem AS c
628 WHERE c.task = ?1
629 ORDER BY c."index"
630 "#,
631 )?;
632 let iter = stmt.query_map([id_for_checklist.as_str()], |r| {
633 Ok(ChecklistItem {
634 id: r.get::<_, String>(0)?,
635 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
636 status: TaskStatus::from_sqlite(r.get::<_, i64>(2)?),
637 })
638 })?;
639 iter.collect()
640 })
641 .await?;
642
643 let tag_map = fetch_tags_for_tasks(pool, vec![id.clone()]).await?;
645 let mut summary = summary;
646 if let Some(v) = tag_map.get(&id) {
647 summary.tags = v.clone();
648 }
649
650 Ok(Some(TodoFull {
651 summary,
652 notes,
653 checklist,
654 completion_date,
655 is_repeating_template: is_repeating,
656 }))
657}
658
659pub async fn get_project(
660 pool: &ReaderPool,
661 id: String,
662) -> Result<Option<ProjectFull>, ThingsError> {
663 let id_for_meta = id.clone();
665 let meta_sql = r#"
666 SELECT t.uuid, t.title, t.area, t.status, t.notes, t.stopDate
667 FROM TMTask AS t
668 WHERE t.uuid = ?1 AND t.type = 1
669 "#;
670 let meta = pool
671 .with_conn(move |c| -> rusqlite::Result<Option<(Project, Option<f64>)>> {
672 let mut stmt = c.prepare_cached(meta_sql)?;
673 let mut rows = stmt.query([id_for_meta.as_str()])?;
674 if let Some(row) = rows.next()? {
675 let project = Project {
676 id: row.get::<_, String>(0)?,
677 title: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
678 area_id: row.get::<_, Option<String>>(2)?,
679 status: TaskStatus::from_sqlite(row.get::<_, i64>(3)?),
680 notes: row.get::<_, Option<String>>(4)?,
681 tags: Vec::new(),
682 };
683 let stop_date: Option<f64> = row.get(5)?;
684 Ok(Some((project, stop_date)))
685 } else {
686 Ok(None)
687 }
688 })
689 .await?;
690 let (mut project, stop_date) = match meta {
691 Some(p) => p,
692 None => return Ok(None),
693 };
694
695 let tag_map = fetch_tags_for_tasks(pool, vec![id.clone()]).await?;
697 if let Some(v) = tag_map.get(&id) {
698 project.tags = v.clone();
699 }
700
701 let id_for_children = id.clone();
703 let children_sql = format!(
704 r#"
705 SELECT t.uuid, t.title, t.type, t.status, t.start, t.project, t.area, t.heading,
706 t.startDate, t.deadline, t.creationDate, t.userModificationDate
707 FROM TMTask AS t
708 WHERE t.project = ?1 AND t.trashed = 0
709 ORDER BY t."index"
710 "#,
711 );
712 let children = pool
713 .with_conn(move |c| -> rusqlite::Result<Vec<(i64, TodoSummary)>> {
714 let mut stmt = c.prepare_cached(&children_sql)?;
715 let iter = stmt.query_map([id_for_children.as_str()], |r| {
716 let kind_int: i64 = r.get(2)?;
717 let summary = TodoSummary {
720 id: r.get::<_, String>(0)?,
721 title: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
722 status: TaskStatus::from_sqlite(r.get::<_, i64>(3)?),
723 start: StartBucket::from_sqlite(r.get::<_, i64>(4)?),
724 project_id: r.get::<_, Option<String>>(5)?,
725 area_id: r.get::<_, Option<String>>(6)?,
726 heading_id: r.get::<_, Option<String>>(7)?,
727 tags: Vec::new(),
728 scheduled: r
729 .get::<_, Option<i64>>(8)?
730 .and_then(crate::core::reader::dates::decode_things_date),
731 deadline: r
732 .get::<_, Option<i64>>(9)?
733 .and_then(crate::core::reader::dates::decode_things_date),
734 creation_date: r.get::<_, Option<f64>>(10)?.map(unix_to_iso),
735 modification_date: r.get::<_, Option<f64>>(11)?.map(unix_to_iso),
736 };
737 Ok((kind_int, summary))
738 })?;
739 iter.collect()
740 })
741 .await?;
742
743 let mut headings: std::collections::BTreeMap<String, Heading> = Default::default();
746 let mut direct_items: Vec<TodoSummary> = Vec::new();
747 let mut heading_order: Vec<String> = Vec::new();
748
749 for (kind, summary) in children.iter() {
750 if *kind == 2 {
751 heading_order.push(summary.id.clone());
752 headings.insert(
753 summary.id.clone(),
754 Heading {
755 id: summary.id.clone(),
756 title: summary.title.clone(),
757 items: Vec::new(),
758 },
759 );
760 }
761 }
762 for (kind, summary) in children.into_iter() {
763 if kind == 2 {
764 continue;
765 }
766 match &summary.heading_id {
767 Some(hid) if headings.contains_key(hid) => {
768 headings.get_mut(hid).unwrap().items.push(summary);
769 }
770 _ => direct_items.push(summary),
771 }
772 }
773
774 let mut all_todo_ids: Vec<String> = direct_items.iter().map(|i| i.id.clone()).collect();
776 for h in headings.values() {
777 for i in &h.items {
778 all_todo_ids.push(i.id.clone());
779 }
780 }
781 let todo_tag_map = fetch_tags_for_tasks(pool, all_todo_ids).await?;
782 for item in direct_items.iter_mut() {
783 if let Some(v) = todo_tag_map.get(&item.id) {
784 item.tags = v.clone();
785 }
786 }
787 for h in headings.values_mut() {
788 for item in h.items.iter_mut() {
789 if let Some(v) = todo_tag_map.get(&item.id) {
790 item.tags = v.clone();
791 }
792 }
793 }
794
795 let ordered_headings: Vec<Heading> =
796 heading_order.into_iter().filter_map(|id| headings.remove(&id)).collect();
797
798 Ok(Some(ProjectFull {
799 project,
800 items: direct_items,
801 headings: ordered_headings,
802 completion_date: stop_date.map(unix_to_iso),
803 notes: None,
804 }))
805}
806
807pub struct ListByTagParams {
808 pub tag: String,
809 pub recurse: bool,
810 pub limit: u32,
811}
812
813impl Default for ListByTagParams {
814 fn default() -> Self {
815 Self {
816 tag: String::new(),
817 recurse: true,
818 limit: 200,
819 }
820 }
821}
822
823pub async fn list_by_tag(
824 pool: &ReaderPool,
825 params: ListByTagParams,
826) -> Result<Vec<TodoSummary>, ThingsError> {
827 let tag = params.tag.clone();
828 let limit = params.limit as i64;
829 let sql = if params.recurse {
830 format!(
831 r#"
832 WITH RECURSIVE tag_tree(uuid) AS (
833 SELECT uuid FROM TMTag WHERE title = ?1 OR uuid = ?1
834 UNION ALL
835 SELECT g.uuid FROM TMTag AS g JOIN tag_tree AS tt ON g.parent = tt.uuid
836 )
837 SELECT DISTINCT {SUMMARY_COLS}
838 FROM TMTask AS t
839 JOIN TMTaskTag AS tx ON tx.tasks = t.uuid
840 JOIN tag_tree ON tx.tags = tag_tree.uuid
841 WHERE t.trashed = 0 AND t.type = 0
842 ORDER BY t.creationDate DESC
843 LIMIT ?2
844 "#,
845 )
846 } else {
847 format!(
848 r#"
849 SELECT DISTINCT {SUMMARY_COLS}
850 FROM TMTask AS t
851 JOIN TMTaskTag AS tx ON tx.tasks = t.uuid
852 JOIN TMTag AS g ON g.uuid = tx.tags
853 WHERE (g.title = ?1 OR g.uuid = ?1)
854 AND t.trashed = 0
855 AND t.type = 0
856 ORDER BY t.creationDate DESC
857 LIMIT ?2
858 "#,
859 )
860 };
861
862 let rows = pool
863 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
864 let mut stmt = c.prepare_cached(&sql)?;
865 let iter = stmt.query_map(
866 rusqlite::params![tag, limit],
867 row_to_summary,
868 )?;
869 iter.collect()
870 })
871 .await?;
872 attach_tags(pool, rows).await
873}
874
875fn unix_to_iso(secs: f64) -> String {
876 let s = secs as i64;
878 let (y, mo, d, h, mi, sec) = crate::core::backup::unix_to_ymdhms(s);
879 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
880}
881
882#[derive(Default)]
885pub struct SearchParams {
886 pub query: Option<String>,
888 pub tags: Vec<String>,
890 pub area_id: Option<String>,
891 pub project_id: Option<String>,
892 pub status: ProjectStatusFilter,
893 pub due_before: Option<String>,
895 pub due_after: Option<String>,
897 pub scheduled_before: Option<String>,
899 pub scheduled_after: Option<String>,
901 pub limit: u32,
905}
906
907pub async fn search(
908 pool: &ReaderPool,
909 params: SearchParams,
910) -> Result<Vec<TodoSummary>, ThingsError> {
911 use crate::core::reader::dates::{pack_things_date, parse_iso_date};
912 use rusqlite::types::Value;
913
914 let mut clauses: Vec<String> = Vec::new();
915 let mut binds: Vec<Value> = Vec::new();
916
917 let effective_limit: i64 = if params.limit == 0 {
918 i64::MAX
919 } else {
920 params.limit as i64
921 };
922
923 match params.status {
926 ProjectStatusFilter::Open => clauses.push("t.status = 0".to_string()),
927 ProjectStatusFilter::Done => clauses.push("t.status IN (2, 3)".to_string()),
928 ProjectStatusFilter::All => {}
929 }
930
931 if let Some(q) = params.query.as_ref().filter(|s| !s.is_empty()) {
933 let pat = format!("%{}%", q);
934 clauses.push("(t.title LIKE ? OR t.notes LIKE ?)".to_string());
935 binds.push(Value::Text(pat.clone()));
936 binds.push(Value::Text(pat));
937 }
938
939 if !params.tags.is_empty() {
941 let tag_placeholders = (0..params.tags.len() * 2)
942 .map(|i| if i % 2 == 0 { "g.title = ?" } else { "g.uuid = ?" })
943 .collect::<Vec<_>>()
944 .chunks(2)
945 .map(|pair| format!("({} OR {})", pair[0], pair[1]))
946 .collect::<Vec<_>>()
947 .join(" OR ");
948 clauses.push(format!(
949 "EXISTS (SELECT 1 FROM TMTaskTag tt \
950 JOIN TMTag g ON g.uuid = tt.tags \
951 WHERE tt.tasks = t.uuid AND ({tag_placeholders}))"
952 ));
953 for tag in ¶ms.tags {
954 binds.push(Value::Text(tag.clone()));
955 binds.push(Value::Text(tag.clone()));
956 }
957 }
958
959 if let Some(area) = params.area_id.as_ref() {
961 clauses.push("(t.area = ? OR p.area = ?)".to_string());
962 binds.push(Value::Text(area.clone()));
963 binds.push(Value::Text(area.clone()));
964 }
965
966 if let Some(project) = params.project_id.as_ref() {
968 clauses.push("t.project = ?".to_string());
969 binds.push(Value::Text(project.clone()));
970 }
971
972 if let Some(iso) = params.due_after.as_ref() {
974 let packed = parse_iso_date(iso)
975 .map(|(y, m, d)| pack_things_date(y, m, d))
976 .ok_or_else(|| ThingsError::InvalidInput {
977 field: "due_after".into(),
978 reason: format!("expected YYYY-MM-DD, got {iso:?}"),
979 })?;
980 clauses.push("(t.deadline > 0 AND t.deadline >= ?)".to_string());
981 binds.push(Value::Integer(packed));
982 }
983 if let Some(iso) = params.due_before.as_ref() {
984 let packed = parse_iso_date(iso)
985 .map(|(y, m, d)| pack_things_date(y, m, d))
986 .ok_or_else(|| ThingsError::InvalidInput {
987 field: "due_before".into(),
988 reason: format!("expected YYYY-MM-DD, got {iso:?}"),
989 })?;
990 clauses.push("(t.deadline > 0 AND t.deadline <= ?)".to_string());
991 binds.push(Value::Integer(packed));
992 }
993
994 if let Some(iso) = params.scheduled_after.as_ref() {
996 let packed = parse_iso_date(iso)
997 .map(|(y, m, d)| pack_things_date(y, m, d))
998 .ok_or_else(|| ThingsError::InvalidInput {
999 field: "scheduled_after".into(),
1000 reason: format!("expected YYYY-MM-DD, got {iso:?}"),
1001 })?;
1002 clauses.push("(t.startDate > 0 AND t.startDate >= ?)".to_string());
1003 binds.push(Value::Integer(packed));
1004 }
1005 if let Some(iso) = params.scheduled_before.as_ref() {
1006 let packed = parse_iso_date(iso)
1007 .map(|(y, m, d)| pack_things_date(y, m, d))
1008 .ok_or_else(|| ThingsError::InvalidInput {
1009 field: "scheduled_before".into(),
1010 reason: format!("expected YYYY-MM-DD, got {iso:?}"),
1011 })?;
1012 clauses.push("(t.startDate > 0 AND t.startDate <= ?)".to_string());
1013 binds.push(Value::Integer(packed));
1014 }
1015
1016 let extra = if clauses.is_empty() {
1017 String::new()
1018 } else {
1019 format!(" AND {}", clauses.join(" AND "))
1020 };
1021 let sql = format!(
1022 r#"
1023 SELECT {SUMMARY_COLS}
1024 FROM TMTask AS t
1025 LEFT JOIN TMTask AS p
1026 ON p.uuid = t.project AND p.type = 1
1027 WHERE t.trashed = 0
1028 AND t.type = 0
1029 {extra}
1030 ORDER BY t.creationDate DESC
1031 LIMIT ?
1032 "#,
1033 );
1034 binds.push(Value::Integer(effective_limit));
1035
1036 let rows = pool
1037 .with_conn(move |c| -> rusqlite::Result<Vec<TodoSummary>> {
1038 let mut stmt = c.prepare_cached(&sql)?;
1039 let iter = stmt.query_map(
1040 rusqlite::params_from_iter(binds.iter()),
1041 row_to_summary,
1042 )?;
1043 iter.collect()
1044 })
1045 .await?;
1046 attach_tags(pool, rows).await
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use crate::core::reader::{fixture::build_fixture, pool::ReaderPool};
1053 use tempfile::tempdir;
1054
1055 #[tokio::test]
1056 async fn list_inbox_default_excludes_completed() {
1057 let tmp = tempdir().unwrap();
1058 let path = tmp.path().join("p.sqlite");
1059 build_fixture(&path).unwrap();
1060 let pool = ReaderPool::new(path, 2).await.unwrap();
1061 let rows = list_inbox(&pool, ListInboxParams::default()).await.unwrap();
1062 assert_eq!(rows.len(), 2);
1064 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1065 assert!(titles.contains(&"Buy milk"));
1066 assert!(titles.contains(&"Call the dentist"));
1067 }
1068
1069 #[tokio::test]
1070 async fn list_inbox_with_completed_includes_completed() {
1071 let tmp = tempdir().unwrap();
1072 let path = tmp.path().join("p.sqlite");
1073 build_fixture(&path).unwrap();
1074 let pool = ReaderPool::new(path, 2).await.unwrap();
1075 let rows = list_inbox(
1076 &pool,
1077 ListInboxParams {
1078 include_completed: true,
1079 limit: 200,
1080 },
1081 )
1082 .await
1083 .unwrap();
1084 assert_eq!(rows.len(), 3);
1085 }
1086
1087 #[tokio::test]
1088 async fn list_inbox_attaches_tags() {
1089 let tmp = tempdir().unwrap();
1090 let path = tmp.path().join("p.sqlite");
1091 build_fixture(&path).unwrap();
1092 let pool = ReaderPool::new(path, 2).await.unwrap();
1093 let rows = list_inbox(&pool, ListInboxParams::default()).await.unwrap();
1094 let dentist = rows.iter().find(|r| r.title == "Call the dentist").unwrap();
1095 assert_eq!(dentist.tags, vec!["Errand".to_string()]);
1096 }
1097
1098 #[tokio::test]
1099 async fn list_today_includes_past_scheduled() {
1100 let tmp = tempdir().unwrap();
1101 let path = tmp.path().join("p.sqlite");
1102 build_fixture(&path).unwrap();
1103 let pool = ReaderPool::new(path, 2).await.unwrap();
1104 let rows = list_today(&pool, ListTodayParams::default()).await.unwrap();
1105 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1106 assert!(titles.contains(&"Today scheduled item"));
1107 assert!(!titles.contains(&"Upcoming scheduled item"));
1109 }
1110
1111 #[tokio::test]
1112 async fn list_upcoming_returns_future_scheduled_and_deadlined() {
1113 let tmp = tempdir().unwrap();
1114 let path = tmp.path().join("p.sqlite");
1115 build_fixture(&path).unwrap();
1116 let pool = ReaderPool::new(path, 2).await.unwrap();
1117 let rows = list_upcoming(&pool, ListUpcomingParams::default()).await.unwrap();
1118 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1119 assert!(titles.contains(&"Upcoming scheduled item"));
1120 assert!(titles.contains(&"Upcoming deadlined item"));
1121 assert!(!titles.contains(&"Today scheduled item"));
1123 assert!(!titles.contains(&"Read RFC 9457"));
1124 }
1125
1126 #[tokio::test]
1127 async fn list_anytime_returns_unscheduled_anytime_items() {
1128 let tmp = tempdir().unwrap();
1129 let path = tmp.path().join("p.sqlite");
1130 build_fixture(&path).unwrap();
1131 let pool = ReaderPool::new(path, 2).await.unwrap();
1132 let rows = list_anytime(&pool, ListAnytimeParams::default()).await.unwrap();
1133 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1134 assert!(titles.contains(&"Read RFC 9457"));
1135 assert!(titles.contains(&"Upcoming deadlined item"));
1137 assert!(!titles.contains(&"Upcoming scheduled item"));
1139 assert!(!titles.contains(&"Today scheduled item"));
1141 }
1142
1143 #[tokio::test]
1144 async fn list_anytime_area_filter() {
1145 let tmp = tempdir().unwrap();
1146 let path = tmp.path().join("p.sqlite");
1147 build_fixture(&path).unwrap();
1148 let pool = ReaderPool::new(path, 2).await.unwrap();
1149 let rows = list_anytime(
1150 &pool,
1151 ListAnytimeParams {
1152 area_id: Some("area-1".to_string()),
1153 limit: 200,
1154 },
1155 )
1156 .await
1157 .unwrap();
1158 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1159 assert!(titles.contains(&"Read RFC 9457"));
1161 assert!(titles.contains(&"Upcoming deadlined item"));
1163 }
1164
1165 #[tokio::test]
1166 async fn list_someday_returns_start_2_items() {
1167 let tmp = tempdir().unwrap();
1168 let path = tmp.path().join("p.sqlite");
1169 build_fixture(&path).unwrap();
1170 let pool = ReaderPool::new(path, 2).await.unwrap();
1171 let rows = list_someday(&pool, ListSomedayParams::default()).await.unwrap();
1172 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1173 assert_eq!(rows.len(), 1);
1174 assert!(titles.contains(&"Read research papers"));
1175 }
1176
1177 #[tokio::test]
1178 async fn list_logbook_returns_completed_and_canceled_ordered_by_stopdate() {
1179 let tmp = tempdir().unwrap();
1180 let path = tmp.path().join("p.sqlite");
1181 build_fixture(&path).unwrap();
1182 let pool = ReaderPool::new(path, 2).await.unwrap();
1183 let rows = list_logbook(&pool, ListLogbookParams::default()).await.unwrap();
1184 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1185 assert!(titles.contains(&"Old completed"));
1186 assert!(titles.contains(&"Old canceled"));
1187 let pos_old = titles.iter().position(|t| *t == "Old completed").unwrap();
1189 let pos_newer = titles.iter().position(|t| *t == "Old canceled").unwrap();
1190 assert!(pos_newer < pos_old);
1191 }
1192
1193 #[tokio::test]
1194 async fn list_logbook_from_bound_excludes_older_items() {
1195 let tmp = tempdir().unwrap();
1196 let path = tmp.path().join("p.sqlite");
1197 build_fixture(&path).unwrap();
1198 let pool = ReaderPool::new(path, 2).await.unwrap();
1199 let rows = list_logbook(
1202 &pool,
1203 ListLogbookParams {
1204 from_iso: Some("2024-04-27".to_string()),
1205 to_iso: None,
1206 limit: 100,
1207 },
1208 )
1209 .await
1210 .unwrap();
1211 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1212 assert!(titles.contains(&"Old canceled"));
1213 assert!(!titles.contains(&"Old completed"));
1214 }
1215
1216 #[tokio::test]
1217 async fn list_trash_returns_trashed_items() {
1218 let tmp = tempdir().unwrap();
1219 let path = tmp.path().join("p.sqlite");
1220 build_fixture(&path).unwrap();
1221 let pool = ReaderPool::new(path, 2).await.unwrap();
1222 let rows = list_trash(&pool, ListTrashParams::default()).await.unwrap();
1223 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1224 assert_eq!(rows.len(), 1);
1225 assert!(titles.contains(&"Trashed thing"));
1226 }
1227
1228 #[tokio::test]
1229 async fn list_areas_returns_areas_in_index_order() {
1230 let tmp = tempdir().unwrap();
1231 let path = tmp.path().join("p.sqlite");
1232 build_fixture(&path).unwrap();
1233 let pool = ReaderPool::new(path, 2).await.unwrap();
1234 let rows = list_areas(&pool).await.unwrap();
1235 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1236 assert_eq!(titles, vec!["Personal", "Work"]);
1237 assert_eq!(rows[0].id, "area-1");
1238 assert_eq!(rows[1].id, "area-2");
1239 }
1240
1241 #[tokio::test]
1242 async fn list_upcoming_respects_to_bound() {
1243 let tmp = tempdir().unwrap();
1244 let path = tmp.path().join("p.sqlite");
1245 build_fixture(&path).unwrap();
1246 let pool = ReaderPool::new(path, 2).await.unwrap();
1247 let rows = list_upcoming(
1250 &pool,
1251 ListUpcomingParams {
1252 from_iso: None,
1253 to_iso: Some("2050-01-01".to_string()),
1254 limit: 200,
1255 },
1256 )
1257 .await
1258 .unwrap();
1259 assert!(rows.is_empty());
1260 }
1261
1262 #[tokio::test]
1263 async fn list_projects_default_returns_open_only() {
1264 let tmp = tempdir().unwrap();
1265 let path = tmp.path().join("p.sqlite");
1266 build_fixture(&path).unwrap();
1267 let pool = ReaderPool::new(path, 2).await.unwrap();
1268 let rows = list_projects(&pool, ListProjectsParams::default()).await.unwrap();
1269 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1270 assert!(titles.contains(&"Reading list"));
1271 assert!(!titles.contains(&"Shipped Q1"));
1272 }
1273
1274 #[tokio::test]
1275 async fn list_projects_status_done_returns_completed_only() {
1276 let tmp = tempdir().unwrap();
1277 let path = tmp.path().join("p.sqlite");
1278 build_fixture(&path).unwrap();
1279 let pool = ReaderPool::new(path, 2).await.unwrap();
1280 let rows = list_projects(
1281 &pool,
1282 ListProjectsParams {
1283 area_id: None,
1284 status: ProjectStatusFilter::Done,
1285 },
1286 )
1287 .await
1288 .unwrap();
1289 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1290 assert_eq!(titles, vec!["Shipped Q1"]);
1291 }
1292
1293 #[tokio::test]
1294 async fn list_tags_returns_flat_list_with_parent_links() {
1295 let tmp = tempdir().unwrap();
1296 let path = tmp.path().join("p.sqlite");
1297 build_fixture(&path).unwrap();
1298 let pool = ReaderPool::new(path, 2).await.unwrap();
1299 let rows = list_tags(&pool).await.unwrap();
1300 assert_eq!(rows.len(), 3);
1301 let errand = rows.iter().find(|t| t.title == "Errand").unwrap();
1302 let call = rows.iter().find(|t| t.title == "Call").unwrap();
1303 let deep = rows.iter().find(|t| t.title == "Deep work").unwrap();
1304 assert!(errand.parent_id.is_none());
1305 assert_eq!(call.parent_id.as_deref(), Some("tag-errand"));
1306 assert!(deep.parent_id.is_none());
1307 assert_eq!(deep.shortcut.as_deref(), Some("D"));
1308 }
1309
1310 #[tokio::test]
1311 async fn list_projects_area_filter_and_tag_attachment() {
1312 let tmp = tempdir().unwrap();
1313 let path = tmp.path().join("p.sqlite");
1314 build_fixture(&path).unwrap();
1315 let pool = ReaderPool::new(path, 2).await.unwrap();
1316 let rows = list_projects(
1317 &pool,
1318 ListProjectsParams {
1319 area_id: Some("area-1".to_string()),
1320 status: ProjectStatusFilter::All,
1321 },
1322 )
1323 .await
1324 .unwrap();
1325 assert_eq!(rows.len(), 1);
1326 assert_eq!(rows[0].title, "Reading list");
1327 assert_eq!(rows[0].tags, vec!["Errand".to_string()]);
1328 }
1329
1330 #[tokio::test]
1331 async fn get_todo_returns_full_shape_with_checklist_and_tags() {
1332 let tmp = tempdir().unwrap();
1333 let path = tmp.path().join("p.sqlite");
1334 build_fixture(&path).unwrap();
1335 let pool = ReaderPool::new(path, 2).await.unwrap();
1336 let full = get_todo(&pool, "todo-1".to_string()).await.unwrap().unwrap();
1337 assert_eq!(full.summary.title, "Buy milk");
1338 assert_eq!(full.checklist.len(), 3);
1339 let titles: Vec<_> = full.checklist.iter().map(|c| c.title.as_str()).collect();
1340 assert_eq!(titles, vec!["Walk to shop", "Buy whole milk", "Pay with card"]);
1341 assert!(!full.is_repeating_template);
1342 }
1343
1344 #[tokio::test]
1345 async fn get_todo_returns_none_for_missing_id() {
1346 let tmp = tempdir().unwrap();
1347 let path = tmp.path().join("p.sqlite");
1348 build_fixture(&path).unwrap();
1349 let pool = ReaderPool::new(path, 2).await.unwrap();
1350 let res = get_todo(&pool, "does-not-exist".to_string()).await.unwrap();
1351 assert!(res.is_none());
1352 }
1353
1354 #[tokio::test]
1355 async fn get_project_returns_full_shape_with_headings() {
1356 let tmp = tempdir().unwrap();
1357 let path = tmp.path().join("p.sqlite");
1358 build_fixture(&path).unwrap();
1359 let pool = ReaderPool::new(path, 2).await.unwrap();
1360 let full = get_project(&pool, "proj-1".to_string()).await.unwrap().unwrap();
1361 assert_eq!(full.project.title, "Reading list");
1362 assert_eq!(full.headings.len(), 1);
1363 assert_eq!(full.headings[0].title, "Articles");
1364 let head_items: Vec<_> = full.headings[0]
1365 .items
1366 .iter()
1367 .map(|i| i.title.as_str())
1368 .collect();
1369 assert_eq!(head_items, vec!["Read intro"]);
1370 let direct_items: Vec<_> = full.items.iter().map(|i| i.title.as_str()).collect();
1372 assert!(direct_items.contains(&"Read RFC 9457"));
1373 }
1374
1375 #[tokio::test]
1376 async fn get_project_returns_none_for_missing_id() {
1377 let tmp = tempdir().unwrap();
1378 let path = tmp.path().join("p.sqlite");
1379 build_fixture(&path).unwrap();
1380 let pool = ReaderPool::new(path, 2).await.unwrap();
1381 let res = get_project(&pool, "does-not-exist".to_string()).await.unwrap();
1382 assert!(res.is_none());
1383 }
1384
1385 #[tokio::test]
1386 async fn list_by_tag_non_recursive_returns_direct_matches_only() {
1387 let tmp = tempdir().unwrap();
1388 let path = tmp.path().join("p.sqlite");
1389 build_fixture(&path).unwrap();
1390 let pool = ReaderPool::new(path, 2).await.unwrap();
1391 let rows = list_by_tag(
1394 &pool,
1395 ListByTagParams {
1396 tag: "Errand".to_string(),
1397 recurse: false,
1398 limit: 200,
1399 },
1400 )
1401 .await
1402 .unwrap();
1403 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1404 assert!(titles.contains(&"Call the dentist"));
1405 assert!(!titles.contains(&"Read RFC 9457"));
1406 }
1407
1408 #[tokio::test]
1409 async fn list_by_tag_recursive_picks_up_child_tags() {
1410 let tmp = tempdir().unwrap();
1411 let path = tmp.path().join("p.sqlite");
1412 build_fixture(&path).unwrap();
1413 let pool = ReaderPool::new(path, 2).await.unwrap();
1414 let rows = list_by_tag(
1415 &pool,
1416 ListByTagParams {
1417 tag: "Errand".to_string(),
1418 recurse: true,
1419 limit: 200,
1420 },
1421 )
1422 .await
1423 .unwrap();
1424 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1425 assert!(titles.contains(&"Call the dentist"));
1426 assert!(titles.contains(&"Read RFC 9457"));
1427 }
1428
1429 #[tokio::test]
1430 async fn list_by_tag_accepts_uuid_input_too() {
1431 let tmp = tempdir().unwrap();
1432 let path = tmp.path().join("p.sqlite");
1433 build_fixture(&path).unwrap();
1434 let pool = ReaderPool::new(path, 2).await.unwrap();
1435 let rows = list_by_tag(
1436 &pool,
1437 ListByTagParams {
1438 tag: "tag-deep".to_string(),
1439 recurse: false,
1440 limit: 200,
1441 },
1442 )
1443 .await
1444 .unwrap();
1445 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1446 assert_eq!(titles, vec!["Read research papers"]);
1447 }
1448
1449 #[tokio::test]
1450 async fn search_text_only_matches_title_and_notes() {
1451 let tmp = tempdir().unwrap();
1452 let path = tmp.path().join("p.sqlite");
1453 build_fixture(&path).unwrap();
1454 let pool = ReaderPool::new(path, 2).await.unwrap();
1455 let rows = search(
1456 &pool,
1457 SearchParams {
1458 query: Some("milk".to_string()),
1459 ..Default::default()
1460 },
1461 )
1462 .await
1463 .unwrap();
1464 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1465 assert!(titles.contains(&"Buy milk"));
1466 assert!(!titles.contains(&"Pay tax bill"));
1468 }
1469
1470 #[tokio::test]
1471 async fn search_text_search_matches_notes_too() {
1472 let tmp = tempdir().unwrap();
1476 let path = tmp.path().join("p.sqlite");
1477 build_fixture(&path).unwrap();
1478 let pool = ReaderPool::new(path, 2).await.unwrap();
1479 let rows = search(
1480 &pool,
1481 SearchParams {
1482 query: Some("Track what to read".to_string()),
1483 ..Default::default()
1484 },
1485 )
1486 .await
1487 .unwrap();
1488 assert!(rows.is_empty(), "projects must not appear in to-do search");
1489 }
1490
1491 #[tokio::test]
1492 async fn search_tag_filter_or_semantics() {
1493 let tmp = tempdir().unwrap();
1494 let path = tmp.path().join("p.sqlite");
1495 build_fixture(&path).unwrap();
1496 let pool = ReaderPool::new(path, 2).await.unwrap();
1497 let rows = search(
1498 &pool,
1499 SearchParams {
1500 tags: vec!["Errand".to_string(), "Deep work".to_string()],
1501 ..Default::default()
1502 },
1503 )
1504 .await
1505 .unwrap();
1506 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1507 assert!(titles.contains(&"Call the dentist"));
1509 assert!(titles.contains(&"Read research papers"));
1510 }
1511
1512 #[tokio::test]
1513 async fn search_area_filter_includes_project_indirection() {
1514 let tmp = tempdir().unwrap();
1515 let path = tmp.path().join("p.sqlite");
1516 build_fixture(&path).unwrap();
1517 let pool = ReaderPool::new(path, 2).await.unwrap();
1518 let rows = search(
1519 &pool,
1520 SearchParams {
1521 area_id: Some("area-1".to_string()),
1522 ..Default::default()
1523 },
1524 )
1525 .await
1526 .unwrap();
1527 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1528 assert!(titles.contains(&"Read RFC 9457"));
1530 assert!(titles.contains(&"Upcoming deadlined item"));
1532 }
1533
1534 #[tokio::test]
1535 async fn search_project_filter() {
1536 let tmp = tempdir().unwrap();
1537 let path = tmp.path().join("p.sqlite");
1538 build_fixture(&path).unwrap();
1539 let pool = ReaderPool::new(path, 2).await.unwrap();
1540 let rows = search(
1541 &pool,
1542 SearchParams {
1543 project_id: Some("proj-1".to_string()),
1544 ..Default::default()
1545 },
1546 )
1547 .await
1548 .unwrap();
1549 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1550 assert!(titles.contains(&"Read RFC 9457"));
1551 assert!(titles.contains(&"Today scheduled item"));
1553 }
1554
1555 #[tokio::test]
1556 async fn search_status_done_includes_logbook() {
1557 let tmp = tempdir().unwrap();
1558 let path = tmp.path().join("p.sqlite");
1559 build_fixture(&path).unwrap();
1560 let pool = ReaderPool::new(path, 2).await.unwrap();
1561 let rows = search(
1562 &pool,
1563 SearchParams {
1564 status: ProjectStatusFilter::Done,
1565 ..Default::default()
1566 },
1567 )
1568 .await
1569 .unwrap();
1570 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1571 assert!(titles.contains(&"Old completed"));
1572 assert!(titles.contains(&"Old canceled"));
1573 assert!(titles.contains(&"Pay tax bill"));
1574 }
1575
1576 #[tokio::test]
1577 async fn search_deadline_range_filter() {
1578 let tmp = tempdir().unwrap();
1579 let path = tmp.path().join("p.sqlite");
1580 build_fixture(&path).unwrap();
1581 let pool = ReaderPool::new(path, 2).await.unwrap();
1582 let rows = search(
1583 &pool,
1584 SearchParams {
1585 due_after: Some("2050-01-01".to_string()),
1586 ..Default::default()
1587 },
1588 )
1589 .await
1590 .unwrap();
1591 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1592 assert_eq!(titles, vec!["Upcoming deadlined item"]);
1593 }
1594
1595 #[tokio::test]
1596 async fn search_scheduled_range_filter() {
1597 let tmp = tempdir().unwrap();
1598 let path = tmp.path().join("p.sqlite");
1599 build_fixture(&path).unwrap();
1600 let pool = ReaderPool::new(path, 2).await.unwrap();
1601 let rows = search(
1602 &pool,
1603 SearchParams {
1604 scheduled_before: Some("2050-01-01".to_string()),
1605 ..Default::default()
1606 },
1607 )
1608 .await
1609 .unwrap();
1610 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1611 assert!(titles.contains(&"Today scheduled item"));
1613 assert!(!titles.contains(&"Upcoming scheduled item"));
1615 }
1616
1617 #[tokio::test]
1618 async fn search_combined_filters_intersect() {
1619 let tmp = tempdir().unwrap();
1620 let path = tmp.path().join("p.sqlite");
1621 build_fixture(&path).unwrap();
1622 let pool = ReaderPool::new(path, 2).await.unwrap();
1623 let rows = search(
1624 &pool,
1625 SearchParams {
1626 query: Some("Read".to_string()),
1627 area_id: Some("area-1".to_string()),
1628 ..Default::default()
1629 },
1630 )
1631 .await
1632 .unwrap();
1633 let titles: Vec<_> = rows.iter().map(|r| r.title.as_str()).collect();
1634 assert!(titles.contains(&"Read RFC 9457"));
1636 assert!(!titles.contains(&"Read research papers"));
1638 }
1639
1640 #[tokio::test]
1641 async fn get_tags_for_task_returns_tag_titles_for_tagged_todo() {
1642 let tmp = tempdir().unwrap();
1643 let path = tmp.path().join("p.sqlite");
1644 build_fixture(&path).unwrap();
1645 let pool = ReaderPool::new(path, 2).await.unwrap();
1646 let tags = get_tags_for_task(&pool, "todo-2".into()).await.unwrap();
1648 assert_eq!(tags, vec!["Errand".to_string()]);
1649 }
1650
1651 #[tokio::test]
1652 async fn get_tags_for_task_returns_empty_for_untagged_todo() {
1653 let tmp = tempdir().unwrap();
1654 let path = tmp.path().join("p.sqlite");
1655 build_fixture(&path).unwrap();
1656 let pool = ReaderPool::new(path, 2).await.unwrap();
1657 let tags = get_tags_for_task(&pool, "todo-1".into()).await.unwrap();
1659 assert!(tags.is_empty());
1660 }
1661}