1use std::path::Path;
2
3use crate::core::graph_index::{self, ProjectIndex, SymbolEntry};
4use crate::core::protocol;
5use crate::core::tokens::count_tokens;
6
7pub fn handle(
8 name: &str,
9 file: Option<&str>,
10 kind: Option<&str>,
11 project_root: &str,
12) -> (String, usize) {
13 let index = graph_index::load_or_build(project_root);
14
15 let matches = find_symbols(&index, name, file, kind);
16
17 if matches.is_empty() {
18 return (
19 format!(
20 "Symbol '{name}' not found in index ({} symbols indexed). \
21 Try ctx_search(pattern=\"{name}\") for a broader search.",
22 index.symbol_count()
23 ),
24 0,
25 );
26 }
27
28 if matches.len() == 1 {
29 return render_single(matches[0], &index, project_root);
30 }
31
32 if matches.len() <= 5 {
33 return render_multiple(&matches, &index, project_root);
34 }
35
36 let mut out = format!(
37 "{} matches for '{name}'. Narrow with file= or kind=:\n",
38 matches.len()
39 );
40 for m in matches.iter().take(20) {
41 out.push_str(&format!(
42 " {}::{} ({}:L{}-{})\n",
43 m.file, m.name, m.kind, m.start_line, m.end_line
44 ));
45 }
46 if matches.len() > 20 {
47 out.push_str(&format!(" ... and {} more\n", matches.len() - 20));
48 }
49 (out, 0)
50}
51
52fn find_symbols<'a>(
53 index: &'a ProjectIndex,
54 name: &str,
55 file_filter: Option<&str>,
56 kind_filter: Option<&str>,
57) -> Vec<&'a SymbolEntry> {
58 let name_lower = name.to_lowercase();
59 let mut results: Vec<&SymbolEntry> = index
60 .symbols
61 .values()
62 .filter(|s| {
63 let s_lower = s.name.to_lowercase();
64 let name_match = s_lower == name_lower
65 || s_lower.ends_with(&name_lower)
66 || s_lower.starts_with(&format!("{name_lower}::"))
67 || s_lower.contains(&format!("::{name_lower}"));
68
69 let file_match = file_filter.is_none_or(|f| s.file.contains(f));
70
71 let kind_match = kind_filter.is_none_or(|k| s.kind.to_lowercase() == k.to_lowercase());
72
73 name_match && file_match && kind_match
74 })
75 .collect();
76
77 results.sort_by(|a, b| {
78 let a_exact = a.name.to_lowercase() == name_lower;
79 let b_exact = b.name.to_lowercase() == name_lower;
80 b_exact.cmp(&a_exact).then_with(|| a.file.cmp(&b.file))
81 });
82
83 results
84}
85
86fn render_single(sym: &SymbolEntry, index: &ProjectIndex, project_root: &str) -> (String, usize) {
87 let abs_path = resolve_file_path(&sym.file, project_root);
88
89 let Ok(content) = std::fs::read_to_string(&abs_path) else {
90 return (
91 format!(
92 "Symbol '{}' found at {}:L{}-{} but file unreadable",
93 sym.name, sym.file, sym.start_line, sym.end_line
94 ),
95 0,
96 );
97 };
98
99 let lines: Vec<&str> = content.lines().collect();
100 let start = sym.start_line.saturating_sub(1);
101 let end = sym.end_line.min(lines.len());
102 let snippet: String = lines[start..end]
103 .iter()
104 .enumerate()
105 .map(|(i, line)| format!("{:>4}|{}", start + i + 1, line))
106 .collect::<Vec<_>>()
107 .join("\n");
108
109 let full_tokens = count_tokens(&content);
110 let snippet_tokens = count_tokens(&snippet);
111
112 let vis = if sym.is_exported { "+" } else { "-" };
113 let header = format!(
114 "{}::{} ({} {}, L{}-{})",
115 sym.file, sym.name, vis, sym.kind, sym.start_line, sym.end_line
116 );
117
118 let file_info = index.files.get(&sym.file);
119 let ctx = if let Some(f) = file_info {
120 format!(
121 "File: {} ({} lines, {} tokens)",
122 sym.file, f.line_count, f.token_count
123 )
124 } else {
125 format!("File: {}", sym.file)
126 };
127
128 let savings = protocol::format_savings(full_tokens, snippet_tokens);
129
130 (
131 format!("{header}\n{ctx}\n\n{snippet}\n{savings}"),
132 full_tokens,
133 )
134}
135
136fn render_multiple(
137 symbols: &[&SymbolEntry],
138 index: &ProjectIndex,
139 project_root: &str,
140) -> (String, usize) {
141 let mut out = String::new();
142 let mut total_original = 0usize;
143
144 for (i, sym) in symbols.iter().enumerate() {
145 if i > 0 {
146 out.push_str("\n---\n\n");
147 }
148 let (rendered, orig) = render_single(sym, index, project_root);
149 out.push_str(&rendered);
150 total_original = total_original.max(orig);
151 }
152
153 (out, total_original)
154}
155
156fn resolve_file_path(relative: &str, project_root: &str) -> String {
157 let p = Path::new(relative);
158 if p.is_absolute() && p.exists() {
159 return relative.to_string();
160 }
161 let joined = Path::new(project_root).join(relative);
162 if joined.exists() {
163 return joined.to_string_lossy().to_string();
164 }
165 relative.to_string()
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::core::graph_index::{ProjectIndex, SymbolEntry};
172
173 fn test_index() -> ProjectIndex {
174 let mut index = ProjectIndex::new("/tmp/test");
175 index.symbols.insert(
176 "src/main.rs::main".to_string(),
177 SymbolEntry {
178 file: "src/main.rs".to_string(),
179 name: "main".to_string(),
180 kind: "fn".to_string(),
181 start_line: 1,
182 end_line: 10,
183 is_exported: false,
184 },
185 );
186 index.symbols.insert(
187 "src/lib.rs::Config".to_string(),
188 SymbolEntry {
189 file: "src/lib.rs".to_string(),
190 name: "Config".to_string(),
191 kind: "struct".to_string(),
192 start_line: 5,
193 end_line: 20,
194 is_exported: true,
195 },
196 );
197 index.symbols.insert(
198 "src/lib.rs::Config::load".to_string(),
199 SymbolEntry {
200 file: "src/lib.rs".to_string(),
201 name: "Config::load".to_string(),
202 kind: "method".to_string(),
203 start_line: 22,
204 end_line: 35,
205 is_exported: true,
206 },
207 );
208 index
209 }
210
211 #[test]
212 fn find_exact_match() {
213 let index = test_index();
214 let results = find_symbols(&index, "main", None, None);
215 assert_eq!(results.len(), 1);
216 assert_eq!(results[0].name, "main");
217 }
218
219 #[test]
220 fn find_with_kind_filter() {
221 let index = test_index();
222 let results = find_symbols(&index, "Config", None, Some("struct"));
223 assert_eq!(results.len(), 1);
224 assert_eq!(results[0].kind, "struct");
225 }
226
227 #[test]
228 fn find_with_file_filter() {
229 let index = test_index();
230 let results = find_symbols(&index, "Config", Some("lib.rs"), None);
231 assert_eq!(results.len(), 2);
232 }
233
234 #[test]
235 fn no_match_returns_empty() {
236 let index = test_index();
237 let results = find_symbols(&index, "nonexistent", None, None);
238 assert!(results.is_empty());
239 }
240}