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}