Skip to main content

lean_ctx/core/
task_relevance.rs

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    // Build degree-normalized adjacency for heat diffusion
34    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    // Seed vector: task files get 1.0
44    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    // Heat diffusion: h(t+1) = (1-alpha)*h(t) + alpha * A_norm * h(t)
52    // Run for k iterations
53    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    // PageRank centrality for gateway detection
73    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    // Combine: heat (primary) + pagerank centrality (gateway bonus)
91    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    // Strip trailing symbol (e.g. `core::tokens::count_tokens` → `core::tokens`)
320    let parts: Vec<&str> = cleaned.split("::").collect();
321
322    // Try progressively shorter prefixes to find a matching file
323    for end in (1..=parts.len()).rev() {
324        let candidate = parts[..end].join("/");
325
326        // Try as .rs file
327        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    // Fallback: match by last segment as filename stem
343    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
355/// Extract likely task-relevant file paths and keywords from a task description.
356pub 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
460/// Information Bottleneck filter v3 — Mutual Information scoring, QUITO-X inspired.
461///
462/// IB principle: maximize I(T;Y) (task relevance) while minimizing I(T;X) (input redundancy).
463/// v3: MI(line, task) approximated via token overlap + IDF weighting + structural importance.
464///
465/// Key changes from v2:
466///   - Mutual Information scoring: MI(line, task) = H(line) - H(line|task)
467///   - Adaptive budget allocation based on task type via TaskClassifier
468///   - Token-level IDF computed over full document for better term weighting
469///   - Maintains L-curve attention, MMR dedup, error-handling priority from v2
470pub fn information_bottleneck_filter(
471    content: &str,
472    task_keywords: &[String],
473    budget_ratio: f64,
474) -> String {
475    information_bottleneck_filter_typed(content, task_keywords, budget_ratio, None)
476}
477
478/// Task-type-aware IB filter. Uses `TaskType` to adjust structural weights.
479pub fn information_bottleneck_filter_typed(
480    content: &str,
481    task_keywords: &[String],
482    budget_ratio: f64,
483    task_type: Option<super::intent_engine::TaskType>,
484) -> String {
485    let lines: Vec<&str> = content.lines().collect();
486    if lines.is_empty() {
487        return String::new();
488    }
489
490    let n = lines.len();
491    let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
492    let attention = LearnedAttention::with_defaults();
493
494    let mut global_token_freq: HashMap<&str, usize> = HashMap::new();
495    for line in &lines {
496        for token in line.split_whitespace() {
497            *global_token_freq.entry(token).or_insert(0) += 1;
498        }
499    }
500    let total_unique = global_token_freq.len().max(1) as f64;
501    let total_lines = n.max(1) as f64;
502
503    let task_token_set: HashSet<String> = kw_lower
504        .iter()
505        .flat_map(|kw| kw.split(|c: char| !c.is_alphanumeric()).map(String::from))
506        .filter(|t| t.len() >= 2)
507        .collect();
508
509    let effective_ratio = if task_token_set.is_empty() {
510        budget_ratio
511    } else {
512        adaptive_ib_budget(content, budget_ratio)
513    };
514
515    let weights = StructuralWeights::for_task_type(task_type);
516
517    let mut scored_lines: Vec<(usize, &str, f64)> = lines
518        .iter()
519        .enumerate()
520        .map(|(i, line)| {
521            let trimmed = line.trim();
522            if trimmed.is_empty() {
523                return (i, *line, 0.05);
524            }
525
526            let line_lower = trimmed.to_lowercase();
527            let line_tokens: Vec<&str> = trimmed.split_whitespace().collect();
528            let line_token_count = line_tokens.len().max(1) as f64;
529
530            let mi_score = if task_token_set.is_empty() {
531                0.0
532            } else {
533                let line_token_set: HashSet<String> =
534                    line_tokens.iter().map(|t| t.to_lowercase()).collect();
535                let overlap: f64 = line_token_set
536                    .iter()
537                    .filter(|t| task_token_set.iter().any(|kw| t.contains(kw.as_str())))
538                    .map(|t| {
539                        let freq = *global_token_freq.get(t.as_str()).unwrap_or(&1) as f64;
540                        (total_lines / freq).ln().max(0.1)
541                    })
542                    .sum();
543                overlap / line_token_count
544            };
545
546            let keyword_hits: f64 = kw_lower
547                .iter()
548                .filter(|kw| line_lower.contains(kw.as_str()))
549                .count() as f64;
550
551            let structural = if is_error_handling(trimmed) {
552                weights.error_handling
553            } else if is_definition_line(trimmed) {
554                weights.definition
555            } else if is_control_flow(trimmed) {
556                weights.control_flow
557            } else if is_closing_brace(trimmed) {
558                weights.closing_brace
559            } else {
560                weights.other
561            };
562            let relevance = mi_score * 0.4 + keyword_hits * 0.3 + structural;
563
564            let unique_in_line = line_tokens.iter().collect::<HashSet<_>>().len() as f64;
565            let token_diversity = unique_in_line / line_token_count;
566
567            let avg_idf: f64 = if line_tokens.is_empty() {
568                0.0
569            } else {
570                line_tokens
571                    .iter()
572                    .map(|t| {
573                        let freq = *global_token_freq.get(t).unwrap_or(&1) as f64;
574                        (total_unique / freq).ln().max(0.0)
575                    })
576                    .sum::<f64>()
577                    / line_token_count
578            };
579            let information = (token_diversity * 0.4 + (avg_idf.min(3.0) / 3.0) * 0.6).min(1.0);
580
581            let pos = i as f64 / n.max(1) as f64;
582            let attn_weight = attention.weight(pos);
583
584            let score = (relevance * 0.6 + 0.05)
585                * (information * 0.25 + 0.05)
586                * (attn_weight * 0.15 + 0.05);
587
588            (i, *line, score)
589        })
590        .collect();
591
592    let budget = ((n as f64) * effective_ratio).ceil() as usize;
593
594    scored_lines.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
595
596    let selected = mmr_select(&scored_lines, budget, 0.3);
597
598    let mut output_lines: Vec<&str> = Vec::with_capacity(budget + 1);
599
600    if !kw_lower.is_empty() {
601        output_lines.push("");
602    }
603
604    for (_, line, _) in &selected {
605        output_lines.push(line);
606    }
607
608    if !kw_lower.is_empty() {
609        let summary = format!("[task: {}]", task_keywords.join(", "));
610        let mut result = summary;
611        result.push('\n');
612        result.push_str(&output_lines[1..].to_vec().join("\n"));
613        return result;
614    }
615
616    output_lines.join("\n")
617}
618
619/// Maximum Marginal Relevance selection — greedy selection that penalizes
620/// redundancy with already-selected lines using token-set Jaccard similarity.
621///
622/// MMR(i) = relevance(i) - lambda * max_{j in S} jaccard(i, j)
623fn mmr_select<'a>(
624    candidates: &[(usize, &'a str, f64)],
625    budget: usize,
626    lambda: f64,
627) -> Vec<(usize, &'a str, f64)> {
628    if candidates.is_empty() || budget == 0 {
629        return Vec::new();
630    }
631
632    let mut selected: Vec<(usize, &'a str, f64)> = Vec::with_capacity(budget);
633    let mut remaining: Vec<(usize, &'a str, f64)> = candidates.to_vec();
634
635    // Always take the top-scored line first
636    selected.push(remaining.remove(0));
637
638    while selected.len() < budget && !remaining.is_empty() {
639        let mut best_idx = 0;
640        let mut best_mmr = f64::NEG_INFINITY;
641
642        for (i, &(_, cand_line, cand_score)) in remaining.iter().enumerate() {
643            let cand_tokens: HashSet<&str> = cand_line.split_whitespace().collect();
644            if cand_tokens.is_empty() {
645                if cand_score > best_mmr {
646                    best_mmr = cand_score;
647                    best_idx = i;
648                }
649                continue;
650            }
651
652            let max_sim = selected
653                .iter()
654                .map(|&(_, sel_line, _)| {
655                    let sel_tokens: HashSet<&str> = sel_line.split_whitespace().collect();
656                    if sel_tokens.is_empty() {
657                        return 0.0;
658                    }
659                    let inter = cand_tokens.intersection(&sel_tokens).count();
660                    let union = cand_tokens.union(&sel_tokens).count();
661                    if union == 0 {
662                        0.0
663                    } else {
664                        inter as f64 / union as f64
665                    }
666                })
667                .fold(0.0_f64, f64::max);
668
669            let mmr = cand_score - lambda * max_sim;
670            if mmr > best_mmr {
671                best_mmr = mmr;
672                best_idx = i;
673            }
674        }
675
676        selected.push(remaining.remove(best_idx));
677    }
678
679    selected
680}
681
682fn is_error_handling(line: &str) -> bool {
683    line.starts_with("return Err(")
684        || line.starts_with("Err(")
685        || line.starts_with("bail!(")
686        || line.starts_with("anyhow::bail!")
687        || line.contains(".map_err(")
688        || line.contains("unwrap()")
689        || line.contains("expect(\"")
690        || line.starts_with("raise ")
691        || line.starts_with("throw ")
692        || line.starts_with("catch ")
693        || line.starts_with("except ")
694        || line.starts_with("try ")
695        || (line.contains("?;") && !line.starts_with("//"))
696        || line.starts_with("panic!(")
697        || line.contains("Error::")
698        || line.contains("error!")
699}
700
701/// Compute an adaptive IB budget ratio based on content characteristics.
702/// Highly repetitive content → more aggressive filtering (lower ratio).
703/// High-entropy diverse content → more conservative (higher ratio).
704pub fn adaptive_ib_budget(content: &str, base_ratio: f64) -> f64 {
705    let lines: Vec<&str> = content.lines().collect();
706    if lines.len() < 10 {
707        return 1.0;
708    }
709
710    let mut token_freq: HashMap<&str, usize> = HashMap::new();
711    let mut total_tokens = 0usize;
712    for line in &lines {
713        for token in line.split_whitespace() {
714            *token_freq.entry(token).or_insert(0) += 1;
715            total_tokens += 1;
716        }
717    }
718
719    if total_tokens == 0 {
720        return base_ratio;
721    }
722
723    let unique_ratio = token_freq.len() as f64 / total_tokens as f64;
724    let repetition_factor = 1.0 - unique_ratio;
725
726    (base_ratio * (1.0 - repetition_factor * 0.3)).clamp(0.2, 1.0)
727}
728
729fn is_definition_line(line: &str) -> bool {
730    let prefixes = [
731        "fn ",
732        "pub fn ",
733        "async fn ",
734        "pub async fn ",
735        "struct ",
736        "pub struct ",
737        "enum ",
738        "pub enum ",
739        "trait ",
740        "pub trait ",
741        "impl ",
742        "type ",
743        "pub type ",
744        "const ",
745        "pub const ",
746        "static ",
747        "pub static ",
748        "class ",
749        "export class ",
750        "interface ",
751        "export interface ",
752        "function ",
753        "export function ",
754        "async function ",
755        "def ",
756        "async def ",
757        "func ",
758    ];
759    prefixes
760        .iter()
761        .any(|p| line.starts_with(p) || line.trim_start().starts_with(p))
762}
763
764fn is_control_flow(line: &str) -> bool {
765    let trimmed = line.trim();
766    trimmed.starts_with("if ")
767        || trimmed.starts_with("else ")
768        || trimmed.starts_with("match ")
769        || trimmed.starts_with("for ")
770        || trimmed.starts_with("while ")
771        || trimmed.starts_with("return ")
772        || trimmed.starts_with("break")
773        || trimmed.starts_with("continue")
774        || trimmed.starts_with("yield")
775        || trimmed.starts_with("await ")
776}
777
778fn is_closing_brace(line: &str) -> bool {
779    let trimmed = line.trim();
780    trimmed == "}" || trimmed == "};" || trimmed == "})" || trimmed == "});"
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    #[test]
788    fn parse_task_finds_files_and_keywords() {
789        let (files, keywords) =
790            parse_task_hints("Fix the authentication bug in src/auth.rs and update tests");
791        assert!(files.iter().any(|f| f.contains("auth.rs")));
792        assert!(
793            keywords
794                .iter()
795                .any(|k| k.to_lowercase().contains("authentication"))
796        );
797    }
798
799    #[test]
800    fn recommend_mode_by_score() {
801        assert_eq!(recommend_mode(1.0), "full");
802        assert_eq!(recommend_mode(0.6), "signatures");
803        assert_eq!(recommend_mode(0.3), "map");
804        assert_eq!(recommend_mode(0.1), "reference");
805    }
806
807    #[test]
808    fn info_bottleneck_preserves_definitions() {
809        let content = "fn main() {\n    let x = 42;\n    // boring comment\n    println!(x);\n}\n";
810        let result = information_bottleneck_filter(content, &["main".to_string()], 0.6);
811        assert!(result.contains("fn main"), "definitions must be preserved");
812        assert!(result.contains("[task: main]"), "should have task summary");
813    }
814
815    #[test]
816    fn info_bottleneck_error_handling_priority() {
817        let content = "fn validate() {\n    let data = parse()?;\n    return Err(\"invalid\");\n    let x = 1;\n    let y = 2;\n}\n";
818        let result = information_bottleneck_filter(content, &["validate".to_string()], 0.5);
819        assert!(
820            result.contains("return Err"),
821            "error handling should survive filtering"
822        );
823    }
824
825    #[test]
826    fn info_bottleneck_score_sorted() {
827        let content = "fn important() {\n    let x = 1;\n    let y = 2;\n    let z = 3;\n}\n}\n";
828        let result = information_bottleneck_filter(content, &[], 0.6);
829        let lines: Vec<&str> = result.lines().collect();
830        let def_pos = lines.iter().position(|l| l.contains("fn important"));
831        let brace_pos = lines.iter().position(|l| l.trim() == "}");
832        if let (Some(d), Some(b)) = (def_pos, brace_pos) {
833            assert!(
834                d < b,
835                "definitions should appear before closing braces in score-sorted output"
836            );
837        }
838    }
839
840    #[test]
841    fn adaptive_budget_reduces_for_repetitive() {
842        let repetitive = "let x = 1;\n".repeat(50);
843        let diverse = (0..50)
844            .map(|i| format!("let var_{i} = func_{i}(arg_{i});"))
845            .collect::<Vec<_>>()
846            .join("\n");
847        let budget_rep = super::adaptive_ib_budget(&repetitive, 0.7);
848        let budget_div = super::adaptive_ib_budget(&diverse, 0.7);
849        assert!(
850            budget_rep < budget_div,
851            "repetitive content should get lower budget"
852        );
853    }
854}