Skip to main content

ralph/cli/queue/
search.rs

1//! Queue search subcommand.
2//!
3//! Responsibilities:
4//! - Search tasks by content (title, evidence, plan, notes, request, tags, scope, custom fields).
5//! - Support regex, fuzzy, and case-sensitive matching modes.
6//! - Apply status, tag, scope, and scheduled filters alongside content search.
7//! - Output in compact, long, or JSON formats.
8//!
9//! Not handled here:
10//! - Task listing without content search (see `list.rs`).
11//! - Task creation, modification, or deletion.
12//!
13//! Invariants/assumptions:
14//! - Queue files are loaded and validated before searching.
15//! - Search is performed after pre-filtering by status/tag/scope.
16//! - JSON output uses the same task shape as queue export/show.
17
18use anyhow::{Result, bail};
19use clap::Args;
20
21use crate::cli::{load_and_validate_queues_read_only, resolve_list_limit};
22use crate::config::Resolved;
23use crate::contracts::{Task, TaskStatus};
24use crate::{outpututil, queue};
25
26use super::{QueueListFormat, StatusArg};
27
28/// Arguments for `ralph queue search`.
29#[derive(Args)]
30/// Search tasks by content (title, evidence, plan, notes, request, tags, scope, custom fields).
31#[command(
32    after_long_help = "Examples:\n  ralph queue search \"authentication\"\n  ralph queue search \"RQ-\\d{4}\" --regex\n  ralph queue search \"TODO\" --match-case\n  ralph queue search \"fix\" --status todo --tag rust\n  ralph queue search \"refactor\" --scope crates/ralph --tag rust\n  ralph queue search \"auth bug\" --fuzzy\n  ralph queue search \"fuzzy search\" --fuzzy --match-case"
33)]
34pub struct QueueSearchArgs {
35    /// Search query (substring or regex pattern).
36    #[arg(value_name = "QUERY")]
37    pub query: String,
38
39    /// Interpret query as a regular expression.
40    #[arg(long)]
41    pub regex: bool,
42
43    /// Case-sensitive search (default: case-insensitive).
44    #[arg(long)]
45    pub match_case: bool,
46
47    /// Use fuzzy matching for search (default: substring).
48    #[arg(long)]
49    pub fuzzy: bool,
50
51    /// Filter by status (repeatable).
52    #[arg(long, value_enum)]
53    pub status: Vec<StatusArg>,
54
55    /// Filter by tag (repeatable, case-insensitive).
56    #[arg(long)]
57    pub tag: Vec<String>,
58
59    /// Filter by scope token (repeatable, case-insensitive; substring match).
60    #[arg(long)]
61    pub scope: Vec<String>,
62
63    /// Include tasks from .ralph/done.jsonc in search.
64    #[arg(long)]
65    pub include_done: bool,
66
67    /// Only search tasks in .ralph/done.jsonc (ignores active queue).
68    #[arg(long)]
69    pub only_done: bool,
70
71    /// Output format.
72    #[arg(long, value_enum, default_value_t = QueueListFormat::Compact)]
73    pub format: QueueListFormat,
74
75    /// Maximum results to show (0 = no limit).
76    #[arg(long, default_value_t = 50)]
77    pub limit: u32,
78
79    /// Show all results (ignores --limit).
80    #[arg(long)]
81    pub all: bool,
82
83    /// Filter to only show scheduled tasks (have scheduled_start set).
84    #[arg(long)]
85    pub scheduled: bool,
86}
87
88pub(crate) fn handle(resolved: &Resolved, args: QueueSearchArgs) -> Result<()> {
89    if args.include_done && args.only_done {
90        bail!(
91            "Conflicting flags: --include-done and --only-done are mutually exclusive. Choose either to include done tasks or to only search done tasks."
92        );
93    }
94
95    if args.fuzzy && args.regex {
96        bail!(
97            "Conflicting flags: --fuzzy and --regex are mutually exclusive. Choose either fuzzy matching or regex matching."
98        );
99    }
100
101    let (queue_file, done_file) =
102        load_and_validate_queues_read_only(resolved, args.include_done || args.only_done)?;
103    let done_ref = done_file
104        .as_ref()
105        .filter(|d| !d.tasks.is_empty() || resolved.done_path.exists());
106
107    let statuses: Vec<TaskStatus> = args.status.into_iter().map(|s| s.into()).collect();
108
109    // Pre-filter by status/tag/scope using filter_tasks
110    let mut prefiltered: Vec<&Task> = Vec::new();
111    if !args.only_done {
112        prefiltered.extend(queue::filter_tasks(
113            &queue_file,
114            &statuses,
115            &args.tag,
116            &args.scope,
117            None,
118        ));
119    }
120    if (args.include_done || args.only_done)
121        && let Some(done_ref) = done_ref
122    {
123        prefiltered.extend(queue::filter_tasks(
124            done_ref,
125            &statuses,
126            &args.tag,
127            &args.scope,
128            None,
129        ));
130    }
131
132    // Apply scheduled filter if requested
133    if args.scheduled {
134        prefiltered.retain(|task| task.scheduled_start.is_some());
135    }
136
137    // Build search options
138    let search_options = queue::SearchOptions {
139        use_regex: args.regex,
140        case_sensitive: args.match_case,
141        use_fuzzy: args.fuzzy,
142        scopes: args.scope.clone(),
143    };
144
145    // Apply content search
146    let results =
147        queue::search_tasks_with_options(prefiltered.into_iter(), &args.query, &search_options)?;
148
149    let limit = resolve_list_limit(args.limit, args.all);
150    let max = limit.unwrap_or(usize::MAX);
151    let results: Vec<&Task> = results.into_iter().take(max).collect();
152
153    match args.format {
154        QueueListFormat::Compact => {
155            for task in results {
156                println!("{}", outpututil::format_task_compact(task));
157            }
158        }
159        QueueListFormat::Long => {
160            for task in results {
161                println!("{}", outpututil::format_task_detailed(task));
162            }
163        }
164        QueueListFormat::Json => {
165            let owned_tasks: Vec<Task> = results.into_iter().cloned().collect();
166            let json = serde_json::to_string_pretty(&owned_tasks)?;
167            println!("{json}");
168        }
169    }
170
171    Ok(())
172}