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
160#[derive(Debug, Clone)]
162pub struct SmartContext {
163 #[allow(dead_code)] pub task: String,
166 pub selected_files: Vec<FileSelection>,
168 pub total_tokens: usize,
170 pub truncated: bool,
172 pub omitted_count: usize,
174}
175
176pub fn smart_context(
191 db: &Database,
192 analytics: &Analytics,
193 provider: &dyn EmbeddingProvider,
194 task: &str,
195 config: SmartConfig,
196) -> Result<SmartContext> {
197 let task_embedding = provider.embed(task)?;
199
200 smart_context_with_embedding(db, analytics, task, &task_embedding, config)
201}
202
203pub fn smart_context_with_embedding(
210 db: &Database,
211 analytics: &Analytics,
212 task: &str,
213 task_embedding: &Embedding,
214 config: SmartConfig,
215) -> Result<SmartContext> {
216 let matches = semantic_search(db, task_embedding, config.top)?;
218
219 if matches.is_empty() {
220 return Err(CtxError::NoMatches);
221 }
222
223 let mut files: HashMap<String, FileSelection> = HashMap::new();
225
226 for result in &matches {
227 add_file(
229 &mut files,
230 &result.file_path,
231 SelectionReason::SemanticMatch {
232 symbol: result.name.clone(),
233 score: result.score,
234 },
235 );
236
237 expand_symbol(&mut files, analytics, result, config.depth)?;
239 }
240
241 let mut selections: Vec<FileSelection> = files.into_values().collect();
243
244 for selection in &mut selections {
246 selection.token_count = count_file_token_safe(&selection.path, config.encoding);
247 }
248
249 rank_files(&mut selections);
251
252 let (selected, total_tokens, omitted) = select_by_token_budget(selections, config.max_tokens);
254
255 Ok(SmartContext {
256 task: task.to_string(),
257 selected_files: selected,
258 total_tokens,
259 truncated: omitted > 0,
260 omitted_count: omitted,
261 })
262}
263
264fn add_file(files: &mut HashMap<String, FileSelection>, path: &str, reason: SelectionReason) {
266 if let Some(existing) = files.get_mut(path) {
267 existing.add_reason(reason);
268 } else {
269 files.insert(
270 path.to_string(),
271 FileSelection::new(path.to_string(), reason),
272 );
273 }
274}
275
276fn expand_symbol(
278 files: &mut HashMap<String, FileSelection>,
279 analytics: &Analytics,
280 result: &SearchResult,
281 depth: i32,
282) -> Result<()> {
283 if let Ok(callers) = analytics.impact_analysis(&result.symbol_id, depth) {
285 for caller in callers {
286 add_file_from_impact(files, &caller, &result.name);
287 }
288 }
289
290 if let Ok(callees) = analytics.call_graph(&result.symbol_id, depth) {
292 for callee in callees {
293 add_file_from_call_graph(files, &callee, &result.name);
294 }
295 }
296
297 Ok(())
298}
299
300fn add_file_from_impact(
302 files: &mut HashMap<String, FileSelection>,
303 node: &ImpactNode,
304 callee_name: &str,
305) {
306 add_file(
307 files,
308 &node.file_path,
309 SelectionReason::CalledBy {
310 caller: node.name.clone(),
311 depth: node.distance,
312 },
313 );
314
315 if let Some(existing) = files.get(&node.file_path) {
317 if existing
318 .reasons
319 .iter()
320 .any(|r| matches!(r, SelectionReason::SemanticMatch { .. }))
321 {
322 } else {
324 let _ = callee_name; }
327 }
328}
329
330fn add_file_from_call_graph(
332 files: &mut HashMap<String, FileSelection>,
333 node: &CallGraphNode,
334 caller_name: &str,
335) {
336 add_file(
337 files,
338 &node.file_path,
339 SelectionReason::Calls {
340 callee: node.name.clone(),
341 depth: node.depth,
342 },
343 );
344
345 let _ = caller_name; }
347
348fn rank_files(files: &mut [FileSelection]) {
350 files.sort_by(|a, b| {
351 b.relevance_score
352 .partial_cmp(&a.relevance_score)
353 .unwrap_or(std::cmp::Ordering::Equal)
354 });
355}
356
357fn count_file_token_safe(path: &str, encoding: Encoding) -> usize {
359 count_file_tokens(Path::new(path), encoding)
360 .map(|tc| tc.count)
361 .unwrap_or(0)
362}
363
364pub fn format_explain(result: &SmartContext) -> String {
366 let mut output = String::new();
367
368 output.push_str(&format!(
369 "Selected {} files ({} tokens):\n\n",
370 result.selected_files.len(),
371 result.total_tokens
372 ));
373
374 for (i, file) in result.selected_files.iter().enumerate() {
375 output.push_str(&format!(
376 "{}. {} ({} tokens)\n",
377 i + 1,
378 file.path,
379 file.token_count
380 ));
381
382 for reason in &file.reasons {
383 output.push_str(&format!(" - {}\n", reason.description()));
384 }
385 output.push('\n');
386 }
387
388 if result.truncated {
389 output.push_str(&format!(
390 "({} files omitted due to token limit)\n",
391 result.omitted_count
392 ));
393 }
394
395 output
396}
397
398pub fn format_dry_run(result: &SmartContext) -> String {
400 let mut output = String::new();
401
402 output.push_str(&format!(
403 "Would select {} files ({} tokens):\n",
404 result.selected_files.len(),
405 result.total_tokens
406 ));
407
408 for file in &result.selected_files {
409 let primary_reason = file
410 .reasons
411 .first()
412 .map(|r| match r {
413 SelectionReason::SemanticMatch { .. } => "SemanticMatch",
414 SelectionReason::CalledBy { .. } => "CalledBy",
415 SelectionReason::Calls { .. } => "Calls",
416 SelectionReason::SameModule { .. } => "SameModule",
417 SelectionReason::Explicit => "Explicit",
418 })
419 .unwrap_or("Unknown");
420
421 output.push_str(&format!(
422 " {} ({} tokens) - {}\n",
423 file.path, file.token_count, primary_reason
424 ));
425 }
426
427 if result.truncated {
428 output.push_str(&format!(
429 "\n({} files would be omitted due to token limit)\n",
430 result.omitted_count
431 ));
432 }
433
434 output
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn test_selection_reason_weight() {
443 let semantic = SelectionReason::SemanticMatch {
444 symbol: "test".to_string(),
445 score: 0.9,
446 };
447 assert!((semantic.weight() - 0.9).abs() < 0.001);
448
449 let called_by = SelectionReason::CalledBy {
450 caller: "main".to_string(),
451 depth: 1,
452 };
453 assert!((called_by.weight() - 0.8).abs() < 0.001);
454
455 let called_by_depth2 = SelectionReason::CalledBy {
456 caller: "main".to_string(),
457 depth: 2,
458 };
459 assert!((called_by_depth2.weight() - 0.4).abs() < 0.001);
460
461 let calls = SelectionReason::Calls {
462 callee: "helper".to_string(),
463 depth: 1,
464 };
465 assert!((calls.weight() - 0.7).abs() < 0.001);
466
467 let same_module = SelectionReason::SameModule {
468 symbol: "related".to_string(),
469 };
470 assert!((same_module.weight() - 0.5).abs() < 0.001);
471
472 let explicit = SelectionReason::Explicit;
473 assert!((explicit.weight() - 1.0).abs() < 0.001);
474 }
475
476 #[test]
477 fn test_file_selection_add_reason() {
478 let mut selection = FileSelection::new(
479 "src/main.rs".to_string(),
480 SelectionReason::Calls {
481 callee: "helper".to_string(),
482 depth: 1,
483 },
484 );
485
486 assert!((selection.relevance_score - 0.7).abs() < 0.001);
487 assert_eq!(selection.reasons.len(), 1);
488
489 selection.add_reason(SelectionReason::SemanticMatch {
491 symbol: "main".to_string(),
492 score: 0.95,
493 });
494
495 assert!((selection.relevance_score - 0.95).abs() < 0.001);
496 assert_eq!(selection.reasons.len(), 2);
497 }
498
499 #[test]
500 fn test_select_by_token_budget() {
501 let files = vec![
502 FileSelection {
503 path: "a.rs".to_string(),
504 relevance_score: 1.0,
505 reasons: vec![SelectionReason::Explicit],
506 token_count: 100,
507 },
508 FileSelection {
509 path: "b.rs".to_string(),
510 relevance_score: 0.8,
511 reasons: vec![SelectionReason::Explicit],
512 token_count: 200,
513 },
514 FileSelection {
515 path: "c.rs".to_string(),
516 relevance_score: 0.5,
517 reasons: vec![SelectionReason::Explicit],
518 token_count: 150,
519 },
520 ];
521
522 let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
524 assert_eq!(selected.len(), 3);
525 assert_eq!(total, 450);
526 assert_eq!(omitted, 0);
527
528 let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
530 assert_eq!(selected.len(), 2);
531 assert_eq!(total, 300);
532 assert_eq!(omitted, 1);
533
534 let (selected, total, omitted) = select_by_token_budget(files, 150);
536 assert_eq!(selected.len(), 1);
537 assert_eq!(total, 100);
538 assert_eq!(omitted, 2);
539 }
540
541 #[test]
542 fn test_format_dry_run() {
543 let result = SmartContext {
544 task: "add caching".to_string(),
545 selected_files: vec![
546 FileSelection {
547 path: "src/main.rs".to_string(),
548 relevance_score: 0.9,
549 reasons: vec![SelectionReason::SemanticMatch {
550 symbol: "main".to_string(),
551 score: 0.9,
552 }],
553 token_count: 500,
554 },
555 FileSelection {
556 path: "src/lib.rs".to_string(),
557 relevance_score: 0.7,
558 reasons: vec![SelectionReason::Calls {
559 callee: "helper".to_string(),
560 depth: 1,
561 }],
562 token_count: 300,
563 },
564 ],
565 total_tokens: 800,
566 truncated: false,
567 omitted_count: 0,
568 };
569
570 let output = format_dry_run(&result);
571 assert!(output.contains("Would select 2 files"));
572 assert!(output.contains("src/main.rs"));
573 assert!(output.contains("SemanticMatch"));
574 }
575
576 #[test]
577 fn test_smart_config_default() {
578 let config = SmartConfig::default();
579 assert_eq!(config.max_tokens, 8000);
580 assert_eq!(config.depth, 2);
581 assert_eq!(config.top, 10);
582 }
583
584 #[test]
585 fn test_add_file_merges_reasons() {
586 let mut files: HashMap<String, FileSelection> = HashMap::new();
587
588 add_file(
590 &mut files,
591 "src/main.rs",
592 SelectionReason::SemanticMatch {
593 symbol: "main".to_string(),
594 score: 0.9,
595 },
596 );
597 assert_eq!(files.len(), 1);
598 assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
599
600 add_file(
602 &mut files,
603 "src/main.rs",
604 SelectionReason::CalledBy {
605 caller: "run".to_string(),
606 depth: 1,
607 },
608 );
609 assert_eq!(files.len(), 1); assert_eq!(files.get("src/main.rs").unwrap().reasons.len(), 2);
611 assert!((files.get("src/main.rs").unwrap().relevance_score - 0.9).abs() < 0.001);
613 }
614
615 #[test]
616 fn test_rank_files_sorts_by_relevance() {
617 let mut files = vec![
618 FileSelection {
619 path: "low.rs".to_string(),
620 relevance_score: 0.3,
621 reasons: vec![SelectionReason::Explicit],
622 token_count: 100,
623 },
624 FileSelection {
625 path: "high.rs".to_string(),
626 relevance_score: 0.9,
627 reasons: vec![SelectionReason::Explicit],
628 token_count: 100,
629 },
630 FileSelection {
631 path: "mid.rs".to_string(),
632 relevance_score: 0.5,
633 reasons: vec![SelectionReason::Explicit],
634 token_count: 100,
635 },
636 ];
637
638 rank_files(&mut files);
639
640 assert_eq!(files[0].path, "high.rs");
641 assert_eq!(files[1].path, "mid.rs");
642 assert_eq!(files[2].path, "low.rs");
643 }
644
645 #[test]
646 fn test_selection_reason_description() {
647 let semantic = SelectionReason::SemanticMatch {
648 symbol: "test".to_string(),
649 score: 0.9,
650 };
651 assert!(semantic.description().contains("SemanticMatch"));
652 assert!(semantic.description().contains("test"));
653
654 let called_by = SelectionReason::CalledBy {
655 caller: "main".to_string(),
656 depth: 2,
657 };
658 assert!(called_by.description().contains("CalledBy"));
659 assert!(called_by.description().contains("main"));
660 assert!(called_by.description().contains("2"));
661 }
662}