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