Skip to main content

things_mcp/core/reader/
queries.rs

1//! Typed SQL helpers against the live Things schema. Every query goes through
2//! `prepare_cached`; no string interpolation of user input.
3//!
4//! Date semantics:
5//! - `creationDate`, `userModificationDate`, `stopDate` are REAL Unix seconds.
6//! - `startDate`, `deadline` are bit-packed integers (handled in later tasks).
7
8use 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
15/// Standard `TodoSummary`-shaped column projection used by every list query.
16/// SQL must `SELECT` columns in this exact order:
17///
18/// `t.uuid, t.title, t.status, t.start, t.project, t.area, t.heading,
19///  t.startDate, t.deadline, t.creationDate, t.userModificationDate`
20pub(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
24/// Number of columns in `SUMMARY_COLS`. Callers that `SELECT {SUMMARY_COLS}, ...`
25/// extra trailing columns use this as the starting index for those extras. If
26/// `SUMMARY_COLS` ever gains or loses a column, this constant must move with it
27/// (and so must `row_to_summary`).
28pub(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                // End of the requested day, exclusive of the next day.
330                .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
400/// Helper used by every list query that returns `TodoSummary` rows.
401async 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
450/// Public helper for the assign/unassign tools: returns the current tag
451/// titles attached to a single to-do (or empty if none). Wraps the
452/// per-task fetch so callers don't have to deal with the HashMap shape.
453pub 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    // Attach tags onto the summary by reusing fetch_tags_for_tasks for one id.
644    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    // 1. Project meta row.
664    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    // 2. Project tags via the same junction we use for to-dos.
696    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    // 3. All child rows (headings + to-dos) under the project, ordered by index.
702    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                // For headings we still call row_to_summary so we get the title/id; the kind
718                // is returned alongside so the caller can split them.
719                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    // 4. Split children into headings vs direct to-dos. For to-dos that point to
744    //    a heading via `heading_id`, group them under that heading.
745    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    // 5. Attach tags onto the to-do summaries (direct + per-heading).
775    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    // Minimal ISO-8601 emitter so we don't pull in `chrono` for one helper.
877    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/// Filter inputs to `search`. Each Option / Vec field is OFF when empty/None,
883/// matching the spec's "all filters are optional" contract.
884#[derive(Default)]
885pub struct SearchParams {
886    /// Free-text query (LIKE-matched against `title` and `notes`). Optional.
887    pub query: Option<String>,
888    /// Tag titles or UUIDs. OR-semantic — an item with any listed tag matches.
889    pub tags: Vec<String>,
890    pub area_id: Option<String>,
891    pub project_id: Option<String>,
892    pub status: ProjectStatusFilter,
893    /// ISO `YYYY-MM-DD`. Inclusive upper bound on `deadline`.
894    pub due_before: Option<String>,
895    /// ISO `YYYY-MM-DD`. Inclusive lower bound on `deadline`.
896    pub due_after: Option<String>,
897    /// ISO `YYYY-MM-DD`. Inclusive upper bound on `startDate`.
898    pub scheduled_before: Option<String>,
899    /// ISO `YYYY-MM-DD`. Inclusive lower bound on `startDate`.
900    pub scheduled_after: Option<String>,
901    /// Cap on returned rows. Caller supplies; 0 is internally rewritten to i64::MAX
902    /// so default-constructed unit tests behave; the MCP-layer adapter always
903    /// supplies a real limit (default 50 at the tool boundary).
904    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    // Status filter — default Open. ProjectStatusFilter is reused (Plan 2)
924    // because the enum values map cleanly: Open=0, Done=2|3, All=no filter.
925    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    // Text filter — LIKE on title + notes.
932    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    // Tag filter — OR-semantic. Inlined EXISTS so the main row scan stays simple.
940    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 &params.tags {
954            binds.push(Value::Text(tag.clone()));
955            binds.push(Value::Text(tag.clone()));
956        }
957    }
958
959    // Area filter — direct OR via project.
960    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    // Project filter.
967    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    // Deadline range — packed-int comparison.
973    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    // Scheduled range — packed-int comparison.
995    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        // fixture: 3 inbox rows, one of which is status=3 (completed)
1063        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        // Future-scheduled item must NOT be in Today.
1108        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        // Today-scheduled and never-scheduled items must NOT be in Upcoming.
1122        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        // Has a deadline but no scheduled date → still anytime.
1136        assert!(titles.contains(&"Upcoming deadlined item"));
1137        // Future-scheduled item is NOT anytime.
1138        assert!(!titles.contains(&"Upcoming scheduled item"));
1139        // Today-scheduled item is NOT anytime.
1140        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        // proj-1 is in area-1, so todo-4 inside proj-1 should be picked up via the project join.
1160        assert!(titles.contains(&"Read RFC 9457"));
1161        // todo-upcoming-dl has area=area-1 directly.
1162        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        // Older completion comes after newer one (DESC by stopDate).
1188        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        // Old completed has stopDate 1714000000 ≈ 2024-04-24; old canceled has 1714500000 ≈ 2024-04-30.
1200        // from = 2024-04-27 → only canceled survives.
1201        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        // to=2050-01-01 should still include the 2099-dated items? No — 2050
1248        // < 2099, so they are excluded.
1249        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        // todo-4 lives directly under proj-1 (no heading)
1371        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        // 'Errand' is the parent tag. todo-2 is tagged 'Errand' directly;
1392        // todo-4 is tagged 'Call' (child of 'Errand') — without recurse, todo-4 is excluded.
1393        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        // Status defaults to Open; the completed inbox row is excluded.
1467        assert!(!titles.contains(&"Pay tax bill"));
1468    }
1469
1470    #[tokio::test]
1471    async fn search_text_search_matches_notes_too() {
1472        // The fixture's proj-1 has notes "Track what to read next" — projects
1473        // are not in scope for to-do search (type=0), so the text match
1474        // should NOT pick them up.
1475        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        // todo-2 carries 'Errand'; todo-someday carries 'Deep work'.
1508        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        // todo-4 sits under proj-1 (area-1) — picked up via project indirection.
1529        assert!(titles.contains(&"Read RFC 9457"));
1530        // todo-upcoming-dl has area=area-1 directly.
1531        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        // todo-today is also in proj-1 (status=Open).
1552        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        // Scheduled to 2020-01-01 — well before 2050-01-01.
1612        assert!(titles.contains(&"Today scheduled item"));
1613        // Scheduled to 2099-12-31 — after the upper bound.
1614        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        // Both text-match ("Read") and area-1 match.
1635        assert!(titles.contains(&"Read RFC 9457"));
1636        // "Read research papers" is in area-2 — excluded by area filter.
1637        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        // todo-2 is tagged 'Errand' in the fixture.
1647        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        // todo-1 ('Buy milk') has no tags.
1658        let tags = get_tags_for_task(&pool, "todo-1".into()).await.unwrap();
1659        assert!(tags.is_empty());
1660    }
1661}