Skip to main content

things3_cloud/commands/
find.rs

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