Skip to main content

tldr_cli/commands/
search.rs

1//! Smart Search command - Enriched BM25 search with structure + call graph context.
2//!
3//! Returns enriched "search result cards" containing function-level context
4//! (signature, callers, callees) for each BM25 match, minimizing round-trips
5//! for LLM agents exploring a codebase.
6
7use std::path::PathBuf;
8
9use anyhow::Result;
10use clap::Args;
11
12use tldr_core::{enriched_search, EnrichedSearchOptions, Language, SearchMode};
13
14use crate::output::{format_enriched_search_text, OutputFormat, OutputWriter};
15
16/// Enriched search: BM25 search with function-level context cards.
17///
18/// By default this command performs token-based ranking using BM25 with
19/// structure and call-graph signals. Common high-frequency tokens
20/// (stopwords like `fn`, `def`, `function`, `class`) are filtered out
21/// of the BM25 query because they would otherwise dominate scoring
22/// without adding signal.
23///
24/// ux-and-explain-completeness-v1 (P12.AGG12-13): when EVERY query
25/// token is filtered (e.g. `fn new`, `function`, `def `), the command
26/// transparently falls back to literal substring search so the query
27/// still returns useful results. The report's `search_mode` field is
28/// then `literal-fallback+structure` (or `+callgraph`).
29///
30/// Pass `--regex` to interpret the query as a regex pattern, or
31/// `--hybrid <PATTERN>` to combine BM25 ranking with a regex filter.
32#[derive(Debug, Args)]
33pub struct SmartSearchArgs {
34    /// Search query (natural language or code terms; BM25 by default,
35    /// regex when `--regex` is set)
36    pub query: String,
37
38    /// Directory to search in (default: current directory)
39    #[arg(default_value = ".")]
40    pub path: PathBuf,
41
42    /// Programming language (auto-detect if not specified)
43    #[arg(long, short = 'l')]
44    pub lang: Option<Language>,
45
46    /// Maximum number of result cards to return
47    #[arg(long, short = 'k', default_value = "10")]
48    pub top_k: usize,
49
50    /// Skip call graph enrichment (much faster, no callers/callees)
51    #[arg(long)]
52    pub no_callgraph: bool,
53
54    /// Use regex pattern matching instead of BM25 ranking.
55    /// The query is interpreted as a regex pattern.
56    #[arg(long, conflicts_with = "hybrid")]
57    pub regex: bool,
58
59    /// Hybrid mode: combine BM25 relevance with regex filtering.
60    /// The positional query is used for BM25 ranking, this pattern for regex filtering.
61    #[arg(long, conflicts_with = "regex")]
62    pub hybrid: Option<String>,
63}
64
65impl SmartSearchArgs {
66    /// Run the search command
67    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
68        let writer = OutputWriter::new(format, quiet);
69
70        // Validate path exists BEFORE language detection / progress banner
71        // (lang-detect-default-v1)
72        if !self.path.exists() {
73            anyhow::bail!("Path not found: {}", self.path.display());
74        }
75
76        // Determine language (auto-detect from directory, default to Python)
77        let language = self
78            .lang
79            .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
80
81        writer.progress(&format!(
82            "Smart searching for '{}' in {} ({})...",
83            self.query,
84            self.path.display(),
85            language.as_str()
86        ));
87
88        let search_mode = if self.regex {
89            SearchMode::Regex(self.query.clone())
90        } else if let Some(ref pattern) = self.hybrid {
91            SearchMode::Hybrid {
92                query: self.query.clone(),
93                pattern: pattern.clone(),
94            }
95        } else {
96            SearchMode::Bm25
97        };
98
99        let options = EnrichedSearchOptions {
100            top_k: self.top_k,
101            include_callgraph: !self.no_callgraph,
102            search_mode,
103        };
104
105        // Run enriched search
106        let report = enriched_search(&self.query, &self.path, language, options)?;
107
108        // Output based on format
109        if writer.is_text() {
110            let text = format_enriched_search_text(&report);
111            writer.write_text(&text)?;
112        } else {
113            writer.write(&report)?;
114        }
115
116        Ok(())
117    }
118}