1use std::collections::HashMap;
19use std::path::Path;
20
21use crate::analytics::{Analytics, CallGraphNode, ImpactNode};
22use crate::db::Database;
23use crate::embeddings::{semantic_search, Embedding, EmbeddingProvider, SearchResult};
24use crate::error::{CtxError, Result};
25use crate::tokens::{count_file_tokens, select_by_token_budget, Encoding, HasTokenCount};
26
27#[derive(Debug, Clone)]
29pub struct SmartConfig {
30 pub max_tokens: usize,
32 pub depth: i32,
34 pub top: usize,
36 pub encoding: Encoding,
38}
39
40impl Default for SmartConfig {
41 fn default() -> Self {
42 Self {
43 max_tokens: 8000,
44 depth: 2,
45 top: 10,
46 encoding: Encoding::default(),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub enum SelectionReason {
54 SemanticMatch {
56 symbol: String,
58 score: f32,
60 },
61 CalledBy {
63 caller: String,
65 depth: i32,
67 },
68 Calls {
70 callee: String,
72 depth: i32,
74 },
75 #[allow(dead_code)] SameModule {
78 symbol: String,
80 },
81 #[allow(dead_code)] Explicit,
84}
85
86impl SelectionReason {
87 pub fn description(&self) -> String {
89 match self {
90 SelectionReason::SemanticMatch { symbol, score } => {
91 format!("SemanticMatch: \"{}\" (score: {:.2})", symbol, score)
92 }
93 SelectionReason::CalledBy { caller, depth } => {
94 format!("CalledBy: {} (depth {})", caller, depth)
95 }
96 SelectionReason::Calls { callee, depth } => {
97 format!("Calls: {} (depth {})", callee, depth)
98 }
99 SelectionReason::SameModule { symbol } => {
100 format!("SameModule: shares context with {}", symbol)
101 }
102 SelectionReason::Explicit => "Explicit: user requested".to_string(),
103 }
104 }
105
106 fn weight(&self) -> f32 {
108 match self {
109 SelectionReason::SemanticMatch { score, .. } => *score,
110 SelectionReason::CalledBy { depth, .. } => 0.8 / (*depth as f32).max(1.0),
111 SelectionReason::Calls { depth, .. } => 0.7 / (*depth as f32).max(1.0),
112 SelectionReason::SameModule { .. } => 0.5,
113 SelectionReason::Explicit => 1.0,
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct FileSelection {
121 pub path: String,
123 pub relevance_score: f32,
125 pub reasons: Vec<SelectionReason>,
127 pub token_count: usize,
129}
130
131impl HasTokenCount for FileSelection {
132 fn token_count(&self) -> usize {
133 self.token_count
134 }
135}
136
137impl FileSelection {
138 fn new(path: String, reason: SelectionReason) -> Self {
140 let score = reason.weight();
141 Self {
142 path,
143 relevance_score: score,
144 reasons: vec![reason],
145 token_count: 0,
146 }
147 }
148
149 fn add_reason(&mut self, reason: SelectionReason) {
151 let new_weight = reason.weight();
152 if new_weight > self.relevance_score {
154 self.relevance_score = new_weight;
155 }
156 self.reasons.push(reason);
157 }
158
159 fn best_semantic_score(&self) -> Option<f32> {
168 self.reasons
169 .iter()
170 .filter_map(|r| match r {
171 SelectionReason::SemanticMatch { score, .. } => Some(*score),
172 _ => None,
173 })
174 .reduce(f32::max)
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct SmartContext {
181 #[allow(dead_code)] pub task: String,
184 pub selected_files: Vec<FileSelection>,
186 pub total_tokens: usize,
188 pub truncated: bool,
190 pub omitted_count: usize,
192}
193
194pub fn smart_context(
209 db: &Database,
210 analytics: &Analytics,
211 provider: &dyn EmbeddingProvider,
212 task: &str,
213 config: SmartConfig,
214) -> Result<SmartContext> {
215 let task_embedding = provider.embed(task)?;
217
218 smart_context_with_embedding(db, analytics, task, &task_embedding, config)
219}
220
221pub fn smart_context_with_embedding(
228 db: &Database,
229 analytics: &Analytics,
230 task: &str,
231 task_embedding: &Embedding,
232 config: SmartConfig,
233) -> Result<SmartContext> {
234 let matches = semantic_search(db, task_embedding, config.top)?;
236
237 if matches.is_empty() {
238 return Err(CtxError::NoMatches);
239 }
240
241 let mut files: HashMap<String, FileSelection> = HashMap::new();
243
244 for result in &matches {
245 add_file(
247 &mut files,
248 &result.file_path,
249 SelectionReason::SemanticMatch {
250 symbol: result.name.clone(),
251 score: result.score,
252 },
253 );
254
255 expand_symbol(&mut files, analytics, result, config.depth)?;
257 }
258
259 let mut selections: Vec<FileSelection> = files.into_values().collect();
261
262 for selection in &mut selections {
264 selection.token_count = count_file_token_safe(&selection.path, config.encoding);
265 }
266
267 rank_files(&mut selections);
269
270 let (selected, total_tokens, omitted) = select_by_token_budget(selections, config.max_tokens);
272
273 Ok(SmartContext {
274 task: task.to_string(),
275 selected_files: selected,
276 total_tokens,
277 truncated: omitted > 0,
278 omitted_count: omitted,
279 })
280}
281
282fn add_file(files: &mut HashMap<String, FileSelection>, path: &str, reason: SelectionReason) {
284 if let Some(existing) = files.get_mut(path) {
285 existing.add_reason(reason);
286 } else {
287 files.insert(
288 path.to_string(),
289 FileSelection::new(path.to_string(), reason),
290 );
291 }
292}
293
294fn expand_symbol(
296 files: &mut HashMap<String, FileSelection>,
297 analytics: &Analytics,
298 result: &SearchResult,
299 depth: i32,
300) -> Result<()> {
301 if let Ok(callers) = analytics.impact_analysis(&result.symbol_id, depth) {
303 for caller in callers {
304 add_file_from_impact(files, &caller, &result.name);
305 }
306 }
307
308 if let Ok(callees) = analytics.call_graph(&result.symbol_id, depth) {
310 for callee in callees {
311 add_file_from_call_graph(files, &callee, &result.name);
312 }
313 }
314
315 Ok(())
316}
317
318fn add_file_from_impact(
320 files: &mut HashMap<String, FileSelection>,
321 node: &ImpactNode,
322 callee_name: &str,
323) {
324 add_file(
325 files,
326 &node.file_path,
327 SelectionReason::CalledBy {
328 caller: node.name.clone(),
329 depth: node.distance,
330 },
331 );
332
333 if let Some(existing) = files.get(&node.file_path) {
335 if existing
336 .reasons
337 .iter()
338 .any(|r| matches!(r, SelectionReason::SemanticMatch { .. }))
339 {
340 } else {
342 let _ = callee_name; }
345 }
346}
347
348fn add_file_from_call_graph(
350 files: &mut HashMap<String, FileSelection>,
351 node: &CallGraphNode,
352 caller_name: &str,
353) {
354 add_file(
355 files,
356 &node.file_path,
357 SelectionReason::Calls {
358 callee: node.name.clone(),
359 depth: node.depth,
360 },
361 );
362
363 let _ = caller_name; }
365
366fn rank_files(files: &mut [FileSelection]) {
378 files.sort_by(|a, b| {
379 let a_sem = a.best_semantic_score();
380 let b_sem = b.best_semantic_score();
381 let a_key = (a_sem.is_none(), a_sem.unwrap_or(a.relevance_score));
385 let b_key = (b_sem.is_none(), b_sem.unwrap_or(b.relevance_score));
386 a_key
387 .0
388 .cmp(&b_key.0)
389 .then_with(|| {
390 b_key
391 .1
392 .partial_cmp(&a_key.1)
393 .unwrap_or(std::cmp::Ordering::Equal)
394 })
395 .then_with(|| a.path.cmp(&b.path))
396 });
397}
398
399fn count_file_token_safe(path: &str, encoding: Encoding) -> usize {
401 count_file_tokens(Path::new(path), encoding)
402 .map(|tc| tc.count)
403 .unwrap_or(0)
404}
405
406pub fn format_explain(result: &SmartContext) -> String {
408 let mut output = String::new();
409
410 output.push_str(&format!(
411 "Selected {} files ({} tokens):\n\n",
412 result.selected_files.len(),
413 result.total_tokens
414 ));
415
416 for (i, file) in result.selected_files.iter().enumerate() {
417 output.push_str(&format!(
418 "{}. {} ({} tokens)\n",
419 i + 1,
420 file.path,
421 file.token_count
422 ));
423
424 for reason in &file.reasons {
425 output.push_str(&format!(" - {}\n", reason.description()));
426 }
427 output.push('\n');
428 }
429
430 if result.truncated {
431 output.push_str(&format!(
432 "({} files omitted due to token limit)\n",
433 result.omitted_count
434 ));
435 }
436
437 output
438}
439
440pub fn format_dry_run(result: &SmartContext) -> String {
442 let mut output = String::new();
443
444 output.push_str(&format!(
445 "Would select {} files ({} tokens):\n",
446 result.selected_files.len(),
447 result.total_tokens
448 ));
449
450 for file in &result.selected_files {
451 let primary_reason = file
452 .reasons
453 .first()
454 .map(|r| match r {
455 SelectionReason::SemanticMatch { .. } => "SemanticMatch",
456 SelectionReason::CalledBy { .. } => "CalledBy",
457 SelectionReason::Calls { .. } => "Calls",
458 SelectionReason::SameModule { .. } => "SameModule",
459 SelectionReason::Explicit => "Explicit",
460 })
461 .unwrap_or("Unknown");
462
463 output.push_str(&format!(
464 " {} ({} tokens) - {}\n",
465 file.path, file.token_count, primary_reason
466 ));
467 }
468
469 if result.truncated {
470 output.push_str(&format!(
471 "\n({} files would be omitted due to token limit)\n",
472 result.omitted_count
473 ));
474 }
475
476 output
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_selection_reason_weight() {
485 let semantic = SelectionReason::SemanticMatch {
486 symbol: "test".to_string(),
487 score: 0.9,
488 };
489 assert!((semantic.weight() - 0.9).abs() < 0.001);
490
491 let called_by = SelectionReason::CalledBy {
492 caller: "main".to_string(),
493 depth: 1,
494 };
495 assert!((called_by.weight() - 0.8).abs() < 0.001);
496
497 let called_by_depth2 = SelectionReason::CalledBy {
498 caller: "main".to_string(),
499 depth: 2,
500 };
501 assert!((called_by_depth2.weight() - 0.4).abs() < 0.001);
502
503 let calls = SelectionReason::Calls {
504 callee: "helper".to_string(),
505 depth: 1,
506 };
507 assert!((calls.weight() - 0.7).abs() < 0.001);
508
509 let same_module = SelectionReason::SameModule {
510 symbol: "related".to_string(),
511 };
512 assert!((same_module.weight() - 0.5).abs() < 0.001);
513
514 let explicit = SelectionReason::Explicit;
515 assert!((explicit.weight() - 1.0).abs() < 0.001);
516 }
517
518 #[test]
519 fn test_file_selection_add_reason() {
520 let mut selection = FileSelection::new(
521 "src/main.rs".to_string(),
522 SelectionReason::Calls {
523 callee: "helper".to_string(),
524 depth: 1,
525 },
526 );
527
528 assert!((selection.relevance_score - 0.7).abs() < 0.001);
529 assert_eq!(selection.reasons.len(), 1);
530
531 selection.add_reason(SelectionReason::SemanticMatch {
533 symbol: "main".to_string(),
534 score: 0.95,
535 });
536
537 assert!((selection.relevance_score - 0.95).abs() < 0.001);
538 assert_eq!(selection.reasons.len(), 2);
539 }
540
541 #[test]
542 fn test_select_by_token_budget() {
543 let files = vec![
544 FileSelection {
545 path: "a.rs".to_string(),
546 relevance_score: 1.0,
547 reasons: vec![SelectionReason::Explicit],
548 token_count: 100,
549 },
550 FileSelection {
551 path: "b.rs".to_string(),
552 relevance_score: 0.8,
553 reasons: vec![SelectionReason::Explicit],
554 token_count: 200,
555 },
556 FileSelection {
557 path: "c.rs".to_string(),
558 relevance_score: 0.5,
559 reasons: vec![SelectionReason::Explicit],
560 token_count: 150,
561 },
562 ];
563
564 let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
566 assert_eq!(selected.len(), 3);
567 assert_eq!(total, 450);
568 assert_eq!(omitted, 0);
569
570 let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
572 assert_eq!(selected.len(), 2);
573 assert_eq!(total, 300);
574 assert_eq!(omitted, 1);
575
576 let (selected, total, omitted) = select_by_token_budget(files, 150);
578 assert_eq!(selected.len(), 1);
579 assert_eq!(total, 100);
580 assert_eq!(omitted, 2);
581 }
582
583 #[test]
584 fn test_format_dry_run() {
585 let result = SmartContext {
586 task: "add caching".to_string(),
587 selected_files: vec![
588 FileSelection {
589 path: "src/main.rs".to_string(),
590 relevance_score: 0.9,
591 reasons: vec![SelectionReason::SemanticMatch {
592 symbol: "main".to_string(),
593 score: 0.9,
594 }],
595 token_count: 500,
596 },
597 FileSelection {
598 path: "src/lib.rs".to_string(),
599 relevance_score: 0.7,
600 reasons: vec![SelectionReason::Calls {
601 callee: "helper".to_string(),
602 depth: 1,
603 }],
604 token_count: 300,
605 },
606 ],
607 total_tokens: 800,
608 truncated: false,
609 omitted_count: 0,
610 };
611
612 let output = format_dry_run(&result);
613 assert!(output.contains("Would select 2 files"));
614 assert!(output.contains("src/main.rs"));
615 assert!(output.contains("SemanticMatch"));
616 }
617
618 #[test]
619 fn test_smart_config_default() {
620 let config = SmartConfig::default();
621 assert_eq!(config.max_tokens, 8000);
622 assert_eq!(config.depth, 2);
623 assert_eq!(config.top, 10);
624 }
625
626 #[test]
627 fn test_add_file_merges_reasons() {
628 let mut files: HashMap<String, FileSelection> = HashMap::new();
629
630 add_file(
632 &mut files,
633 "src/main.rs",
634 SelectionReason::SemanticMatch {
635 symbol: "main".to_string(),
636 score: 0.9,
637 },
638 );
639 assert_eq!(files.len(), 1);
640 assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
641
642 add_file(
644 &mut files,
645 "src/main.rs",
646 SelectionReason::CalledBy {
647 caller: "run".to_string(),
648 depth: 1,
649 },
650 );
651 assert_eq!(files.len(), 1); assert_eq!(files.get("src/main.rs").unwrap().reasons.len(), 2);
653 assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
655 }
656
657 #[test]
658 fn test_rank_files_sorts_by_relevance() {
659 let mut files = vec![
660 FileSelection {
661 path: "low.rs".to_string(),
662 relevance_score: 0.3,
663 reasons: vec![SelectionReason::Explicit],
664 token_count: 100,
665 },
666 FileSelection {
667 path: "high.rs".to_string(),
668 relevance_score: 0.9,
669 reasons: vec![SelectionReason::Explicit],
670 token_count: 100,
671 },
672 FileSelection {
673 path: "mid.rs".to_string(),
674 relevance_score: 0.5,
675 reasons: vec![SelectionReason::Explicit],
676 token_count: 100,
677 },
678 ];
679
680 rank_files(&mut files);
681
682 assert_eq!(files[0].path, "high.rs");
683 assert_eq!(files[1].path, "mid.rs");
684 assert_eq!(files[2].path, "low.rs");
685 }
686
687 #[test]
692 fn test_semantic_match_ranks_above_graph_only() {
693 let mut files = vec![
694 FileSelection {
695 path: "graph_hub.rs".to_string(),
696 relevance_score: 0.8, reasons: vec![SelectionReason::CalledBy {
698 caller: "run".to_string(),
699 depth: 1,
700 }],
701 token_count: 100,
702 },
703 FileSelection {
704 path: "on_topic.rs".to_string(),
705 relevance_score: 0.5,
706 reasons: vec![SelectionReason::SemanticMatch {
707 symbol: "run_sql".to_string(),
708 score: 0.5,
709 }],
710 token_count: 100,
711 },
712 ];
713
714 rank_files(&mut files);
715
716 assert_eq!(
717 files[0].path, "on_topic.rs",
718 "semantic match must outrank a higher-weighted graph-only file"
719 );
720 assert_eq!(files[1].path, "graph_hub.rs");
721 }
722
723 #[test]
726 fn test_semantic_tier_orders_by_score_then_path() {
727 let mk = |path: &str, score: f32| FileSelection {
728 path: path.to_string(),
729 relevance_score: score,
730 reasons: vec![SelectionReason::SemanticMatch {
731 symbol: "s".to_string(),
732 score,
733 }],
734 token_count: 100,
735 };
736 let mut files = vec![mk("b.rs", 0.6), mk("c.rs", 0.4), mk("a.rs", 0.6)];
738
739 rank_files(&mut files);
740
741 assert_eq!(
742 files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
743 vec!["a.rs", "b.rs", "c.rs"]
744 );
745 }
746
747 #[test]
751 fn test_rank_files_is_deterministic() {
752 let make_set = || {
753 vec![
754 FileSelection {
755 path: "z_graph.rs".to_string(),
756 relevance_score: 0.8,
757 reasons: vec![SelectionReason::CalledBy {
758 caller: "run".to_string(),
759 depth: 1,
760 }],
761 token_count: 100,
762 },
763 FileSelection {
764 path: "a_graph.rs".to_string(),
765 relevance_score: 0.8, reasons: vec![SelectionReason::CalledBy {
767 caller: "run".to_string(),
768 depth: 1,
769 }],
770 token_count: 100,
771 },
772 FileSelection {
773 path: "seed.rs".to_string(),
774 relevance_score: 0.5,
775 reasons: vec![SelectionReason::SemanticMatch {
776 symbol: "seed".to_string(),
777 score: 0.5,
778 }],
779 token_count: 100,
780 },
781 ]
782 };
783
784 let mut a = make_set();
785 rank_files(&mut a);
786 let order_a: Vec<String> = a.iter().map(|f| f.path.clone()).collect();
787
788 let mut b = make_set();
790 b.reverse();
791 rank_files(&mut b);
792 let order_b: Vec<String> = b.iter().map(|f| f.path.clone()).collect();
793
794 assert_eq!(order_a, order_b);
795 assert_eq!(order_a, vec!["seed.rs", "a_graph.rs", "z_graph.rs"]);
797 }
798
799 #[test]
800 fn test_selection_reason_description() {
801 let semantic = SelectionReason::SemanticMatch {
802 symbol: "test".to_string(),
803 score: 0.9,
804 };
805 assert!(semantic.description().contains("SemanticMatch"));
806 assert!(semantic.description().contains("test"));
807
808 let called_by = SelectionReason::CalledBy {
809 caller: "main".to_string(),
810 depth: 2,
811 };
812 assert!(called_by.description().contains("CalledBy"));
813 assert!(called_by.description().contains("main"));
814 assert!(called_by.description().contains("2"));
815 }
816}