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}