Skip to main content

things3_cloud/commands/
find.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
5use clap::{ArgGroup, Args};
6use iocraft::prelude::*;
7
8use crate::{
9    app::Cli,
10    arg_types::IdentifierToken,
11    commands::{Command, DetailedArgs, detailed_json_conflict, write_json},
12    common::resolve_single_tag,
13    ids::ThingsId,
14    store::{Task, ThingsStore},
15    ui::{
16        render_element_to_string,
17        views::{
18            find::{FindRow, FindView},
19            json::common::build_tasks_json,
20        },
21    },
22    wire::task::{TaskStart, TaskStatus},
23};
24
25#[derive(Debug, Clone, Copy)]
26struct MatchResult {
27    matched: bool,
28    checklist_only: bool,
29}
30
31impl MatchResult {
32    fn no() -> Self {
33        Self {
34            matched: false,
35            checklist_only: false,
36        }
37    }
38
39    fn yes(checklist_only: bool) -> Self {
40        Self {
41            matched: true,
42            checklist_only,
43        }
44    }
45}
46
47fn matches_project_filter(
48    filter: &IdentifierToken,
49    project_uuid: &str,
50    project_title_lower: &str,
51) -> bool {
52    let token = filter.as_str();
53    let lowered = token.to_ascii_lowercase();
54    project_uuid.starts_with(token) || project_title_lower.contains(&lowered)
55}
56
57fn matches_area_filter(filter: &IdentifierToken, area_uuid: &str, area_title_lower: &str) -> bool {
58    let token = filter.as_str();
59    let lowered = token.to_ascii_lowercase();
60    area_uuid.starts_with(token) || area_title_lower.contains(&lowered)
61}
62
63#[derive(Debug, Default, Args)]
64#[command(about = "Search and filter tasks.")]
65#[command(
66    after_help = "Date filter syntax:  --deadline OP DATE\n  OP is one of: >  <  >=  <=  =\n  DATE is YYYY-MM-DD or a keyword: today, tomorrow, yesterday\n\n  Examples:\n    --deadline \"<today\"          overdue tasks\n    --deadline \">=2026-01-01\"    deadline on or after date\n    --created \">=2026-01-01\" --created \"<=2026-03-31\"   date range"
67)]
68#[command(group(ArgGroup::new("status").args(["incomplete", "completed", "canceled", "any_status"]).multiple(false)))]
69#[command(group(ArgGroup::new("deadline_presence").args(["has_deadline", "no_deadline"]).multiple(false)))]
70pub struct FindArgs {
71    #[command(flatten)]
72    pub detailed: DetailedArgs,
73    #[arg(help = "Case-insensitive substring to match against task title")]
74    pub query: Option<String>,
75    #[arg(long, short = 'i', help = "Only incomplete tasks (default)")]
76    pub incomplete: bool,
77    #[arg(long, short = 'n', help = "Also search query against note text")]
78    pub notes: bool,
79    #[arg(
80        long,
81        short = 'k',
82        help = "Also search query against checklist item titles; implies --detailed for checklist-only matches"
83    )]
84    pub checklists: bool,
85    #[arg(long, short = 'c', help = "Only completed tasks")]
86    pub completed: bool,
87    #[arg(long, short = 'x', help = "Only canceled tasks")]
88    pub canceled: bool,
89    #[arg(
90        long = "any-status",
91        short = 'A',
92        help = "Match tasks regardless of status"
93    )]
94    pub any_status: bool,
95    #[arg(
96        long = "tag",
97        short = 't',
98        value_name = "TAG",
99        help = "Has this tag (title or UUID prefix); repeatable, OR logic"
100    )]
101    tag_filters: Vec<IdentifierToken>,
102    #[arg(
103        long = "project",
104        short = 'p',
105        value_name = "PROJECT",
106        help = "In this project (title substring or UUID prefix); repeatable, OR logic"
107    )]
108    project_filters: Vec<IdentifierToken>,
109    #[arg(
110        long = "area",
111        short = 'a',
112        value_name = "AREA",
113        help = "In this area (title substring or UUID prefix); repeatable, OR logic"
114    )]
115    area_filters: Vec<IdentifierToken>,
116    #[arg(long, short = 'I', help = "In Inbox view")]
117    pub inbox: bool,
118    #[arg(long, short = 'T', help = "In Today view")]
119    pub today: bool,
120    #[arg(long, short = 's', help = "In Someday")]
121    pub someday: bool,
122    #[arg(long, short = 'e', help = "Evening flag set")]
123    pub evening: bool,
124    #[arg(long = "has-deadline", short = 'H', help = "Has any deadline set")]
125    pub has_deadline: bool,
126    #[arg(long = "no-deadline", short = 'N', help = "No deadline set")]
127    pub no_deadline: bool,
128    #[arg(long, short = 'r', help = "Only recurring tasks")]
129    pub recurring: bool,
130    #[arg(
131        long,
132        short = 'l',
133        value_name = "EXPR",
134        help = "Deadline filter, e.g. '<today' or '>=2026-04-01' (repeatable for range)"
135    )]
136    pub deadline: Vec<String>,
137    #[arg(
138        long,
139        short = 'S',
140        value_name = "EXPR",
141        help = "Scheduled start date filter (repeatable)"
142    )]
143    pub scheduled: Vec<String>,
144    #[arg(
145        long,
146        short = 'C',
147        value_name = "EXPR",
148        help = "Creation date filter (repeatable)"
149    )]
150    pub created: Vec<String>,
151    #[arg(
152        long = "completed-on",
153        short = 'o',
154        value_name = "EXPR",
155        help = "Completion date filter; implies --completed (repeatable)"
156    )]
157    pub completed_on: Vec<String>,
158}
159
160impl Command for FindArgs {
161    fn run_with_ctx(
162        &self,
163        cli: &Cli,
164        out: &mut dyn std::io::Write,
165        ctx: &mut dyn crate::cmd_ctx::CmdCtx,
166    ) -> Result<()> {
167        let store = Arc::new(cli.load_store()?);
168        let today = ctx.today();
169
170        for (flag, exprs) in [
171            ("--deadline", &self.deadline),
172            ("--scheduled", &self.scheduled),
173            ("--created", &self.created),
174            ("--completed-on", &self.completed_on),
175        ] {
176            for expr in exprs {
177                if let Err(err) = parse_date_expr(expr, flag, &today) {
178                    eprintln!("{err}");
179                    return Ok(());
180                }
181            }
182        }
183
184        let mut resolved_tag_uuids = Vec::new();
185        for tag_filter in &self.tag_filters {
186            let (tag, err) = resolve_single_tag(&store, tag_filter.as_str());
187            if !err.is_empty() {
188                eprintln!("{err}");
189                return Ok(());
190            }
191            if let Some(tag) = tag {
192                resolved_tag_uuids.push(tag.uuid);
193            }
194        }
195
196        let mut matched: Vec<(Task, MatchResult)> = store
197            .tasks_by_uuid
198            .values()
199            .filter_map(|task| {
200                let result = matches(task, &store, self, &resolved_tag_uuids, &today);
201                if result.matched {
202                    Some((task.clone(), result))
203                } else {
204                    None
205                }
206            })
207            .collect();
208
209        matched.sort_by(|(a, _), (b, _)| {
210            let a_proj = if a.is_project() { 0 } else { 1 };
211            let b_proj = if b.is_project() { 0 } else { 1 };
212            (a_proj, a.index, &a.uuid).cmp(&(b_proj, b.index, &b.uuid))
213        });
214
215        let json = cli.json;
216        if json {
217            if detailed_json_conflict(json, self.detailed.detailed) {
218                return Ok(());
219            }
220            let tasks = matched
221                .iter()
222                .map(|(task, _)| task.clone())
223                .collect::<Vec<_>>();
224            write_json(out, &build_tasks_json(&tasks, &store, &today))?;
225            return Ok(());
226        }
227
228        let rows = matched
229            .iter()
230            .map(|(task, result)| FindRow {
231                task,
232                force_detailed: result.checklist_only,
233            })
234            .collect::<Vec<_>>();
235
236        let mut ui = element! {
237            ContextProvider(value: Context::owned(store.clone())) {
238                ContextProvider(value: Context::owned(today)) {
239                    FindView(rows, detailed: self.detailed.detailed)
240                }
241            }
242        };
243        let rendered = render_element_to_string(&mut ui, cli.no_color);
244        writeln!(out, "{}", rendered)?;
245
246        Ok(())
247    }
248}
249
250fn parse_date_value(
251    value: &str,
252    flag: &str,
253    today: &DateTime<Utc>,
254) -> Result<DateTime<Utc>, String> {
255    let lowered = value.trim().to_ascii_lowercase();
256    match lowered.as_str() {
257        "today" => Ok(*today),
258        "tomorrow" => Ok(*today + Duration::days(1)),
259        "yesterday" => Ok(*today - Duration::days(1)),
260        _ => {
261            let parsed = NaiveDate::parse_from_str(&lowered, "%Y-%m-%d").map_err(|_| {
262                format!(
263                    "Invalid date for {flag}: {value:?}. Expected YYYY-MM-DD, 'today', 'tomorrow', or 'yesterday'."
264                )
265            })?;
266            let ndt = parsed.and_hms_opt(0, 0, 0).ok_or_else(|| {
267                format!(
268                    "Invalid date for {flag}: {value:?}. Expected YYYY-MM-DD, 'today', 'tomorrow', or 'yesterday'."
269                )
270            })?;
271            Ok(Utc.from_utc_datetime(&ndt))
272        }
273    }
274}
275
276fn parse_date_expr(
277    raw: &str,
278    flag: &str,
279    today: &DateTime<Utc>,
280) -> Result<(&'static str, DateTime<Utc>), String> {
281    let value = raw.trim();
282    let (op, date_part) = if let Some(rest) = value.strip_prefix(">=") {
283        (">=", rest)
284    } else if let Some(rest) = value.strip_prefix("<=") {
285        ("<=", rest)
286    } else if let Some(rest) = value.strip_prefix('>') {
287        (">", rest)
288    } else if let Some(rest) = value.strip_prefix('<') {
289        ("<", rest)
290    } else if let Some(rest) = value.strip_prefix('=') {
291        ("=", rest)
292    } else {
293        return Err(format!(
294            "Invalid date expression for {flag}: {raw:?}. Expected an operator prefix: >, <, >=, <=, or =  (e.g. '<=2026-03-31')"
295        ));
296    };
297    let date = parse_date_value(date_part, flag, today)?;
298    Ok((op, date))
299}
300
301fn date_matches(field: Option<DateTime<Utc>>, op: &str, threshold: DateTime<Utc>) -> bool {
302    let Some(field) = field else {
303        return false;
304    };
305
306    let field_day = field
307        .with_timezone(&Utc)
308        .date_naive()
309        .and_hms_opt(0, 0, 0)
310        .map(|d| Utc.from_utc_datetime(&d));
311    let threshold_day = threshold
312        .date_naive()
313        .and_hms_opt(0, 0, 0)
314        .map(|d| Utc.from_utc_datetime(&d));
315    let (Some(field_day), Some(threshold_day)) = (field_day, threshold_day) else {
316        return false;
317    };
318
319    match op {
320        ">" => field_day > threshold_day,
321        "<" => field_day < threshold_day,
322        ">=" => field_day >= threshold_day,
323        "<=" => field_day <= threshold_day,
324        "=" => field_day == threshold_day,
325        _ => false,
326    }
327}
328
329fn build_status_set(args: &FindArgs) -> Option<Vec<TaskStatus>> {
330    if args.any_status {
331        return None;
332    }
333
334    let mut chosen = Vec::new();
335    if args.incomplete {
336        chosen.push(TaskStatus::Incomplete);
337    }
338    if args.completed {
339        chosen.push(TaskStatus::Completed);
340    }
341    if args.canceled {
342        chosen.push(TaskStatus::Canceled);
343    }
344
345    if chosen.is_empty() && !args.completed_on.is_empty() {
346        return Some(vec![TaskStatus::Completed]);
347    }
348    if chosen.is_empty() {
349        return Some(vec![TaskStatus::Incomplete]);
350    }
351    Some(chosen)
352}
353
354fn matches(
355    task: &Task,
356    store: &ThingsStore,
357    args: &FindArgs,
358    resolved_tag_uuids: &[ThingsId],
359    today: &DateTime<Utc>,
360) -> MatchResult {
361    if task.is_heading() || task.trashed || task.entity != "Task6" {
362        return MatchResult::no();
363    }
364
365    if let Some(allowed_statuses) = build_status_set(args)
366        && !allowed_statuses.contains(&task.status)
367    {
368        return MatchResult::no();
369    }
370
371    let mut checklist_only = false;
372    if let Some(query) = &args.query {
373        let q = query.to_ascii_lowercase();
374        let title_match = task.title.to_ascii_lowercase().contains(&q);
375        let notes_match = args.notes
376            && task
377                .notes
378                .as_ref()
379                .map(|n| n.to_ascii_lowercase().contains(&q))
380                .unwrap_or(false);
381        let checklist_match = args.checklists
382            && task
383                .checklist_items
384                .iter()
385                .any(|item| item.title.to_ascii_lowercase().contains(&q));
386
387        if !title_match && !notes_match && !checklist_match {
388            return MatchResult::no();
389        }
390        checklist_only = checklist_match && !title_match && !notes_match;
391    }
392
393    if !args.tag_filters.is_empty()
394        && !resolved_tag_uuids
395            .iter()
396            .any(|tag_uuid| task.tags.iter().any(|task_tag| task_tag == tag_uuid))
397    {
398        return MatchResult::no();
399    }
400
401    if !args.project_filters.is_empty() {
402        let Some(project_uuid) = store.effective_project_uuid(task) else {
403            return MatchResult::no();
404        };
405        let Some(project) = store.get_task(&project_uuid.to_string()) else {
406            return MatchResult::no();
407        };
408
409        let project_title = project.title.to_ascii_lowercase();
410        let matched = args
411            .project_filters
412            .iter()
413            .any(|f| matches_project_filter(f, &project_uuid.to_string(), &project_title));
414        if !matched {
415            return MatchResult::no();
416        }
417    }
418
419    if !args.area_filters.is_empty() {
420        let Some(area_uuid) = store.effective_area_uuid(task) else {
421            return MatchResult::no();
422        };
423        let Some(area) = store.get_area(&area_uuid.to_string()) else {
424            return MatchResult::no();
425        };
426
427        let area_title = area.title.to_ascii_lowercase();
428        let matched = args
429            .area_filters
430            .iter()
431            .any(|f| matches_area_filter(f, &area_uuid.to_string(), &area_title));
432        if !matched {
433            return MatchResult::no();
434        }
435    }
436
437    if args.inbox && task.start != TaskStart::Inbox {
438        return MatchResult::no();
439    }
440    if args.today && !task.is_today(today) {
441        return MatchResult::no();
442    }
443    if args.someday && !task.in_someday() {
444        return MatchResult::no();
445    }
446    if args.evening && !task.evening {
447        return MatchResult::no();
448    }
449    if args.has_deadline && task.deadline.is_none() {
450        return MatchResult::no();
451    }
452    if args.no_deadline && task.deadline.is_some() {
453        return MatchResult::no();
454    }
455    if args.recurring && task.recurrence_rule.is_none() {
456        return MatchResult::no();
457    }
458
459    for expr in &args.deadline {
460        let Ok((op, threshold)) = parse_date_expr(expr, "--deadline", today) else {
461            return MatchResult::no();
462        };
463        if !date_matches(task.deadline, op, threshold) {
464            return MatchResult::no();
465        }
466    }
467    for expr in &args.scheduled {
468        let Ok((op, threshold)) = parse_date_expr(expr, "--scheduled", today) else {
469            return MatchResult::no();
470        };
471        if !date_matches(task.start_date, op, threshold) {
472            return MatchResult::no();
473        }
474    }
475    for expr in &args.created {
476        let Ok((op, threshold)) = parse_date_expr(expr, "--created", today) else {
477            return MatchResult::no();
478        };
479        if !date_matches(task.creation_date, op, threshold) {
480            return MatchResult::no();
481        }
482    }
483    for expr in &args.completed_on {
484        let Ok((op, threshold)) = parse_date_expr(expr, "--completed-on", today) else {
485            return MatchResult::no();
486        };
487        if !date_matches(task.stop_date, op, threshold) {
488            return MatchResult::no();
489        }
490    }
491
492    if !args.any_status && !args.completed_on.is_empty() && task.status != TaskStatus::Completed {
493        return MatchResult::no();
494    }
495
496    MatchResult::yes(checklist_only)
497}