1use std::collections::{HashMap, HashSet};
2
3use super::graph_provider::{EdgeInfo, GraphProvider};
4
5#[derive(Debug, Clone)]
6pub struct RelevanceScore {
7 pub path: String,
8 pub score: f64,
9 pub recommended_mode: &'static str,
10}
11
12pub fn compute_relevance(
13 gp: &GraphProvider,
14 task_files: &[String],
15 task_keywords: &[String],
16) -> Vec<RelevanceScore> {
17 let all_edges = gp.edges();
18 let file_set: HashSet<String> = gp.file_paths().into_iter().collect();
19 let adj = build_adjacency_resolved(&all_edges, &file_set);
20 let all_nodes: Vec<String> = file_set.into_iter().collect();
21 if all_nodes.is_empty() {
22 return Vec::new();
23 }
24
25 let node_idx: HashMap<&str, usize> = all_nodes
26 .iter()
27 .enumerate()
28 .map(|(i, n)| (n.as_str(), i))
29 .collect();
30 let n = all_nodes.len();
31
32 let degrees: Vec<f64> = all_nodes
34 .iter()
35 .map(|node| {
36 adj.get(node)
37 .map_or(0.0, |neigh| neigh.len() as f64)
38 .max(1.0)
39 })
40 .collect();
41
42 let mut heat: Vec<f64> = vec![0.0; n];
44 for f in task_files {
45 if let Some(&idx) = node_idx.get(f.as_str()) {
46 heat[idx] = 1.0;
47 }
48 }
49
50 let alpha = 0.5;
53 let iterations = 4;
54 for _ in 0..iterations {
55 let mut new_heat = vec![0.0; n];
56 for (i, node) in all_nodes.iter().enumerate() {
57 let self_term = (1.0 - alpha) * heat[i];
58 let mut neighbor_sum = 0.0;
59 if let Some(neighbors) = adj.get(node) {
60 for neighbor in neighbors {
61 if let Some(&j) = node_idx.get(neighbor.as_str()) {
62 neighbor_sum += heat[j] / degrees[j];
63 }
64 }
65 }
66 new_heat[i] = self_term + alpha * neighbor_sum;
67 }
68 heat = new_heat;
69 }
70
71 let mut pagerank = vec![1.0 / n as f64; n];
73 let damping = 0.85;
74 for _ in 0..8 {
75 let mut new_pr = vec![(1.0 - damping) / n as f64; n];
76 for (i, node) in all_nodes.iter().enumerate() {
77 if let Some(neighbors) = adj.get(node) {
78 let out_deg = neighbors.len().max(1) as f64;
79 for neighbor in neighbors {
80 if let Some(&j) = node_idx.get(neighbor.as_str()) {
81 new_pr[j] += damping * pagerank[i] / out_deg;
82 }
83 }
84 }
85 }
86 pagerank = new_pr;
87 }
88
89 let mut scores: HashMap<String, f64> = HashMap::new();
91 let heat_max = heat.iter().copied().fold(0.0_f64, f64::max).max(1e-10);
92 let pr_max = pagerank.iter().copied().fold(0.0_f64, f64::max).max(1e-10);
93
94 for (i, node) in all_nodes.iter().enumerate() {
95 let h = heat[i] / heat_max;
96 let pr = pagerank[i] / pr_max;
97 let combined = h * 0.8 + pr * 0.2;
98 if combined > 0.01 {
99 scores.insert(node.clone(), combined);
100 }
101 }
102
103 if !task_keywords.is_empty() {
104 let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
105 for file_path in &all_nodes {
106 let path_lower = file_path.to_lowercase();
107 let mut keyword_hits = 0;
108 for kw in &kw_lower {
109 if path_lower.contains(kw) {
110 keyword_hits += 1;
111 }
112 if let Some(entry) = gp.get_file_entry(file_path) {
113 for export in &entry.exports {
114 if export.to_lowercase().contains(kw) {
115 keyword_hits += 1;
116 }
117 }
118 }
119 }
120 if keyword_hits > 0 {
121 let boost = (keyword_hits as f64 * 0.15).min(0.6);
122 let entry = scores.entry(file_path.clone()).or_insert(0.0);
123 *entry = (*entry + boost).min(1.0);
124 }
125 }
126 }
127
128 let mut result: Vec<RelevanceScore> = scores
129 .into_iter()
130 .map(|(path, score)| {
131 let mode = recommend_mode(score);
132 RelevanceScore {
133 path,
134 score,
135 recommended_mode: mode,
136 }
137 })
138 .collect();
139
140 result.sort_by(|a, b| {
141 b.score
142 .partial_cmp(&a.score)
143 .unwrap_or(std::cmp::Ordering::Equal)
144 });
145 result
146}
147
148pub fn compute_relevance_from_intent(
149 gp: &GraphProvider,
150 intent: &super::intent_engine::StructuredIntent,
151) -> Vec<RelevanceScore> {
152 use super::intent_engine::IntentScope;
153
154 let mut file_seeds: Vec<String> = Vec::new();
155 let mut extra_keywords: Vec<String> = intent.keywords.clone();
156
157 let file_paths = gp.file_paths();
158 for target in &intent.targets {
159 if target.contains('.') || target.contains('/') {
160 let matched = resolve_target_to_files(&file_paths, target);
161 if matched.is_empty() {
162 extra_keywords.push(target.clone());
163 } else {
164 file_seeds.extend(matched);
165 }
166 } else {
167 let from_symbol = resolve_symbol_to_files(gp, target);
168 if from_symbol.is_empty() {
169 extra_keywords.push(target.clone());
170 } else {
171 file_seeds.extend(from_symbol);
172 }
173 }
174 }
175
176 if let Some(lang) = &intent.language_hint {
177 let lang_ext = match lang.as_str() {
178 "rust" => Some("rs"),
179 "typescript" => Some("ts"),
180 "javascript" => Some("js"),
181 "python" => Some("py"),
182 "go" => Some("go"),
183 "ruby" => Some("rb"),
184 "java" => Some("java"),
185 _ => None,
186 };
187 if let Some(ext) = lang_ext
188 && file_seeds.is_empty()
189 {
190 for path in &file_paths {
191 if path.ends_with(&format!(".{ext}")) {
192 extra_keywords.push(
193 std::path::Path::new(path)
194 .file_stem()
195 .and_then(|s| s.to_str())
196 .unwrap_or("")
197 .to_string(),
198 );
199 break;
200 }
201 }
202 }
203 }
204
205 let mut result = compute_relevance(gp, &file_seeds, &extra_keywords);
206
207 match intent.scope {
208 IntentScope::SingleFile => {
209 result.truncate(5);
210 }
211 IntentScope::MultiFile => {
212 result.truncate(15);
213 }
214 IntentScope::CrossModule | IntentScope::ProjectWide => {}
215 }
216
217 result
218}
219
220fn resolve_target_to_files(file_paths: &[String], target: &str) -> Vec<String> {
221 file_paths
222 .iter()
223 .filter(|path| path.ends_with(target) || path.contains(target))
224 .cloned()
225 .collect()
226}
227
228fn resolve_symbol_to_files(gp: &GraphProvider, symbol: &str) -> Vec<String> {
229 let found = gp.find_symbols(symbol, None, None);
230 let mut matches: Vec<String> = found
231 .into_iter()
232 .map(|s| s.file)
233 .collect::<HashSet<_>>()
234 .into_iter()
235 .collect();
236 if matches.is_empty() {
237 let sym_lower = symbol.to_lowercase();
238 for path in gp.file_paths() {
239 if let Some(entry) = gp.get_file_entry(&path)
240 && entry
241 .exports
242 .iter()
243 .any(|e| e.to_lowercase().contains(&sym_lower))
244 && !matches.contains(&path)
245 {
246 matches.push(path);
247 }
248 }
249 }
250 matches
251}
252
253fn recommend_mode(score: f64) -> &'static str {
254 if score >= 0.8 {
255 "full"
256 } else if score >= 0.5 {
257 "signatures"
258 } else if score >= 0.2 {
259 "map"
260 } else {
261 "reference"
262 }
263}
264
265fn build_adjacency_resolved(
266 edges: &[EdgeInfo],
267 file_set: &HashSet<String>,
268) -> HashMap<String, Vec<String>> {
269 let file_paths_vec: Vec<&str> = file_set.iter().map(String::as_str).collect();
270 let module_to_file = build_module_map(edges, file_set, &file_paths_vec);
271 let mut adj: HashMap<String, Vec<String>> = HashMap::new();
272
273 for edge in edges {
274 let from = &edge.from;
275 let to_resolved = module_to_file
276 .get(&edge.to)
277 .cloned()
278 .unwrap_or_else(|| edge.to.clone());
279
280 if file_set.contains(from) && file_set.contains(&to_resolved) {
281 adj.entry(from.clone())
282 .or_default()
283 .push(to_resolved.clone());
284 adj.entry(to_resolved).or_default().push(from.clone());
285 }
286 }
287 adj
288}
289
290fn build_module_map(
291 edges: &[EdgeInfo],
292 file_set: &HashSet<String>,
293 file_paths: &[&str],
294) -> HashMap<String, String> {
295 let mut mapping: HashMap<String, String> = HashMap::new();
296
297 let edge_targets: HashSet<String> = edges.iter().map(|e| e.to.clone()).collect();
298
299 for target in &edge_targets {
300 if file_set.contains(target) {
301 mapping.insert(target.clone(), target.clone());
302 continue;
303 }
304
305 if let Some(resolved) = resolve_module_to_file(target, file_paths) {
306 mapping.insert(target.clone(), resolved);
307 }
308 }
309
310 mapping
311}
312
313fn resolve_module_to_file(module_path: &str, file_paths: &[&str]) -> Option<String> {
314 let cleaned = module_path
315 .trim_start_matches("crate::")
316 .trim_start_matches("super::");
317
318 let parts: Vec<&str> = cleaned.split("::").collect();
320
321 for end in (1..=parts.len()).rev() {
323 let candidate = parts[..end].join("/");
324
325 for fp in file_paths {
327 let fp_normalized = fp
328 .trim_start_matches("rust/src/")
329 .trim_start_matches("src/");
330
331 if fp_normalized == format!("{candidate}.rs")
332 || fp_normalized == format!("{candidate}/mod.rs")
333 || fp.ends_with(&format!("/{candidate}.rs"))
334 || fp.ends_with(&format!("/{candidate}/mod.rs"))
335 {
336 return Some(fp.to_string());
337 }
338 }
339 }
340
341 if let Some(last) = parts.last() {
343 let stem = format!("{last}.rs");
344 for fp in file_paths {
345 if fp.ends_with(&stem) {
346 return Some(fp.to_string());
347 }
348 }
349 }
350
351 None
352}
353
354pub fn parse_task_hints(task_description: &str) -> (Vec<String>, Vec<String>) {
356 let mut files = Vec::new();
357 let mut keywords = Vec::new();
358
359 for word in task_description.split_whitespace() {
360 let clean = word.trim_matches(|c: char| {
361 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
362 });
363 if clean.contains('.') && {
364 let p = std::path::Path::new(clean);
365 clean.contains('/')
366 || p.extension().is_some_and(|e| {
367 e.eq_ignore_ascii_case("rs")
368 || e.eq_ignore_ascii_case("ts")
369 || e.eq_ignore_ascii_case("py")
370 || e.eq_ignore_ascii_case("go")
371 || e.eq_ignore_ascii_case("js")
372 })
373 } {
374 files.push(clean.to_string());
375 } else if clean.len() >= 3 && !STOP_WORDS.contains(&clean.to_lowercase().as_str()) {
376 keywords.push(clean.to_string());
377 }
378 }
379
380 (files, keywords)
381}
382
383const STOP_WORDS: &[&str] = &[
384 "the", "and", "for", "that", "this", "with", "from", "have", "has", "was", "are", "been",
385 "not", "but", "all", "can", "had", "her", "one", "our", "out", "you", "its", "will", "each",
386 "make", "like", "fix", "add", "use", "get", "set", "run", "new", "old", "should", "would",
387 "could", "into", "also", "than", "them", "then", "when", "just", "only", "very", "some",
388 "more", "other", "nach", "und", "die", "der", "das", "ist", "ein", "eine", "nicht", "auf",
389 "mit",
390];
391
392struct StructuralWeights {
393 error_handling: f64,
394 definition: f64,
395 control_flow: f64,
396 closing_brace: f64,
397 other: f64,
398}
399
400impl StructuralWeights {
401 const DEFAULT: Self = Self {
402 error_handling: 1.5,
403 definition: 1.0,
404 control_flow: 0.5,
405 closing_brace: 0.15,
406 other: 0.3,
407 };
408
409 fn for_task_type(task_type: Option<super::intent_engine::TaskType>) -> Self {
410 use super::intent_engine::TaskType;
411 match task_type {
412 Some(TaskType::FixBug) => Self {
413 error_handling: 2.0,
414 definition: 0.8,
415 control_flow: 0.8,
416 closing_brace: 0.1,
417 other: 0.2,
418 },
419 Some(TaskType::Debug) => Self {
420 error_handling: 2.0,
421 definition: 0.6,
422 control_flow: 1.0,
423 closing_brace: 0.1,
424 other: 0.2,
425 },
426 Some(TaskType::Generate) => Self {
427 error_handling: 0.8,
428 definition: 1.5,
429 control_flow: 0.3,
430 closing_brace: 0.15,
431 other: 0.4,
432 },
433 Some(TaskType::Refactor) => Self {
434 error_handling: 1.0,
435 definition: 1.5,
436 control_flow: 0.6,
437 closing_brace: 0.2,
438 other: 0.3,
439 },
440 Some(TaskType::Test) => Self {
441 error_handling: 1.2,
442 definition: 1.3,
443 control_flow: 0.4,
444 closing_brace: 0.15,
445 other: 0.3,
446 },
447 Some(TaskType::Review) => Self {
448 error_handling: 1.3,
449 definition: 1.2,
450 control_flow: 0.6,
451 closing_brace: 0.15,
452 other: 0.3,
453 },
454 None | Some(TaskType::Explore | _) => Self::DEFAULT,
455 }
456 }
457}
458
459pub fn information_bottleneck_filter(
470 content: &str,
471 task_keywords: &[String],
472 budget_ratio: f64,
473 force_keep: &[String],
474) -> String {
475 information_bottleneck_filter_typed(content, task_keywords, budget_ratio, None, force_keep)
476}
477
478pub fn information_bottleneck_filter_with_headers(
483 content: &str,
484 task_keywords: &[String],
485 budget_ratio: f64,
486 force_keep: &[String],
487 preserve_section_headers: bool,
488) -> String {
489 if !preserve_section_headers {
490 return information_bottleneck_filter(content, task_keywords, budget_ratio, force_keep);
491 }
492 let filtered = information_bottleneck_filter(content, task_keywords, budget_ratio, force_keep);
493 inject_section_headers(content, &filtered)
494}
495
496fn inject_section_headers(original: &str, filtered: &str) -> String {
499 let orig_lines: Vec<&str> = original.lines().collect();
500 let filt_lines: Vec<&str> = filtered.lines().collect();
501
502 let header_indices: Vec<usize> = orig_lines
503 .iter()
504 .enumerate()
505 .filter(|(_, l)| l.starts_with('#'))
506 .map(|(i, _)| i)
507 .collect();
508
509 if header_indices.is_empty() {
510 return filtered.to_string();
511 }
512
513 let mut result_lines: Vec<&str> = Vec::with_capacity(filt_lines.len() + header_indices.len());
514 let mut injected_headers: std::collections::HashSet<usize> = std::collections::HashSet::new();
515
516 for filt_line in &filt_lines {
517 let trimmed = filt_line.trim();
518 if trimmed.is_empty() || trimmed.starts_with("[task:") {
519 result_lines.push(filt_line);
520 continue;
521 }
522 if let Some(orig_idx) = orig_lines.iter().position(|l| *l == *filt_line)
523 && let Some(&h) = header_indices.iter().rev().find(|&&h| h <= orig_idx)
524 && !injected_headers.contains(&h)
525 && !filt_lines.contains(&orig_lines[h])
526 {
527 injected_headers.insert(h);
528 result_lines.push(orig_lines[h]);
529 }
530 result_lines.push(filt_line);
531 }
532
533 result_lines.join("\n")
534}
535
536pub fn information_bottleneck_filter_typed(
540 content: &str,
541 task_keywords: &[String],
542 budget_ratio: f64,
543 task_type: Option<super::intent_engine::TaskType>,
544 force_keep: &[String],
545) -> String {
546 let selected = ib_select(content, task_keywords, budget_ratio, task_type, force_keep);
547 if selected.is_empty() {
548 return String::new();
549 }
550 let body: Vec<&str> = selected.iter().map(|(_, line)| *line).collect();
551 let body = body.join("\n");
552 if task_keywords.is_empty() {
553 body
554 } else {
555 format!("[task: {}]\n{body}", task_keywords.join(", "))
556 }
557}
558
559pub fn ib_select<'a>(
575 content: &'a str,
576 task_keywords: &[String],
577 budget_ratio: f64,
578 task_type: Option<super::intent_engine::TaskType>,
579 force_keep: &[String],
580) -> Vec<(usize, &'a str)> {
581 let lines: Vec<&str> = content.lines().collect();
582 if lines.is_empty() {
583 return Vec::new();
584 }
585
586 let n = lines.len();
587 let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
588
589 let mut global_token_freq: HashMap<&str, usize> = HashMap::new();
590 for line in &lines {
591 for token in line.split_whitespace() {
592 *global_token_freq.entry(token).or_insert(0) += 1;
593 }
594 }
595 let total_unique = global_token_freq.len().max(1) as f64;
596 let total_lines = n.max(1) as f64;
597
598 let task_token_set: HashSet<String> = kw_lower
599 .iter()
600 .flat_map(|kw| kw.split(|c: char| !c.is_alphanumeric()).map(String::from))
601 .filter(|t| t.len() >= 2)
602 .collect();
603
604 let effective_ratio = if task_token_set.is_empty() {
605 budget_ratio
606 } else {
607 adaptive_ib_budget(content, budget_ratio)
608 };
609
610 let weights = StructuralWeights::for_task_type(task_type);
611
612 let mut scored_lines: Vec<(usize, &str, f64)> = lines
613 .iter()
614 .enumerate()
615 .filter_map(|(i, line)| {
616 let trimmed = line.trim();
617 if trimmed.is_empty() {
618 return super::protect::line_is_protected(line, force_keep).then_some((
621 i,
622 *line,
623 f64::INFINITY,
624 ));
625 }
626
627 let line_lower = trimmed.to_lowercase();
628 let line_tokens: Vec<&str> = trimmed.split_whitespace().collect();
629 let line_token_count = line_tokens.len().max(1) as f64;
630
631 let mi_score = if task_token_set.is_empty() {
632 0.0
633 } else {
634 let line_token_set: HashSet<String> =
635 line_tokens.iter().map(|t| t.to_lowercase()).collect();
636 let overlap: f64 = line_token_set
637 .iter()
638 .filter(|t| task_token_set.iter().any(|kw| t.contains(kw.as_str())))
639 .map(|t| {
640 let freq = *global_token_freq.get(t.as_str()).unwrap_or(&1) as f64;
641 (total_lines / freq).ln().max(0.1)
642 })
643 .sum();
644 overlap / line_token_count
645 };
646
647 let keyword_hits: f64 = kw_lower
648 .iter()
649 .filter(|kw| line_lower.contains(kw.as_str()))
650 .count() as f64;
651
652 let structural = if is_error_handling(trimmed) {
653 weights.error_handling
654 } else if is_definition_line(trimmed) {
655 weights.definition
656 } else if is_control_flow(trimmed) {
657 weights.control_flow
658 } else if is_closing_brace(trimmed) {
659 weights.closing_brace
660 } else {
661 weights.other
662 };
663 let relevance = mi_score * 0.4 + keyword_hits * 0.3 + structural;
664
665 let unique_in_line = line_tokens.iter().collect::<HashSet<_>>().len() as f64;
666 let token_diversity = unique_in_line / line_token_count;
667
668 let avg_idf: f64 = if line_tokens.is_empty() {
669 0.0
670 } else {
671 line_tokens
672 .iter()
673 .map(|t| {
674 let freq = *global_token_freq.get(t).unwrap_or(&1) as f64;
675 (total_unique / freq).ln().max(0.0)
676 })
677 .sum::<f64>()
678 / line_token_count
679 };
680 let information = (token_diversity * 0.4 + (avg_idf.min(3.0) / 3.0) * 0.6).min(1.0);
681
682 let pos = i as f64 / n.max(1) as f64;
683 let attn_weight = 1.0 - (pos - 0.5).abs() * 0.5;
684
685 let score = (relevance * 0.6 + 0.05)
686 * (information * 0.25 + 0.05)
687 * (attn_weight * 0.15 + 0.05);
688
689 let score = if super::protect::line_is_protected(line, force_keep) {
692 f64::INFINITY
693 } else {
694 score
695 };
696
697 Some((i, *line, score))
698 })
699 .collect();
700
701 let protected_count = lines
705 .iter()
706 .filter(|l| super::protect::line_is_protected(l, force_keep))
707 .count();
708 let budget = (((n as f64) * effective_ratio).ceil() as usize) + protected_count;
709
710 scored_lines.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
711
712 let mut selected = mmr_select(&scored_lines, budget, 0.3);
713 selected.sort_by_key(|(i, _, _)| *i);
715 selected.into_iter().map(|(i, line, _)| (i, line)).collect()
716}
717
718fn mmr_select<'a>(
723 candidates: &[(usize, &'a str, f64)],
724 budget: usize,
725 lambda: f64,
726) -> Vec<(usize, &'a str, f64)> {
727 if candidates.is_empty() || budget == 0 {
728 return Vec::new();
729 }
730
731 let mut selected: Vec<(usize, &'a str, f64)> = Vec::with_capacity(budget);
732 let mut remaining: Vec<(usize, &'a str, f64)> = candidates.to_vec();
733
734 selected.push(remaining.remove(0));
736
737 while selected.len() < budget && !remaining.is_empty() {
738 let mut best_idx = 0;
739 let mut best_mmr = f64::NEG_INFINITY;
740
741 for (i, &(_, cand_line, cand_score)) in remaining.iter().enumerate() {
742 let cand_tokens: HashSet<&str> = cand_line.split_whitespace().collect();
743 if cand_tokens.is_empty() {
744 if cand_score > best_mmr {
745 best_mmr = cand_score;
746 best_idx = i;
747 }
748 continue;
749 }
750
751 let max_sim = selected
752 .iter()
753 .map(|&(_, sel_line, _)| {
754 let sel_tokens: HashSet<&str> = sel_line.split_whitespace().collect();
755 if sel_tokens.is_empty() {
756 return 0.0;
757 }
758 let inter = cand_tokens.intersection(&sel_tokens).count();
759 let union = cand_tokens.union(&sel_tokens).count();
760 if union == 0 {
761 0.0
762 } else {
763 inter as f64 / union as f64
764 }
765 })
766 .fold(0.0_f64, f64::max);
767
768 let mmr = cand_score - lambda * max_sim;
769 if mmr > best_mmr {
770 best_mmr = mmr;
771 best_idx = i;
772 }
773 }
774
775 selected.push(remaining.remove(best_idx));
776 }
777
778 selected
779}
780
781fn is_error_handling(line: &str) -> bool {
782 line.starts_with("return Err(")
783 || line.starts_with("Err(")
784 || line.starts_with("bail!(")
785 || line.starts_with("anyhow::bail!")
786 || line.contains(".map_err(")
787 || line.contains("unwrap()")
788 || line.contains("expect(\"")
789 || line.starts_with("raise ")
790 || line.starts_with("throw ")
791 || line.starts_with("catch ")
792 || line.starts_with("except ")
793 || line.starts_with("try ")
794 || (line.contains("?;") && !line.starts_with("//"))
795 || line.starts_with("panic!(")
796 || line.contains("Error::")
797 || line.contains("error!")
798}
799
800pub fn adaptive_ib_budget(content: &str, base_ratio: f64) -> f64 {
804 let lines: Vec<&str> = content.lines().collect();
805 if lines.len() < 10 {
806 return 1.0;
807 }
808
809 let mut token_freq: HashMap<&str, usize> = HashMap::new();
810 let mut total_tokens = 0usize;
811 for line in &lines {
812 for token in line.split_whitespace() {
813 *token_freq.entry(token).or_insert(0) += 1;
814 total_tokens += 1;
815 }
816 }
817
818 if total_tokens == 0 {
819 return base_ratio;
820 }
821
822 let unique_ratio = token_freq.len() as f64 / total_tokens as f64;
823 let repetition_factor = 1.0 - unique_ratio;
824
825 (base_ratio * (1.0 - repetition_factor * 0.3)).clamp(0.2, 1.0)
826}
827
828fn is_definition_line(line: &str) -> bool {
829 let prefixes = [
830 "fn ",
831 "pub fn ",
832 "async fn ",
833 "pub async fn ",
834 "struct ",
835 "pub struct ",
836 "enum ",
837 "pub enum ",
838 "trait ",
839 "pub trait ",
840 "impl ",
841 "type ",
842 "pub type ",
843 "const ",
844 "pub const ",
845 "static ",
846 "pub static ",
847 "class ",
848 "export class ",
849 "interface ",
850 "export interface ",
851 "function ",
852 "export function ",
853 "async function ",
854 "def ",
855 "async def ",
856 "func ",
857 ];
858 prefixes
859 .iter()
860 .any(|p| line.starts_with(p) || line.trim_start().starts_with(p))
861}
862
863fn is_control_flow(line: &str) -> bool {
864 let trimmed = line.trim();
865 trimmed.starts_with("if ")
866 || trimmed.starts_with("else ")
867 || trimmed.starts_with("match ")
868 || trimmed.starts_with("for ")
869 || trimmed.starts_with("while ")
870 || trimmed.starts_with("return ")
871 || trimmed.starts_with("break")
872 || trimmed.starts_with("continue")
873 || trimmed.starts_with("yield")
874 || trimmed.starts_with("await ")
875}
876
877fn is_closing_brace(line: &str) -> bool {
878 let trimmed = line.trim();
879 trimmed == "}" || trimmed == "};" || trimmed == "})" || trimmed == "});"
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn parse_task_finds_files_and_keywords() {
888 let (files, keywords) =
889 parse_task_hints("Fix the authentication bug in src/auth.rs and update tests");
890 assert!(files.iter().any(|f| f.contains("auth.rs")));
891 assert!(
892 keywords
893 .iter()
894 .any(|k| k.to_lowercase().contains("authentication"))
895 );
896 }
897
898 #[test]
899 fn recommend_mode_by_score() {
900 assert_eq!(recommend_mode(1.0), "full");
901 assert_eq!(recommend_mode(0.6), "signatures");
902 assert_eq!(recommend_mode(0.3), "map");
903 assert_eq!(recommend_mode(0.1), "reference");
904 }
905
906 #[test]
907 fn info_bottleneck_preserves_definitions() {
908 let content = "fn main() {\n let x = 42;\n // boring comment\n println!(x);\n}\n";
909 let result = information_bottleneck_filter(content, &["main".to_string()], 0.6, &[]);
910 assert!(result.contains("fn main"), "definitions must be preserved");
911 assert!(result.contains("[task: main]"), "should have task summary");
912 }
913
914 #[test]
915 fn protect_force_keeps_line_in_ib() {
916 let content = "fn main() {\n let x = 1;\n let unimportant = 2;\n let y = 3;\n let z = 4;\n}\n";
917 let kept = information_bottleneck_filter(
919 content,
920 &["main".to_string()],
921 0.1,
922 &["unimportant".to_string()],
923 );
924 assert!(
925 kept.contains("let unimportant = 2;"),
926 "protected line must survive the IB budget: {kept}"
927 );
928 }
929
930 #[test]
931 fn ib_empty_force_keep_is_byte_identical() {
932 let content = "fn main() {\n let x = 1;\n return Err(\"e\");\n let y = 2;\n}\n";
934 let a = information_bottleneck_filter(content, &["main".to_string()], 0.5, &[]);
935 let b = information_bottleneck_filter_typed(content, &["main".to_string()], 0.5, None, &[]);
936 assert_eq!(a, b);
937 }
938
939 #[test]
940 fn info_bottleneck_error_handling_priority() {
941 let content = "fn validate() {\n let data = parse()?;\n return Err(\"invalid\");\n let x = 1;\n let y = 2;\n}\n";
942 let result = information_bottleneck_filter(content, &["validate".to_string()], 0.5, &[]);
943 assert!(
944 result.contains("return Err"),
945 "error handling should survive filtering"
946 );
947 }
948
949 #[test]
950 fn info_bottleneck_preserves_source_order() {
951 let content = "fn important() {\n let x = 1;\n let y = 2;\n let z = 3;\n}\n}\n";
954 let result = information_bottleneck_filter(content, &[], 0.6, &[]);
955 let lines: Vec<&str> = result.lines().collect();
956 let def_pos = lines.iter().position(|l| l.contains("fn important"));
957 let brace_pos = lines.iter().position(|l| l.trim() == "}");
958 if let (Some(d), Some(b)) = (def_pos, brace_pos) {
959 assert!(d < b, "the definition precedes its closing brace in source");
960 }
961
962 let numbered = "alpha_one\nbeta_two\ngamma_three\ndelta_four\nepsilon_five\nzeta_six\n";
963 let selected = ib_select(numbered, &["gamma".to_string()], 0.6, None, &[]);
964 let indices: Vec<usize> = selected.iter().map(|(i, _)| *i).collect();
965 let mut sorted = indices.clone();
966 sorted.sort_unstable();
967 assert_eq!(
968 indices, sorted,
969 "selection must be returned in source order"
970 );
971 }
972
973 #[test]
974 fn info_bottleneck_never_selects_blank_lines() {
975 let mut content = String::new();
979 for i in 0..40 {
980 content.push_str(&format!("fn handler_{i}(input: &str) -> usize {{\n"));
981 content.push_str("\n\n\n");
982 content.push_str(" input.len()\n}\n\n\n");
983 }
984 let result = information_bottleneck_filter(&content, &["handler".to_string()], 0.3, &[]);
985 for line in result.lines().skip(1) {
986 assert!(
987 !line.trim().is_empty(),
988 "no blank line may consume a slot in the task budget"
989 );
990 }
991 assert!(result.contains("handler_"), "real content must survive");
992 }
993
994 #[test]
995 fn protected_blank_line_still_survives() {
996 let content = "fn a() {}\n \nfn b() {}\n";
999 let kept = ib_select(content, &["a".to_string()], 0.1, None, &[" ".to_string()]);
1000 assert!(
1001 kept.iter().any(|(_, l)| l.trim().is_empty()),
1002 "an explicitly protected line is kept even when blank"
1003 );
1004 }
1005
1006 #[test]
1007 fn adaptive_budget_reduces_for_repetitive() {
1008 let repetitive = "let x = 1;\n".repeat(50);
1009 let diverse = (0..50)
1010 .map(|i| format!("let var_{i} = func_{i}(arg_{i});"))
1011 .collect::<Vec<_>>()
1012 .join("\n");
1013 let budget_rep = super::adaptive_ib_budget(&repetitive, 0.7);
1014 let budget_div = super::adaptive_ib_budget(&diverse, 0.7);
1015 assert!(
1016 budget_rep < budget_div,
1017 "repetitive content should get lower budget"
1018 );
1019 }
1020}