1use std::collections::HashMap;
6use std::path::Path;
7
8use serde_json::{json, Value};
9
10use crate::commands::{
11 find::SymbolMatch, list::SymbolEntry, read::format_numbered_lines, DEFAULT_MAX_LINES,
12};
13use crate::index::hasher;
14use crate::index::store::{CachedSymbol, IndexStore};
15use crate::index::watcher::DirtyFiles;
16
17fn is_file_fresh(
25 store: &IndexStore,
26 rel_path: &str,
27 project_root: &Path,
28 dirty_files: Option<&DirtyFiles>,
29) -> bool {
30 if let Some(df) = dirty_files {
31 if df.is_dirty(rel_path) {
33 return false;
34 }
35 return store.get_file_hash(rel_path).ok().flatten().is_some();
37 }
38
39 let Some(stored_hash) = store.get_file_hash(rel_path).ok().flatten() else {
41 return false;
42 };
43 let abs_path = project_root.join(rel_path);
44 let Ok(current_hash) = hasher::hash_file(&abs_path) else {
45 return false;
46 };
47 stored_hash == current_hash
48}
49
50pub fn cached_find_symbol(
53 store: &IndexStore,
54 name: &str,
55 project_root: &Path,
56 dirty_files: Option<&DirtyFiles>,
57) -> Option<Vec<SymbolMatch>> {
58 let symbols = store.find_symbols_by_name(name).ok()?;
59 if symbols.is_empty() {
60 return None;
61 }
62
63 for sym in &symbols {
66 if !is_file_fresh(store, &sym.path, project_root, dirty_files) {
67 return None;
68 }
69 }
70
71 let mut by_path: HashMap<&str, Vec<usize>> = HashMap::new();
73 for (i, sym) in symbols.iter().enumerate() {
74 by_path.entry(sym.path.as_str()).or_default().push(i);
75 }
76
77 let mut previews = vec![String::new(); symbols.len()];
78 for (rel_path, indices) in &by_path {
79 let abs = project_root.join(rel_path);
80 if let Ok(content) = std::fs::read_to_string(&abs) {
81 let lines: Vec<&str> = content.lines().collect();
82 for &idx in indices {
83 let line_no = symbols[idx].range_start_line as usize;
85 previews[idx] = lines.get(line_no).unwrap_or(&"").trim().to_string();
86 }
87 }
88 }
89
90 let mut results: Vec<SymbolMatch> = symbols
91 .into_iter()
92 .zip(previews)
93 .map(|(sym, preview)| SymbolMatch {
94 path: sym.path,
95 line: sym.range_start_line + 1,
97 kind: sym.kind,
98 preview,
99 body: None,
100 })
101 .collect();
102
103 results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
104 Some(results)
105}
106
107fn build_hierarchy(symbols: &[CachedSymbol], max_depth: u8) -> Vec<SymbolEntry> {
113 let top_level: Vec<&CachedSymbol> =
115 symbols.iter().filter(|s| s.parent_name.is_none()).collect();
116
117 top_level
118 .into_iter()
119 .map(|sym| build_entry(sym, symbols, max_depth, 1))
120 .collect()
121}
122
123fn build_entry(
124 sym: &CachedSymbol,
125 all_symbols: &[CachedSymbol],
126 max_depth: u8,
127 current_depth: u8,
128) -> SymbolEntry {
129 let children = if current_depth < max_depth {
130 all_symbols
131 .iter()
132 .filter(|s| s.parent_name.as_deref() == Some(&sym.name))
133 .map(|child| build_entry(child, all_symbols, max_depth, current_depth + 1))
134 .collect()
135 } else {
136 Vec::new()
137 };
138
139 SymbolEntry {
140 name: sym.name.clone(),
141 kind: sym.kind.clone(),
142 line: sym.range_start_line + 1, end_line: sym.range_end_line + 1,
144 children,
145 }
146}
147
148pub fn cached_list_symbols(
150 store: &IndexStore,
151 rel_path: &str,
152 depth: u8,
153 project_root: &Path,
154 dirty_files: Option<&DirtyFiles>,
155) -> Option<Vec<SymbolEntry>> {
156 if !is_file_fresh(store, rel_path, project_root, dirty_files) {
157 return None;
158 }
159
160 let symbols = store.find_symbols_by_path(rel_path).ok()?;
161 if symbols.is_empty() {
162 return None;
163 }
164
165 Some(build_hierarchy(&symbols, depth))
166}
167
168pub fn cached_read_symbol(
173 store: &IndexStore,
174 name: &str,
175 signature_only: bool,
176 max_lines: Option<u32>,
177 project_root: &Path,
178 dirty_files: Option<&DirtyFiles>,
179) -> Option<Value> {
180 let (search_name, child_name) = if let Some(dot_pos) = name.find('.') {
181 (&name[..dot_pos], Some(&name[dot_pos + 1..]))
182 } else {
183 (name, None)
184 };
185
186 let symbols = store.find_symbols_by_name(search_name).ok()?;
187 if symbols.is_empty() {
188 return None;
189 }
190
191 let target = if let Some(child) = child_name {
193 let parent = symbols.first()?;
195 let file_symbols = store.find_symbols_by_path(&parent.path).ok()?;
196 file_symbols
197 .into_iter()
198 .find(|s| s.name == child && s.parent_name.as_deref() == Some(search_name))?
199 } else {
200 symbols.into_iter().next()?
201 };
202
203 if !is_file_fresh(store, &target.path, project_root, dirty_files) {
205 return None;
206 }
207
208 let abs_path = project_root.join(&target.path);
210 let content = std::fs::read_to_string(&abs_path).ok()?;
211 let all_lines: Vec<&str> = content.lines().collect();
212
213 let start = target.range_start_line as usize;
214 let end = (target.range_end_line as usize + 1).min(all_lines.len());
215
216 if start >= all_lines.len() {
217 return None;
218 }
219
220 let selected = &all_lines[start..end];
221
222 let lines: &[&str] = if signature_only {
223 let sig_end = selected
224 .iter()
225 .position(|l| l.contains('{'))
226 .map_or(1, |i| i + 1);
227 &selected[..sig_end.min(selected.len())]
228 } else {
229 selected
230 };
231
232 let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
233 let truncated = lines.len() > max;
234 let lines = if truncated { &lines[..max] } else { lines };
235
236 let numbered = format_numbered_lines(lines, start + 1);
237
238 let display_from = start + 1;
239 let display_to = start + lines.len();
240
241 Some(json!({
242 "path": target.path,
243 "symbol": target.name,
244 "kind": target.kind,
245 "content": numbered,
246 "from": display_from,
247 "to": display_to,
248 "truncated": truncated,
249 }))
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn make_store_with_symbols() -> (IndexStore, tempfile::TempDir) {
257 let dir = tempfile::tempdir().unwrap();
258 let store = IndexStore::open_in_memory().unwrap();
259
260 let src_dir = dir.path().join("src");
262 std::fs::create_dir_all(&src_dir).unwrap();
263 std::fs::write(
264 src_dir.join("lib.rs"),
265 "// line 1\nstruct Config {\n name: String,\n value: u32,\n}\n\nimpl Config {\n fn new() -> Self {\n Config { name: String::new(), value: 0 }\n }\n}\n",
266 ).unwrap();
267
268 let hash = hasher::hash_file(&src_dir.join("lib.rs")).unwrap();
270 store.upsert_file("src/lib.rs", &hash).unwrap();
271
272 let symbols = vec![
274 CachedSymbol {
275 name: "Config".into(),
276 kind: "struct".into(),
277 path: "src/lib.rs".into(),
278 range_start_line: 1,
279 range_start_col: 0,
280 range_end_line: 4,
281 range_end_col: 1,
282 parent_name: None,
283 },
284 CachedSymbol {
285 name: "name".into(),
286 kind: "field".into(),
287 path: "src/lib.rs".into(),
288 range_start_line: 2,
289 range_start_col: 4,
290 range_end_line: 2,
291 range_end_col: 20,
292 parent_name: Some("Config".into()),
293 },
294 CachedSymbol {
295 name: "value".into(),
296 kind: "field".into(),
297 path: "src/lib.rs".into(),
298 range_start_line: 3,
299 range_start_col: 4,
300 range_end_line: 3,
301 range_end_col: 15,
302 parent_name: Some("Config".into()),
303 },
304 CachedSymbol {
305 name: "new".into(),
306 kind: "function".into(),
307 path: "src/lib.rs".into(),
308 range_start_line: 7,
309 range_start_col: 4,
310 range_end_line: 9,
311 range_end_col: 5,
312 parent_name: Some("Config".into()),
313 },
314 ];
315 store.insert_symbols("src/lib.rs", &symbols).unwrap();
316
317 (store, dir)
318 }
319
320 #[test]
323 fn file_freshness_matches() {
324 let (store, dir) = make_store_with_symbols();
325 assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), None));
326 }
327
328 #[test]
329 fn file_freshness_stale_after_modify() {
330 let (store, dir) = make_store_with_symbols();
331 std::fs::write(dir.path().join("src/lib.rs"), "modified content").unwrap();
332 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), None));
333 }
334
335 #[test]
336 fn file_freshness_missing_file() {
337 let store = IndexStore::open_in_memory().unwrap();
338 let dir = tempfile::tempdir().unwrap();
339 assert!(!is_file_fresh(&store, "nonexistent.rs", dir.path(), None));
340 }
341
342 #[test]
345 fn fresh_with_watcher_clean_file() {
346 let (store, dir) = make_store_with_symbols();
347 let df = DirtyFiles::new();
348 assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
350 }
351
352 #[test]
353 fn stale_with_watcher_dirty_file() {
354 let (store, dir) = make_store_with_symbols();
355 let df = DirtyFiles::new();
356 df.mark_dirty("src/lib.rs".to_string());
357 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
359 }
360
361 #[test]
362 fn stale_with_watcher_poisoned() {
363 let (store, dir) = make_store_with_symbols();
364 let df = DirtyFiles::new();
365 df.poison();
366 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
368 }
369
370 #[test]
371 fn stale_with_watcher_not_indexed() {
372 let store = IndexStore::open_in_memory().unwrap();
373 let dir = tempfile::tempdir().unwrap();
374 let df = DirtyFiles::new();
375 assert!(!is_file_fresh(&store, "unknown.rs", dir.path(), Some(&df)));
377 }
378
379 #[test]
382 fn find_symbol_from_cache() {
383 let (store, dir) = make_store_with_symbols();
384 let results = cached_find_symbol(&store, "Config", dir.path(), None).unwrap();
385 assert_eq!(results.len(), 1);
386 assert_eq!(results[0].kind, "struct");
387 assert_eq!(results[0].line, 2);
388 assert_eq!(results[0].path, "src/lib.rs");
389 assert!(results[0].preview.contains("struct Config"));
390 }
391
392 #[test]
393 fn find_symbol_stale_file() {
394 let (store, dir) = make_store_with_symbols();
395 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
396 assert!(cached_find_symbol(&store, "Config", dir.path(), None).is_none());
397 }
398
399 #[test]
400 fn find_symbol_dirty_via_watcher() {
401 let (store, dir) = make_store_with_symbols();
402 let df = DirtyFiles::new();
403 df.mark_dirty("src/lib.rs".to_string());
404 assert!(cached_find_symbol(&store, "Config", dir.path(), Some(&df)).is_none());
405 }
406
407 #[test]
408 fn find_symbol_clean_via_watcher() {
409 let (store, dir) = make_store_with_symbols();
410 let df = DirtyFiles::new();
411 let results = cached_find_symbol(&store, "Config", dir.path(), Some(&df)).unwrap();
412 assert_eq!(results.len(), 1);
413 }
414
415 #[test]
416 fn find_symbol_not_in_cache() {
417 let (store, dir) = make_store_with_symbols();
418 assert!(cached_find_symbol(&store, "NonExistent", dir.path(), None).is_none());
419 }
420
421 #[test]
424 fn list_symbols_from_cache() {
425 let (store, dir) = make_store_with_symbols();
426 let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
427 assert_eq!(symbols.len(), 1);
428 assert_eq!(symbols[0].name, "Config");
429 assert_eq!(symbols[0].children.len(), 3);
430 }
431
432 #[test]
433 fn list_symbols_depth_1() {
434 let (store, dir) = make_store_with_symbols();
435 let symbols = cached_list_symbols(&store, "src/lib.rs", 1, dir.path(), None).unwrap();
436 assert_eq!(symbols.len(), 1);
437 assert!(symbols[0].children.is_empty());
438 }
439
440 #[test]
441 fn list_symbols_stale_file() {
442 let (store, dir) = make_store_with_symbols();
443 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
444 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
445 }
446
447 #[test]
448 fn list_symbols_dirty_via_watcher() {
449 let (store, dir) = make_store_with_symbols();
450 let df = DirtyFiles::new();
451 df.mark_dirty("src/lib.rs".to_string());
452 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), Some(&df)).is_none());
453 }
454
455 #[test]
458 fn read_symbol_from_cache() {
459 let (store, dir) = make_store_with_symbols();
460 let result = cached_read_symbol(&store, "Config", false, None, dir.path(), None).unwrap();
461 assert_eq!(result["path"], "src/lib.rs");
462 assert_eq!(result["symbol"], "Config");
463 assert_eq!(result["kind"], "struct");
464 assert_eq!(result["from"], 2);
465 assert_eq!(result["truncated"], false);
466 assert!(result["content"]
467 .as_str()
468 .unwrap()
469 .contains("struct Config"));
470 }
471
472 #[test]
473 fn read_symbol_signature_only() {
474 let (store, dir) = make_store_with_symbols();
475 let result = cached_read_symbol(&store, "Config", true, None, dir.path(), None).unwrap();
476 let content = result["content"].as_str().unwrap();
477 assert!(content.contains("struct Config"));
478 assert!(!content.contains("value"));
479 }
480
481 #[test]
482 fn read_symbol_dotted_name() {
483 let (store, dir) = make_store_with_symbols();
484 let result =
485 cached_read_symbol(&store, "Config.new", false, None, dir.path(), None).unwrap();
486 assert_eq!(result["symbol"], "new");
487 assert_eq!(result["kind"], "function");
488 assert!(result["content"].as_str().unwrap().contains("fn new"));
489 }
490
491 #[test]
492 fn read_symbol_stale_file() {
493 let (store, dir) = make_store_with_symbols();
494 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
495 assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), None).is_none());
496 }
497
498 #[test]
499 fn read_symbol_dirty_via_watcher() {
500 let (store, dir) = make_store_with_symbols();
501 let df = DirtyFiles::new();
502 df.mark_dirty("src/lib.rs".to_string());
503 assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), Some(&df)).is_none());
504 }
505
506 #[test]
507 fn read_symbol_not_found() {
508 let (store, dir) = make_store_with_symbols();
509 assert!(cached_read_symbol(&store, "NonExistent", false, None, dir.path(), None).is_none());
510 }
511
512 #[test]
513 fn read_symbol_max_lines() {
514 let (store, dir) = make_store_with_symbols();
515 let result =
516 cached_read_symbol(&store, "Config", false, Some(2), dir.path(), None).unwrap();
517 assert_eq!(result["truncated"], true);
518 assert_eq!(result["to"], 3);
519 }
520
521 #[test]
522 fn hierarchy_preserves_line_numbers() {
523 let (store, dir) = make_store_with_symbols();
524 let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
525 assert_eq!(symbols[0].line, 2);
526 assert_eq!(symbols[0].end_line, 5);
527 }
528
529 #[test]
530 fn empty_index_returns_none() {
531 let store = IndexStore::open_in_memory().unwrap();
532 let dir = tempfile::tempdir().unwrap();
533
534 assert!(cached_find_symbol(&store, "Foo", dir.path(), None).is_none());
535 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
536 assert!(cached_read_symbol(&store, "Foo", false, None, dir.path(), None).is_none());
537 }
538}