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}