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