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 const HEADER_EXTS: &[&str] = &["h", "hpp", "hxx", "hh"];
204 let preferred = symbols
205 .iter()
206 .find(|s| {
207 let ext = std::path::Path::new(&s.path)
208 .extension()
209 .and_then(|e| e.to_str())
210 .unwrap_or("");
211 !HEADER_EXTS.contains(&ext)
212 })
213 .or_else(|| symbols.first())
214 .cloned();
215 preferred?
216 };
217
218 if !is_file_fresh(store, &target.path, project_root, dirty_files) {
220 return None;
221 }
222
223 let abs_path = project_root.join(&target.path);
225 let content = std::fs::read_to_string(&abs_path).ok()?;
226 let all_lines: Vec<&str> = content.lines().collect();
227
228 let start = target.range_start_line as usize;
229 let end = (target.range_end_line as usize + 1).min(all_lines.len());
230
231 if start >= all_lines.len() {
232 return None;
233 }
234
235 let selected = &all_lines[start..end];
236
237 let lines: &[&str] = if signature_only {
238 let sig_end = selected
239 .iter()
240 .position(|l| l.contains('{'))
241 .map_or(1, |i| i + 1);
242 &selected[..sig_end.min(selected.len())]
243 } else {
244 selected
245 };
246
247 let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
248 let truncated = lines.len() > max;
249 let lines = if truncated { &lines[..max] } else { lines };
250
251 let numbered = format_numbered_lines(lines, start + 1);
252
253 let display_from = start + 1;
254 let display_to = start + lines.len();
255
256 Some(json!({
257 "path": target.path,
258 "symbol": target.name,
259 "kind": target.kind,
260 "content": numbered,
261 "from": display_from,
262 "to": display_to,
263 "truncated": truncated,
264 }))
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn make_store_with_symbols() -> (IndexStore, tempfile::TempDir) {
272 let dir = tempfile::tempdir().unwrap();
273 let store = IndexStore::open_in_memory().unwrap();
274
275 let src_dir = dir.path().join("src");
277 std::fs::create_dir_all(&src_dir).unwrap();
278 std::fs::write(
279 src_dir.join("lib.rs"),
280 "// 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",
281 ).unwrap();
282
283 let hash = hasher::hash_file(&src_dir.join("lib.rs")).unwrap();
285 store.upsert_file("src/lib.rs", &hash).unwrap();
286
287 let symbols = vec![
289 CachedSymbol {
290 name: "Config".into(),
291 kind: "struct".into(),
292 path: "src/lib.rs".into(),
293 range_start_line: 1,
294 range_start_col: 0,
295 range_end_line: 4,
296 range_end_col: 1,
297 parent_name: None,
298 },
299 CachedSymbol {
300 name: "name".into(),
301 kind: "field".into(),
302 path: "src/lib.rs".into(),
303 range_start_line: 2,
304 range_start_col: 4,
305 range_end_line: 2,
306 range_end_col: 20,
307 parent_name: Some("Config".into()),
308 },
309 CachedSymbol {
310 name: "value".into(),
311 kind: "field".into(),
312 path: "src/lib.rs".into(),
313 range_start_line: 3,
314 range_start_col: 4,
315 range_end_line: 3,
316 range_end_col: 15,
317 parent_name: Some("Config".into()),
318 },
319 CachedSymbol {
320 name: "new".into(),
321 kind: "function".into(),
322 path: "src/lib.rs".into(),
323 range_start_line: 7,
324 range_start_col: 4,
325 range_end_line: 9,
326 range_end_col: 5,
327 parent_name: Some("Config".into()),
328 },
329 ];
330 store.insert_symbols("src/lib.rs", &symbols).unwrap();
331
332 (store, dir)
333 }
334
335 #[test]
338 fn file_freshness_matches() {
339 let (store, dir) = make_store_with_symbols();
340 assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), None));
341 }
342
343 #[test]
344 fn file_freshness_stale_after_modify() {
345 let (store, dir) = make_store_with_symbols();
346 std::fs::write(dir.path().join("src/lib.rs"), "modified content").unwrap();
347 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), None));
348 }
349
350 #[test]
351 fn file_freshness_missing_file() {
352 let store = IndexStore::open_in_memory().unwrap();
353 let dir = tempfile::tempdir().unwrap();
354 assert!(!is_file_fresh(&store, "nonexistent.rs", dir.path(), None));
355 }
356
357 #[test]
360 fn fresh_with_watcher_clean_file() {
361 let (store, dir) = make_store_with_symbols();
362 let df = DirtyFiles::new();
363 assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
365 }
366
367 #[test]
368 fn stale_with_watcher_dirty_file() {
369 let (store, dir) = make_store_with_symbols();
370 let df = DirtyFiles::new();
371 df.mark_dirty("src/lib.rs".to_string());
372 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
374 }
375
376 #[test]
377 fn stale_with_watcher_poisoned() {
378 let (store, dir) = make_store_with_symbols();
379 let df = DirtyFiles::new();
380 df.poison();
381 assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
383 }
384
385 #[test]
386 fn stale_with_watcher_not_indexed() {
387 let store = IndexStore::open_in_memory().unwrap();
388 let dir = tempfile::tempdir().unwrap();
389 let df = DirtyFiles::new();
390 assert!(!is_file_fresh(&store, "unknown.rs", dir.path(), Some(&df)));
392 }
393
394 #[test]
397 fn find_symbol_from_cache() {
398 let (store, dir) = make_store_with_symbols();
399 let results = cached_find_symbol(&store, "Config", dir.path(), None).unwrap();
400 assert_eq!(results.len(), 1);
401 assert_eq!(results[0].kind, "struct");
402 assert_eq!(results[0].line, 2);
403 assert_eq!(results[0].path, "src/lib.rs");
404 assert!(results[0].preview.contains("struct Config"));
405 }
406
407 #[test]
408 fn find_symbol_stale_file() {
409 let (store, dir) = make_store_with_symbols();
410 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
411 assert!(cached_find_symbol(&store, "Config", dir.path(), None).is_none());
412 }
413
414 #[test]
415 fn find_symbol_dirty_via_watcher() {
416 let (store, dir) = make_store_with_symbols();
417 let df = DirtyFiles::new();
418 df.mark_dirty("src/lib.rs".to_string());
419 assert!(cached_find_symbol(&store, "Config", dir.path(), Some(&df)).is_none());
420 }
421
422 #[test]
423 fn find_symbol_clean_via_watcher() {
424 let (store, dir) = make_store_with_symbols();
425 let df = DirtyFiles::new();
426 let results = cached_find_symbol(&store, "Config", dir.path(), Some(&df)).unwrap();
427 assert_eq!(results.len(), 1);
428 }
429
430 #[test]
431 fn find_symbol_not_in_cache() {
432 let (store, dir) = make_store_with_symbols();
433 assert!(cached_find_symbol(&store, "NonExistent", dir.path(), None).is_none());
434 }
435
436 #[test]
439 fn list_symbols_from_cache() {
440 let (store, dir) = make_store_with_symbols();
441 let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
442 assert_eq!(symbols.len(), 1);
443 assert_eq!(symbols[0].name, "Config");
444 assert_eq!(symbols[0].children.len(), 3);
445 }
446
447 #[test]
448 fn list_symbols_depth_1() {
449 let (store, dir) = make_store_with_symbols();
450 let symbols = cached_list_symbols(&store, "src/lib.rs", 1, dir.path(), None).unwrap();
451 assert_eq!(symbols.len(), 1);
452 assert!(symbols[0].children.is_empty());
453 }
454
455 #[test]
456 fn list_symbols_stale_file() {
457 let (store, dir) = make_store_with_symbols();
458 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
459 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
460 }
461
462 #[test]
463 fn list_symbols_dirty_via_watcher() {
464 let (store, dir) = make_store_with_symbols();
465 let df = DirtyFiles::new();
466 df.mark_dirty("src/lib.rs".to_string());
467 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), Some(&df)).is_none());
468 }
469
470 #[test]
473 fn read_symbol_from_cache() {
474 let (store, dir) = make_store_with_symbols();
475 let result = cached_read_symbol(&store, "Config", false, None, dir.path(), None).unwrap();
476 assert_eq!(result["path"], "src/lib.rs");
477 assert_eq!(result["symbol"], "Config");
478 assert_eq!(result["kind"], "struct");
479 assert_eq!(result["from"], 2);
480 assert_eq!(result["truncated"], false);
481 assert!(result["content"]
482 .as_str()
483 .unwrap()
484 .contains("struct Config"));
485 }
486
487 #[test]
488 fn read_symbol_signature_only() {
489 let (store, dir) = make_store_with_symbols();
490 let result = cached_read_symbol(&store, "Config", true, None, dir.path(), None).unwrap();
491 let content = result["content"].as_str().unwrap();
492 assert!(content.contains("struct Config"));
493 assert!(!content.contains("value"));
494 }
495
496 #[test]
497 fn read_symbol_dotted_name() {
498 let (store, dir) = make_store_with_symbols();
499 let result =
500 cached_read_symbol(&store, "Config.new", false, None, dir.path(), None).unwrap();
501 assert_eq!(result["symbol"], "new");
502 assert_eq!(result["kind"], "function");
503 assert!(result["content"].as_str().unwrap().contains("fn new"));
504 }
505
506 #[test]
507 fn read_symbol_stale_file() {
508 let (store, dir) = make_store_with_symbols();
509 std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
510 assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), None).is_none());
511 }
512
513 #[test]
514 fn read_symbol_dirty_via_watcher() {
515 let (store, dir) = make_store_with_symbols();
516 let df = DirtyFiles::new();
517 df.mark_dirty("src/lib.rs".to_string());
518 assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), Some(&df)).is_none());
519 }
520
521 #[test]
522 fn read_symbol_not_found() {
523 let (store, dir) = make_store_with_symbols();
524 assert!(cached_read_symbol(&store, "NonExistent", false, None, dir.path(), None).is_none());
525 }
526
527 #[test]
528 fn read_symbol_max_lines() {
529 let (store, dir) = make_store_with_symbols();
530 let result =
531 cached_read_symbol(&store, "Config", false, Some(2), dir.path(), None).unwrap();
532 assert_eq!(result["truncated"], true);
533 assert_eq!(result["to"], 3);
534 }
535
536 #[test]
537 fn hierarchy_preserves_line_numbers() {
538 let (store, dir) = make_store_with_symbols();
539 let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
540 assert_eq!(symbols[0].line, 2);
541 assert_eq!(symbols[0].end_line, 5);
542 }
543
544 #[test]
545 fn empty_index_returns_none() {
546 let store = IndexStore::open_in_memory().unwrap();
547 let dir = tempfile::tempdir().unwrap();
548
549 assert!(cached_find_symbol(&store, "Foo", dir.path(), None).is_none());
550 assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
551 assert!(cached_read_symbol(&store, "Foo", false, None, dir.path(), None).is_none());
552 }
553}