Skip to main content

lean_ctx_server/tools/
ctx_preload.rs

1// SPDX-License-Identifier: Apache-2.0
2use crate::tools::CrpMode;
3use crate::tools::admitted_sources::SourceRead;
4use lean_ctx::core::cache::SessionCache;
5use lean_ctx::core::graph_provider::{self, GraphProvider};
6use lean_ctx::core::protocol;
7use lean_ctx::core::task_relevance::{RelevanceScore, compute_relevance, parse_task_hints};
8use lean_ctx::core::tokens::count_tokens;
9
10/// Tool identity the sources are read for (path jail, role rules, audit).
11const TOOL: &str = "ctx_preload";
12const MAX_PRELOAD_FILES: usize = 8;
13const MAX_CRITICAL_LINES: usize = 15;
14const SIGNATURES_BUDGET: usize = 10;
15const TOTAL_TOKEN_BUDGET: usize = 4000;
16
17/// Returns the rendered preload plus whether it actually carries context
18/// (#1134). Callers must not re-derive that from the body: the guidance
19/// strings below are prose and drift, and a preloaded file may quote them.
20pub fn handle(
21    cache: &mut SessionCache,
22    task: &str,
23    path: Option<&str>,
24    _crp_mode: CrpMode,
25) -> (String, bool) {
26    if task.trim().is_empty() {
27        return (
28            "ERROR: ctx_preload requires a task description".to_string(),
29            false,
30        );
31    }
32
33    let project_root = path.map_or_else(|| ".".to_string(), std::string::ToString::to_string);
34    let jail_root = std::path::Path::new(&project_root);
35
36    let Some(open) = graph_provider::open_or_build(&project_root) else {
37        return (
38            format!(
39                "[task: {task}]\nNo graph available. Use ctx_compose(task=…) for a ranked project map."
40            ),
41            false,
42        );
43    };
44    let gp = &open.provider;
45
46    let session_intent =
47        lean_ctx::core::session::caller::with(|s| s.active_structured_intent.clone()).flatten();
48
49    let (task_files, task_keywords) = parse_task_hints(task);
50    let mut relevance = if let Some(ref intent) = session_intent {
51        lean_ctx::core::task_relevance::compute_relevance_from_intent(gp, intent)
52    } else {
53        compute_relevance(gp, &task_files, &task_keywords)
54    };
55    // Git working-set boost (#497): uncommitted + recently-churned files rank up.
56    lean_ctx::core::git_signals::apply_boost(&mut relevance, &project_root);
57    // Active build errors outrank everything (#499).
58    lean_ctx::core::diagnostics_store::apply_boost(&mut relevance);
59    // Editor focus (#500): the file the developer is looking at ranks up.
60    lean_ctx::core::editor_signal::apply_boost(&mut relevance);
61
62    let mut scored: Vec<_> = relevance
63        .iter()
64        .filter(|r| r.score >= 0.1)
65        .take(MAX_PRELOAD_FILES + 10)
66        .collect();
67
68    apply_heat_ranking(&mut scored, gp, &project_root);
69
70    let pop = lean_ctx::core::pop_pruning::decide_for_candidates(task, &project_root, &scored);
71    let candidates =
72        lean_ctx::core::pop_pruning::filter_candidates_by_pop(&project_root, &scored, &pop);
73
74    if candidates.is_empty() {
75        return (
76            format!(
77                "[task: {task}]\nNo directly relevant files found. Use ctx_compose(task=…) for a ranked project map."
78            ),
79            false,
80        );
81    }
82
83    // #1131: when the top score is below the minimum relevance threshold, the
84    // task likely does not match the current project state (stale persisted
85    // session, wrong worktree, etc.). Report the preload as unusable so
86    // autonomy does not inject it as AUTO CONTEXT.
87    const MIN_USEFUL_SCORE: f64 = 0.25;
88    let max_score = candidates.iter().map(|c| c.score).fold(0.0_f64, f64::max);
89    if max_score < MIN_USEFUL_SCORE {
90        return (
91            format!(
92                "[task: {task}]\nNo sufficiently relevant files (max score {max_score:.1} < {MIN_USEFUL_SCORE}). Use ctx_compose(task=…) for a ranked project map."
93            ),
94            false,
95        );
96    }
97
98    // Boltzmann allocation: p(file_i) = exp(score_i / T) / Z
99    // Temperature T is derived from task specificity:
100    //   - Many keywords / specific file mentions → low T → concentrate budget
101    //   - Few keywords / broad task → high T → spread budget evenly
102    let task_specificity =
103        (task_files.len() as f64 * 0.3 + task_keywords.len() as f64 * 0.1).clamp(0.0, 1.0);
104    let temperature = 0.8 - task_specificity * 0.6; // range [0.2, 0.8]
105    let temperature = temperature.max(0.1);
106
107    let allocations = boltzmann_allocate(&candidates, TOTAL_TOKEN_BUDGET, temperature);
108
109    // Every candidate is acquired ONCE, through the common rooted admitted
110    // reader (G01). The briefing, the critical lines, the signatures and the
111    // session cache below all derive from these admitted acquisitions; no
112    // second, raw read of a candidate exists.
113    let mut remaining =
114        lean_ctx::core::limits::max_read_bytes().saturating_mul(MAX_PRELOAD_FILES + 10);
115    let acquired: std::collections::HashMap<&str, Acquired> = candidates
116        .iter()
117        .filter_map(|c| {
118            acquire(&c.path, &project_root, jail_root, &mut remaining)
119                .map(|acquired| (c.path.as_str(), acquired))
120        })
121        .collect();
122
123    let file_context: Vec<(String, usize)> = candidates
124        .iter()
125        .filter_map(|c| {
126            acquired
127                .get(c.path.as_str())
128                .and_then(|acquired| acquired.read.text())
129                .map(|text| (c.path.clone(), text.lines().count()))
130        })
131        .collect();
132    let briefing = lean_ctx::core::task_briefing::build_briefing(task, &file_context);
133    let briefing_block = lean_ctx::core::task_briefing::format_briefing(&briefing);
134
135    let multi_intents = lean_ctx::core::intent_engine::detect_multi_intent(task);
136    let primary = &multi_intents[0];
137    let complexity = lean_ctx::core::intent_engine::classify_complexity(task, primary);
138
139    let mut output = Vec::new();
140    output.push(briefing_block);
141
142    let complexity_label = complexity.instruction_suffix().lines().next().unwrap_or("");
143    if multi_intents.len() > 1 {
144        output.push(format!(
145            "[task: {task}] | {} | {} sub-intents",
146            complexity_label,
147            multi_intents.len()
148        ));
149        for (i, sub) in multi_intents.iter().enumerate() {
150            output.push(format!(
151                "  {}. {} ({:.0}%)",
152                i + 1,
153                sub.task_type.as_str(),
154                sub.confidence * 100.0
155            ));
156        }
157    } else {
158        output.push(format!("[task: {task}] | {complexity_label}"));
159    }
160
161    for r in lean_ctx::core::prospective_memory::reminders_for_task(&project_root, task) {
162        output.push(r);
163    }
164
165    if !pop.excluded_modules.is_empty() {
166        output.push("POP:".to_string());
167        for ex in &pop.excluded_modules {
168            output.push(format!(
169                "  - exclude {}/ ({} candidates) — {}",
170                ex.module, ex.candidate_files, ex.reason
171            ));
172        }
173    }
174
175    let mut total_estimated_saved = 0usize;
176    let mut critical_count = 0usize;
177    let git_signals = lean_ctx::core::git_signals::collect(&project_root);
178
179    for (rel, token_budget) in candidates.iter().zip(allocations.iter()) {
180        if *token_budget < 20 {
181            continue;
182        }
183        critical_count += 1;
184        if critical_count > MAX_PRELOAD_FILES {
185            break;
186        }
187
188        let Some(source) = acquired.get(rel.path.as_str()) else {
189            continue;
190        };
191        let jailed_s = source.path.as_str();
192        let short = protocol::shorten_path(jailed_s);
193
194        // The session cache is a derived store: it receives the admitted
195        // text of a source whose ORIGINAL may be kept, and nothing else.
196        seed_cache(cache, source);
197
198        let content = match &source.read {
199            SourceRead::Admitted(read) => read.content.as_str(),
200            SourceRead::Withheld(reason) => {
201                output.push(format!(
202                    "\n{}",
203                    crate::tools::admitted_sources::withheld_line(&short, reason)
204                ));
205                continue;
206            }
207            SourceRead::Unavailable(_) => continue,
208        };
209
210        let file_ref = cache.get_file_ref(jailed_s);
211        let line_count = content.lines().count();
212        let file_tokens = count_tokens(content);
213
214        let mode = budget_to_mode(*token_budget, file_tokens);
215
216        let critical_lines = extract_critical_lines(content, &task_keywords, MAX_CRITICAL_LINES);
217        let sigs = extract_key_signatures(content, SIGNATURES_BUDGET);
218        let imports = extract_imports(content);
219
220        // Surface the git signal so the agent knows WHY a file ranked up (#497).
221        let git_marker = {
222            let recency = git_signals.recency_for(&rel.path, &project_root);
223            if recency >= 1.0 {
224                " ● uncommitted"
225            } else if recency > 0.5 {
226                " ● recent-commit"
227            } else {
228                ""
229            }
230        };
231        // Active diagnostics marker (#499): tell the agent which file is broken.
232        let diag_marker = {
233            let diags = lean_ctx::core::diagnostics_store::details_for(&rel.path);
234            diags
235                .iter()
236                .find(|(_, sev, _)| *sev == lean_ctx::core::diagnostics_store::Severity::Error)
237                .map(|(line, _, _)| match line {
238                    Some(l) => format!(" ✖ error L{l}"),
239                    None => " ✖ error".to_string(),
240                })
241                .unwrap_or_default()
242        };
243
244        output.push(format!(
245            "\nCRITICAL: {file_ref}={short} {line_count}L score={:.1} budget={token_budget}tok mode={mode}{git_marker}{diag_marker}",
246            rel.score
247        ));
248
249        if !critical_lines.is_empty() {
250            for (line_no, line) in &critical_lines {
251                output.push(format!("  :{line_no} {line}"));
252            }
253        }
254
255        if !imports.is_empty() {
256            output.push(format!("  imports: {}", imports.join(", ")));
257        }
258
259        if !sigs.is_empty() {
260            for sig in &sigs {
261                output.push(format!("  {sig}"));
262            }
263        }
264
265        total_estimated_saved += file_tokens;
266    }
267
268    let context_files: Vec<_> = relevance
269        .iter()
270        .filter(|r| r.score >= 0.1 && r.score < 0.3)
271        .take(10)
272        .collect();
273
274    if !context_files.is_empty() {
275        output.push("\nRELATED:".to_string());
276        for rel in &context_files {
277            let short = protocol::shorten_path(&rel.path);
278            output.push(format!(
279                "  {} mode={} score={:.1}",
280                short, rel.recommended_mode, rel.score
281            ));
282        }
283    }
284
285    let all_edges = gp.edges();
286    let graph_edges: Vec<_> = all_edges
287        .iter()
288        .filter(|e| {
289            candidates
290                .iter()
291                .any(|c| c.path == e.from || c.path == e.to)
292        })
293        .take(10)
294        .collect();
295
296    if !graph_edges.is_empty() {
297        output.push("\nGRAPH:".to_string());
298        for edge in &graph_edges {
299            let from_short = protocol::shorten_path(&edge.from);
300            let to_short = protocol::shorten_path(&edge.to);
301            output.push(format!("  {from_short} -> {to_short}"));
302        }
303    }
304
305    let preload_result = output.join("\n");
306    let preload_tokens = count_tokens(&preload_result);
307    let savings = protocol::format_savings(total_estimated_saved, preload_tokens);
308
309    let body = format!("{preload_result}\n{savings}");
310    (body, true)
311}
312
313/// One preload candidate as it was acquired: the jailed path it is cached and
314/// rendered under, and the outcome of the admitted read.
315struct Acquired {
316    path: String,
317    read: SourceRead,
318}
319
320/// Acquire one candidate under the project root. `None`: the path is outside
321/// the jail, is a secret-like path the role may only read with a warning, or
322/// is a cloud placeholder that an automatic preload must not hydrate (#363).
323fn acquire(
324    candidate: &str,
325    project_root: &str,
326    jail_root: &std::path::Path,
327    remaining: &mut usize,
328) -> Option<Acquired> {
329    let (jailed, warning) = lean_ctx::core::io_boundary::jail_and_check_path(
330        TOOL,
331        std::path::Path::new(candidate),
332        jail_root,
333    )
334    .ok()?;
335    if warning.is_some() || lean_ctx::core::cloud_files::is_cloud_placeholder(&jailed) {
336        return None;
337    }
338    let path = jailed.to_string_lossy().into_owned();
339    let read = crate::tools::admitted_sources::read(&path, project_root, TOOL, remaining);
340    Some(Acquired { path, read })
341}
342
343/// Seed the session cache from one acquisition. Only the admitted text of a
344/// source whose original is storable (complete, not restricted) enters it. A
345/// source that is withheld, restricted or incompletely decoded is not cached,
346/// and a copy an earlier call cached for the same path is dropped — the
347/// cache must not keep serving what the current policy no longer lets in.
348fn seed_cache(cache: &mut SessionCache, source: &Acquired) {
349    match (&source.read, source.read.storable()) {
350        (_, Some(text)) => {
351            let _ = cache.store(&source.path, text);
352        }
353        (SourceRead::Admitted(_) | SourceRead::Withheld(_), None) => {
354            let _ = cache.invalidate(&source.path);
355        }
356        (SourceRead::Unavailable(_), None) => {}
357    }
358}
359
360/// Boltzmann distribution for token budget allocation across files.
361/// p(file_i) = exp(score_i / T) / Z, then budget_i = total * p(file_i)
362fn boltzmann_allocate(
363    candidates: &[&lean_ctx::core::task_relevance::RelevanceScore],
364    total_budget: usize,
365    temperature: f64,
366) -> Vec<usize> {
367    if candidates.is_empty() {
368        return Vec::new();
369    }
370
371    let t = temperature.max(0.01);
372
373    // Compute exp(score / T) for each candidate, using log-sum-exp for numerical stability
374    let log_weights: Vec<f64> = candidates.iter().map(|c| c.score / t).collect();
375    let max_log = log_weights
376        .iter()
377        .copied()
378        .fold(f64::NEG_INFINITY, f64::max);
379    let exp_weights: Vec<f64> = log_weights.iter().map(|&lw| (lw - max_log).exp()).collect();
380    let z: f64 = exp_weights.iter().sum();
381
382    if z <= 0.0 {
383        return vec![total_budget / candidates.len().max(1); candidates.len()];
384    }
385
386    let mut allocations: Vec<usize> = exp_weights
387        .iter()
388        .map(|&w| ((w / z) * total_budget as f64).round() as usize)
389        .collect();
390
391    // Ensure total doesn't exceed budget
392    let sum: usize = allocations.iter().sum();
393    if sum > total_budget {
394        let overflow = sum - total_budget;
395        if let Some(last) = allocations.last_mut() {
396            *last = last.saturating_sub(overflow);
397        }
398    }
399
400    allocations
401}
402
403/// Map a token budget to a recommended compression mode.
404fn budget_to_mode(budget: usize, file_tokens: usize) -> &'static str {
405    let ratio = budget as f64 / file_tokens.max(1) as f64;
406    if ratio >= 0.8 {
407        "full"
408    } else if ratio >= 0.4 {
409        "signatures"
410    } else if ratio >= 0.15 {
411        "map"
412    } else {
413        "reference"
414    }
415}
416
417fn extract_critical_lines(content: &str, keywords: &[String], max: usize) -> Vec<(usize, String)> {
418    let kw_lower: Vec<String> = keywords.iter().map(|k| k.to_lowercase()).collect();
419
420    let mut hits: Vec<(usize, String, usize)> = content
421        .lines()
422        .enumerate()
423        .filter_map(|(i, line)| {
424            let trimmed = line.trim();
425            if trimmed.is_empty() {
426                return None;
427            }
428            let line_lower = trimmed.to_lowercase();
429            let hit_count = kw_lower
430                .iter()
431                .filter(|kw| line_lower.contains(kw.as_str()))
432                .count();
433
434            let is_error = trimmed.contains("Error")
435                || trimmed.contains("Err(")
436                || trimmed.contains("panic!")
437                || trimmed.contains("unwrap()")
438                || trimmed.starts_with("return Err");
439
440            if hit_count > 0 || is_error {
441                let priority = hit_count + if is_error { 2 } else { 0 };
442                Some((i + 1, trimmed.to_string(), priority))
443            } else {
444                None
445            }
446        })
447        .collect();
448
449    hits.sort_by_key(|x| std::cmp::Reverse(x.2));
450    hits.truncate(max);
451    hits.iter().map(|(n, l, _)| (*n, l.clone())).collect()
452}
453
454fn extract_key_signatures(content: &str, max: usize) -> Vec<String> {
455    let sig_starters = [
456        "pub fn ",
457        "pub async fn ",
458        "pub struct ",
459        "pub enum ",
460        "pub trait ",
461        "pub type ",
462        "pub const ",
463    ];
464
465    content
466        .lines()
467        .filter(|line| {
468            let trimmed = line.trim();
469            sig_starters.iter().any(|s| trimmed.starts_with(s))
470        })
471        .take(max)
472        .map(|line| {
473            let trimmed = line.trim();
474            if trimmed.len() > 120 {
475                format!("{}...", &trimmed[..trimmed.floor_char_boundary(117)])
476            } else {
477                trimmed.to_string()
478            }
479        })
480        .collect()
481}
482
483fn extract_imports(content: &str) -> Vec<String> {
484    content
485        .lines()
486        .filter(|line| {
487            let t = line.trim();
488            t.starts_with("use ") || t.starts_with("import ") || t.starts_with("from ")
489        })
490        .take(8)
491        .map(|line| {
492            let t = line.trim();
493            if let Some(rest) = t.strip_prefix("use ") {
494                rest.trim_end_matches(';').to_string()
495            } else {
496                t.to_string()
497            }
498        })
499        .collect()
500}
501
502fn apply_heat_ranking(candidates: &mut [&RelevanceScore], gp: &GraphProvider, root: &str) {
503    if gp.file_count() == 0 {
504        return;
505    }
506
507    let all_edges = gp.edges();
508    let mut connection_counts: std::collections::HashMap<String, usize> =
509        std::collections::HashMap::new();
510    for edge in &all_edges {
511        *connection_counts.entry(edge.from.clone()).or_default() += 1;
512        *connection_counts.entry(edge.to.clone()).or_default() += 1;
513    }
514
515    let mut max_tokens = 1usize;
516    for path in gp.file_paths() {
517        if let Some(entry) = gp.get_file_entry(&path) {
518            max_tokens = max_tokens.max(entry.token_count);
519        }
520    }
521    let max_tokens = max_tokens as f64;
522    let max_conn = connection_counts.values().max().copied().unwrap_or(1) as f64;
523
524    candidates.sort_by(|a, b| {
525        let heat_a = compute_heat(&a.path, root, gp, &connection_counts, max_tokens, max_conn);
526        let heat_b = compute_heat(&b.path, root, gp, &connection_counts, max_tokens, max_conn);
527        let combined_a = a.score * 0.6 + heat_a * 0.4;
528        let combined_b = b.score * 0.6 + heat_b * 0.4;
529        combined_b
530            .partial_cmp(&combined_a)
531            .unwrap_or(std::cmp::Ordering::Equal)
532    });
533}
534
535fn compute_heat(
536    path: &str,
537    root: &str,
538    gp: &GraphProvider,
539    connections: &std::collections::HashMap<String, usize>,
540    max_tokens: f64,
541    max_conn: f64,
542) -> f64 {
543    let rel = path
544        .strip_prefix(root)
545        .unwrap_or(path)
546        .trim_start_matches('/');
547
548    if let Some(entry) = gp.get_file_entry(rel) {
549        let conn = connections.get(rel).copied().unwrap_or(0);
550        let token_norm = entry.token_count as f64 / max_tokens;
551        let conn_norm = conn as f64 / max_conn;
552        token_norm * 0.4 + conn_norm * 0.6
553    } else {
554        0.0
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    /// #1134: the "did this preload carry anything" answer travels with the
563    /// body. The guidance text is prose that has drifted before, so autonomy
564    /// must not have to recognise it.
565    #[test]
566    fn guidance_only_preloads_report_themselves_as_unusable() {
567        let mut cache = SessionCache::new();
568        let (body, usable) = handle(&mut cache, "   ", None, CrpMode::Off);
569        assert!(!usable, "an empty task carries no context: {body}");
570
571        let dir = tempfile::tempdir().unwrap();
572        let root = dir.path().to_string_lossy().to_string();
573        let (body, usable) = handle(&mut cache, "unrelated task", Some(&root), CrpMode::Off);
574        assert!(
575            !usable,
576            "a preload with no candidate files carries no context: {body}"
577        );
578    }
579
580    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
581
582    fn project_file(root: &std::path::Path, name: &str, content: &str) {
583        std::fs::write(root.join(name), content).unwrap();
584    }
585
586    /// G01: the old preload read each candidate with `fs::read_to_string` and
587    /// stored those raw bytes in the session cache — a key `ctx_read` masks
588    /// was served from the cache afterwards, and a restricted source was
589    /// kept. The cache now receives the admitted text of a storable original
590    /// only.
591    #[test]
592    fn the_session_cache_is_seeded_with_admitted_storable_sources_only() {
593        let _data = lean_ctx::core::data_dir::isolated_data_dir();
594        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
595        let project = tempfile::tempdir().unwrap();
596        let root = project.path().to_string_lossy().into_owned();
597        project_file(project.path(), "clean.rs", "pub fn clean() {}\n");
598        project_file(
599            project.path(),
600            "keyed.rs",
601            &format!("pub fn connect() {{\n    let aws_key = \"{AWS_KEY}\";\n}}\n"),
602        );
603        project_file(
604            project.path(),
605            "marked.rs",
606            "// TOP SECRET\npub fn plan() {}\n",
607        );
608        let mut cache = SessionCache::new();
609        let mut remaining = lean_ctx::core::limits::max_read_bytes().saturating_mul(4);
610
611        // Control: a clean source is cached byte-identically, as before.
612        let clean = acquire("clean.rs", &root, project.path(), &mut remaining).unwrap();
613        seed_cache(&mut cache, &clean);
614        assert_eq!(
615            cache.get_full_content(&clean.path).as_deref(),
616            Some("pub fn clean() {}\n")
617        );
618
619        let keyed = acquire("keyed.rs", &root, project.path(), &mut remaining).unwrap();
620        seed_cache(&mut cache, &keyed);
621        let cached = cache
622            .get_full_content(&keyed.path)
623            .expect("the masked source is cached");
624        assert!(!cached.contains(AWS_KEY), "the cache holds a raw key");
625        assert!(cached.contains("pub fn connect"), "{cached}");
626
627        // A restricted original is delivered to its reader, never retained;
628        // a copy cached before is dropped.
629        let marked = acquire("marked.rs", &root, project.path(), &mut remaining).unwrap();
630        let _ = cache.store(&marked.path, "// TOP SECRET\npub fn plan() {}\n");
631        seed_cache(&mut cache, &marked);
632        assert!(marked.read.text().is_some_and(|text| text.contains("plan")));
633        assert!(cache.get_full_content(&marked.path).is_none());
634    }
635
636    #[test]
637    fn a_withheld_candidate_is_not_cached_and_loses_its_earlier_copy() {
638        let _data = lean_ctx::core::data_dir::isolated_data_dir();
639        let policy = lean_ctx::core::policy::load(
640            "name = \"ctx-preload-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
641             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
642        )
643        .unwrap();
644        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
645        let project = tempfile::tempdir().unwrap();
646        let root = project.path().to_string_lossy().into_owned();
647        let source = "CONFIDENTIAL\npub fn hidden_marker() {}\n";
648        project_file(project.path(), "plan.rs", source);
649        let mut cache = SessionCache::new();
650        let mut remaining = lean_ctx::core::limits::max_read_bytes();
651
652        let plan = acquire("plan.rs", &root, project.path(), &mut remaining).unwrap();
653        let _ = cache.store(&plan.path, source);
654        seed_cache(&mut cache, &plan);
655        let reason = plan.read.withheld().expect("blocked by the policy");
656        assert!(!reason.contains("hidden_marker"), "{reason}");
657        assert!(cache.get_full_content(&plan.path).is_none());
658    }
659
660    #[test]
661    fn extract_critical_lines_finds_keywords() {
662        let content = "fn main() {\n    let token = validate();\n    return Err(e);\n}\n";
663        let result = extract_critical_lines(content, &["validate".to_string()], 5);
664        assert!(!result.is_empty(), "`result` is empty");
665        assert!(result.iter().any(|(_, l)| l.contains("validate")));
666    }
667
668    #[test]
669    fn extract_critical_lines_prioritizes_errors() {
670        let content = "fn main() {\n    let x = 1;\n    return Err(\"bad\");\n    let token = validate();\n}\n";
671        let result = extract_critical_lines(content, &["validate".to_string()], 5);
672        assert!(result.len() >= 2);
673        assert!(result[0].1.contains("Err"), "errors should be first");
674    }
675
676    #[test]
677    fn extract_key_signatures_finds_pub() {
678        let content = "use std::io;\nfn private() {}\npub fn public_one() {}\npub struct Foo {}\n";
679        let sigs = extract_key_signatures(content, 10);
680        assert_eq!(sigs.len(), 2);
681        assert!(sigs[0].contains("pub fn public_one"));
682        assert!(sigs[1].contains("pub struct Foo"));
683    }
684
685    #[test]
686    fn extract_imports_works() {
687        let content = "use std::io;\nuse crate::core::cache;\nfn main() {}\n";
688        let imports = extract_imports(content);
689        assert_eq!(imports.len(), 2);
690        assert!(imports[0].contains("std::io"));
691    }
692}