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