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}