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