git_adr/cli/
search.rs

1//! Search ADRs.
2
3use anyhow::Result;
4use clap::Args as ClapArgs;
5use colored::Colorize;
6use regex::Regex;
7
8use crate::core::{AdrStatus, ConfigManager, Git, NotesManager};
9
10/// Arguments for the search command.
11#[derive(ClapArgs, Debug)]
12pub struct Args {
13    /// Search query.
14    pub query: String,
15
16    /// Filter by status.
17    #[arg(long, short)]
18    pub status: Option<String>,
19
20    /// Filter by tag.
21    #[arg(long, short = 'g')]
22    pub tag: Option<String>,
23
24    /// Case sensitive search.
25    #[arg(long, short)]
26    pub case_sensitive: bool,
27
28    /// Use regex pattern.
29    #[arg(long, short = 'E')]
30    pub regex: bool,
31
32    /// Context lines to show.
33    #[arg(long, short = 'C', default_value = "2")]
34    pub context: usize,
35
36    /// Maximum results.
37    #[arg(long)]
38    pub limit: Option<usize>,
39}
40
41/// A search match result.
42struct SearchMatch {
43    line_number: usize,
44    line: String,
45    context_before: Vec<String>,
46    context_after: Vec<String>,
47}
48
49/// Run the search command.
50///
51/// # Errors
52///
53/// Returns an error if search fails.
54pub fn run(args: Args) -> Result<()> {
55    let git = Git::new();
56    git.check_repository()?;
57
58    let config = ConfigManager::new(git.clone()).load()?;
59    let notes = NotesManager::new(git, config);
60
61    let mut adrs = notes.list()?;
62
63    // Filter by status
64    if let Some(status_str) = &args.status {
65        let status: AdrStatus = status_str.parse().map_err(|e| anyhow::anyhow!("{}", e))?;
66        adrs.retain(|a| a.frontmatter.status == status);
67    }
68
69    // Filter by tag
70    if let Some(tag) = &args.tag {
71        adrs.retain(|a| a.frontmatter.tags.iter().any(|t| t.contains(tag)));
72    }
73
74    // Build search pattern
75    let pattern = if args.regex {
76        if args.case_sensitive {
77            Regex::new(&args.query)?
78        } else {
79            Regex::new(&format!("(?i){}", &args.query))?
80        }
81    } else {
82        let escaped = regex::escape(&args.query);
83        if args.case_sensitive {
84            Regex::new(&escaped)?
85        } else {
86            Regex::new(&format!("(?i){}", escaped))?
87        }
88    };
89
90    let mut total_matches = 0;
91    let mut results = Vec::new();
92
93    for adr in &adrs {
94        let content = adr.to_markdown().unwrap_or_default();
95        let lines: Vec<&str> = content.lines().collect();
96        let mut matches = Vec::new();
97
98        for (idx, line) in lines.iter().enumerate() {
99            if pattern.is_match(line) {
100                // Collect context
101                let context_before: Vec<String> = lines[idx.saturating_sub(args.context)..idx]
102                    .iter()
103                    .map(|s| (*s).to_string())
104                    .collect();
105
106                let context_after: Vec<String> = lines
107                    [(idx + 1).min(lines.len())..(idx + 1 + args.context).min(lines.len())]
108                    .iter()
109                    .map(|s| (*s).to_string())
110                    .collect();
111
112                matches.push(SearchMatch {
113                    line_number: idx + 1,
114                    line: (*line).to_string(),
115                    context_before,
116                    context_after,
117                });
118
119                total_matches += 1;
120            }
121        }
122
123        if !matches.is_empty() {
124            results.push((adr.clone(), matches));
125        }
126
127        // Check limit
128        if let Some(limit) = args.limit {
129            if results.len() >= limit {
130                break;
131            }
132        }
133    }
134
135    if results.is_empty() {
136        eprintln!("{} No matches found for: {}", "→".yellow(), args.query);
137        return Ok(());
138    }
139
140    // Display results
141    for (adr, matches) in &results {
142        println!(
143            "{} {} - {}",
144            adr.id.cyan().bold(),
145            format!("[{}]", adr.frontmatter.status).dimmed(),
146            adr.frontmatter.title
147        );
148
149        for m in matches {
150            // Print context before
151            for ctx_line in &m.context_before {
152                println!("  {} {}", "│".dimmed(), ctx_line.dimmed());
153            }
154
155            // Print matching line with highlighting
156            let highlighted = pattern.replace_all(&m.line, |caps: &regex::Captures| {
157                format!("{}", caps[0].red().bold())
158            });
159            println!(
160                "  {} {}",
161                format!("{}:", m.line_number).yellow(),
162                highlighted
163            );
164
165            // Print context after
166            for ctx_line in &m.context_after {
167                println!("  {} {}", "│".dimmed(), ctx_line.dimmed());
168            }
169
170            println!();
171        }
172    }
173
174    eprintln!(
175        "{} {} match(es) in {} ADR(s)",
176        "→".blue(),
177        total_matches,
178        results.len()
179    );
180
181    Ok(())
182}