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}