Skip to main content

lean_ctx_server/tools/registered/
ctx_compose.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::{BTreeMap, HashSet};
3use std::path::Path;
4
5use lean_ctx::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
6use rmcp::ErrorData;
7use rmcp::model::Tool;
8use serde_json::{Map, Value, json};
9
10use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_bool, get_str};
11use lean_ctx::tool_defs::tool_def;
12
13pub struct CtxComposeTool;
14
15const MIN_TASK_AWARE_COVERAGE: f32 = 0.20;
16const TASK_AWARE_TOP_K: usize = 2;
17const TASK_AWARE_DIVERSE_SECTIONS: usize = 1;
18const PROFILE_KEYWORD_WEIGHT: f32 = 0.45;
19const STOP_WORD_WEIGHT: f32 = 0.20;
20const QUERY_STOP_WORDS: &[&str] = &[
21    "a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "how", "in", "is", "it", "of",
22    "on", "or", "that", "the", "this", "to", "use", "what", "with",
23];
24
25#[derive(Debug, Clone)]
26struct QueryKeyword {
27    term: String,
28    weight: f32,
29}
30
31#[derive(Debug)]
32struct ScoredSection<'a> {
33    index: usize,
34    text: &'a str,
35    score: f32,
36    matched_terms: HashSet<String>,
37}
38
39fn normalized_keywords(input: &str) -> Vec<String> {
40    let mut keywords = input
41        .split(|character: char| !character.is_alphanumeric())
42        .map(str::to_lowercase)
43        .filter(|keyword| keyword.chars().count() > 1)
44        .collect::<Vec<_>>();
45    keywords.sort_unstable();
46    keywords.dedup();
47    keywords
48}
49
50fn add_keywords(weights: &mut BTreeMap<String, f32>, input: &str, source_weight: f32) {
51    for keyword in normalized_keywords(input) {
52        let weight = if QUERY_STOP_WORDS.contains(&keyword.as_str()) {
53            source_weight * STOP_WORD_WEIGHT
54        } else {
55            source_weight
56        };
57        weights
58            .entry(keyword)
59            .and_modify(|existing| *existing = (*existing).max(weight))
60            .or_insert(weight);
61    }
62}
63
64fn task_aware_keywords(
65    task: &str,
66    profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
67) -> Vec<QueryKeyword> {
68    let mut weights = BTreeMap::new();
69    // The caller's task is the retrieval query. Profile fields only provide a
70    // lower-weight session hint so broad classes such as `bug_fix` cannot win.
71    add_keywords(&mut weights, task, 1.0);
72    add_keywords(&mut weights, &profile.task_class, PROFILE_KEYWORD_WEIGHT);
73    add_keywords(&mut weights, &profile.intent, PROFILE_KEYWORD_WEIGHT);
74    weights
75        .into_iter()
76        .map(|(term, weight)| QueryKeyword { term, weight })
77        .collect()
78}
79
80fn score_section(chunk: &str, keywords: &[QueryKeyword]) -> (f32, HashSet<String>) {
81    let terms = normalized_keywords(chunk)
82        .into_iter()
83        .collect::<HashSet<_>>();
84    let mut matched_terms = HashSet::new();
85    let mut matched_weight = 0.0;
86    let mut total_weight = 0.0;
87
88    for keyword in keywords {
89        total_weight += keyword.weight;
90        if terms.contains(&keyword.term) {
91            matched_weight += keyword.weight;
92            matched_terms.insert(keyword.term.clone());
93        }
94    }
95
96    let score = if total_weight > 0.0 {
97        matched_weight / total_weight
98    } else {
99        0.0
100    };
101    (score, matched_terms)
102}
103
104fn select_task_aware_sections<'a>(
105    mut sections: Vec<ScoredSection<'a>>,
106    keywords: &[QueryKeyword],
107    task_terms: &HashSet<String>,
108) -> Vec<ScoredSection<'a>> {
109    sections.retain(|section| section.score >= MIN_TASK_AWARE_COVERAGE);
110    sections.sort_by(|left, right| {
111        right
112            .score
113            .total_cmp(&left.score)
114            .then_with(|| left.index.cmp(&right.index))
115    });
116
117    let top_k = sections.len().min(TASK_AWARE_TOP_K);
118    let mut selected = sections.drain(..top_k).collect::<Vec<_>>();
119    let mut covered_terms = selected
120        .iter()
121        .flat_map(|section| section.matched_terms.iter().cloned())
122        .collect::<HashSet<_>>();
123
124    for _ in 0..TASK_AWARE_DIVERSE_SECTIONS {
125        let Some((position, _)) = sections
126            .iter()
127            .enumerate()
128            .filter_map(|(position, section)| {
129                let novel_weight = keywords
130                    .iter()
131                    .filter(|keyword| {
132                        task_terms.contains(&keyword.term)
133                            && section.matched_terms.contains(&keyword.term)
134                            && !covered_terms.contains(&keyword.term)
135                    })
136                    .map(|keyword| keyword.weight)
137                    .sum::<f32>();
138                (novel_weight > 0.0).then_some((position, novel_weight))
139            })
140            .max_by(
141                |(left_position, left_weight), (right_position, right_weight)| {
142                    left_weight
143                        .total_cmp(right_weight)
144                        .then_with(|| {
145                            sections[*left_position]
146                                .score
147                                .total_cmp(&sections[*right_position].score)
148                        })
149                        .then_with(|| {
150                            sections[*right_position]
151                                .index
152                                .cmp(&sections[*left_position].index)
153                        })
154                },
155            )
156        else {
157            break;
158        };
159
160        let section = sections.remove(position);
161        covered_terms.extend(section.matched_terms.iter().cloned());
162        selected.push(section);
163    }
164
165    selected
166}
167
168fn apply_task_aware_filter(
169    output: &str,
170    task: &str,
171    profile: &lean_ctx::core::triage::profile::TaskProfileLocal,
172    enabled: bool,
173) -> String {
174    if !enabled {
175        return output.to_owned();
176    }
177
178    let task_terms = normalized_keywords(task)
179        .into_iter()
180        .collect::<HashSet<_>>();
181    if task_terms.is_empty() {
182        return output.to_owned();
183    }
184    let keywords = task_aware_keywords(task, profile);
185    if keywords.is_empty() {
186        return output.to_owned();
187    }
188
189    let mut chunks = output.split("\n## ");
190    let prefix = chunks.next().unwrap_or_default();
191    let sections = chunks
192        .enumerate()
193        .map(|(index, chunk)| {
194            let (score, matched_terms) = score_section(chunk, &keywords);
195            ScoredSection {
196                index,
197                text: chunk,
198                score,
199                matched_terms,
200            }
201        })
202        .collect::<Vec<_>>();
203    let selected = select_task_aware_sections(sections, &keywords, &task_terms);
204    if selected.is_empty() {
205        return output.to_owned();
206    }
207
208    // Keep selected source byte-for-byte. The global dispatch pipeline owns
209    // line triage and the final turn budget, so compose is never compressed twice.
210    let mut filtered = prefix.to_owned();
211    for section in selected {
212        filtered.push_str("\n## ");
213        filtered.push_str(section.text);
214    }
215    filtered
216}
217
218fn current_task_profile(
219    ctx: &ToolContext,
220) -> Option<lean_ctx::core::triage::profile::TaskProfileLocal> {
221    let session = ctx.session.as_ref()?.try_read().ok()?;
222    lean_ctx::core::decision_loop_runtime::DecisionLoopRuntime::get_or_init()
223        .profile_for_session(&session.id)
224}
225
226const SOLUTION_HINT_ITEM_LIMIT: usize = 20;
227const UTILITY_DIRECTORIES: &[&str] = &["utils", "helpers", "common"];
228
229/// Collect declared dependencies from manifest files at the project root.
230fn project_dependencies(project_root: &Path) -> Vec<String> {
231    let mut dependencies = Vec::new();
232    cargo_dependencies(&project_root.join("Cargo.toml"), &mut dependencies);
233    package_dependencies(&project_root.join("package.json"), &mut dependencies);
234    requirements_dependencies(&project_root.join("requirements.txt"), &mut dependencies);
235    go_dependencies(&project_root.join("go.mod"), &mut dependencies);
236    dependencies
237}
238
239fn read_manifest(manifest: &Path) -> std::io::Result<String> {
240    let root = manifest.parent().unwrap_or(manifest);
241    crate::tools::ctx_read::read_file_for_tool_rooted(
242        &manifest.to_string_lossy(),
243        &root.to_string_lossy(),
244        "ctx_compose",
245    )
246}
247
248fn cargo_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
249    let Ok(content) = read_manifest(manifest) else {
250        return;
251    };
252
253    let mut in_dependencies = false;
254    for line in content.lines() {
255        let trimmed = line.trim();
256        if let Some(section) = trimmed.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
257            in_dependencies = section == "dependencies"
258                || section == "workspace.dependencies"
259                || section.ends_with(".dependencies");
260            continue;
261        }
262        if !in_dependencies || trimmed.is_empty() || trimmed.starts_with('#') {
263            continue;
264        }
265        if let Some((name, _)) = trimmed.split_once('=') {
266            add_dependency(dependencies, name.trim().trim_matches(['\"', '\'']));
267        }
268    }
269}
270
271fn package_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
272    let Ok(content) = read_manifest(manifest) else {
273        return;
274    };
275
276    for section in ["dependencies", "devDependencies"] {
277        let Some(dependency_object) = json_object_section(&content, section) else {
278            continue;
279        };
280        for entry in dependency_object.split(',') {
281            let name = entry
282                .trim_start()
283                .strip_prefix('"')
284                .and_then(|entry| entry.split_once('"'))
285                .map(|(name, _)| name)
286                .unwrap_or_default();
287            add_dependency(dependencies, name);
288        }
289    }
290}
291
292fn json_object_section<'a>(content: &'a str, section: &str) -> Option<&'a str> {
293    let section_marker = format!("\"{section}\"");
294    let section_start = content.find(&section_marker)? + section_marker.len();
295    let value = content[section_start..].split_once(':')?.1.trim_start();
296    if !value.starts_with('{') {
297        return None;
298    }
299    let object_start = content.len() - value.len();
300    let mut depth = 0usize;
301    let mut in_string = false;
302    let mut escaped = false;
303
304    for (offset, character) in content[object_start..].char_indices() {
305        if in_string {
306            if escaped {
307                escaped = false;
308            } else if character == '\\' {
309                escaped = true;
310            } else if character == '"' {
311                in_string = false;
312            }
313            continue;
314        }
315        match character {
316            '"' => in_string = true,
317            '{' => depth += 1,
318            '}' => {
319                depth -= 1;
320                if depth == 0 {
321                    return Some(&content[object_start + 1..object_start + offset]);
322                }
323            }
324            _ => {}
325        }
326    }
327    None
328}
329
330fn requirements_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
331    let Ok(content) = read_manifest(manifest) else {
332        return;
333    };
334
335    for line in content.lines() {
336        let requirement = line.split('#').next().unwrap_or_default().trim();
337        if requirement.is_empty() || requirement.starts_with('-') {
338            continue;
339        }
340        let name = requirement
341            .split(|c: char| matches!(c, '=' | '<' | '>' | '!' | '~' | ';' | '[' | ' '))
342            .next()
343            .unwrap_or_default()
344            .trim();
345        add_dependency(dependencies, name);
346    }
347}
348
349fn go_dependencies(manifest: &Path, dependencies: &mut Vec<String>) {
350    let Ok(content) = read_manifest(manifest) else {
351        return;
352    };
353
354    let mut in_require_block = false;
355    for line in content.lines() {
356        let trimmed = line.split("//").next().unwrap_or_default().trim();
357        if trimmed.is_empty() {
358            continue;
359        }
360        if let Some(requirement) = trimmed.strip_prefix("require") {
361            let requirement = requirement.trim();
362            if requirement == "(" {
363                in_require_block = true;
364            } else if let Some(name) = requirement.split_whitespace().next() {
365                add_dependency(dependencies, name);
366            }
367            continue;
368        }
369        if in_require_block && trimmed == ")" {
370            in_require_block = false;
371            continue;
372        }
373        if in_require_block {
374            if let Some(name) = trimmed.split_whitespace().next() {
375                add_dependency(dependencies, name);
376            }
377        }
378    }
379}
380
381fn add_dependency(dependencies: &mut Vec<String>, dependency: &str) {
382    if !dependency.is_empty() && !dependencies.iter().any(|known| known == dependency) {
383        dependencies.push(dependency.to_string());
384    }
385}
386
387/// List files in conventional utility directories rooted at the project.
388fn project_utility_files(project_root: &Path) -> Vec<String> {
389    let mut files = Vec::new();
390    let protected = lean_ctx::core::policy::runtime::is_active();
391    let mut inspected = 0usize;
392    let mut remaining_bytes = 8 * 1024 * 1024;
393    'directories: for directory in UTILITY_DIRECTORIES {
394        let utility_root = project_root.join(directory);
395        if !utility_root.is_dir() {
396            continue;
397        }
398        for entry in ignore::WalkBuilder::new(&utility_root)
399            .hidden(false)
400            .follow_links(false)
401            .build()
402            .flatten()
403            .take(256)
404        {
405            if !entry
406                .file_type()
407                .is_some_and(|file_type| file_type.is_file())
408            {
409                continue;
410            }
411            if let Ok(relative_path) = entry.path().strip_prefix(project_root) {
412                if lean_ctx::core::io_boundary::jail_and_check_path(
413                    "ctx_compose",
414                    entry.path(),
415                    project_root,
416                )
417                .is_err()
418                {
419                    continue;
420                }
421                if protected {
422                    if inspected >= 32 || remaining_bytes == 0 {
423                        break 'directories;
424                    }
425                    inspected += 1;
426                    let Ok(_) = crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
427                        &entry.path().to_string_lossy(),
428                        &project_root.to_string_lossy(),
429                        "ctx_compose",
430                        &mut remaining_bytes,
431                    ) else {
432                        continue;
433                    };
434                }
435                files.push(relative_path.to_string_lossy().replace('\\', "/"));
436            }
437        }
438    }
439    files.sort();
440    files.dedup();
441    files
442}
443
444fn utility_hint(utility_files: &[String]) -> Option<String> {
445    (!utility_files.is_empty()).then(|| {
446        let listed = utility_files
447            .iter()
448            .take(SOLUTION_HINT_ITEM_LIMIT)
449            .map(String::as_str)
450            .collect::<Vec<_>>();
451        let remaining = utility_files.len().saturating_sub(listed.len());
452        let suffix = (remaining > 0).then(|| format!(", … (+{remaining} more)"));
453        format!(
454            "Existing utilities: {}{}",
455            listed.join(", "),
456            suffix.unwrap_or_default()
457        )
458    })
459}
460
461/// Extract unique file paths from compose output and sum their raw byte sizes
462/// to compute what the agent would have read without compose.
463fn project_stdlib_hint(project_root: &Path) -> &'static str {
464    if read_manifest(&project_root.join("Cargo.toml")).is_ok() {
465        "std::{fs, path, collections, io, process}"
466    } else if read_manifest(&project_root.join("package.json")).is_ok() {
467        "node:fs, node:path, node:util, node:child_process"
468    } else if read_manifest(&project_root.join("requirements.txt")).is_ok()
469        || read_manifest(&project_root.join("pyproject.toml")).is_ok()
470    {
471        "pathlib, collections, json, subprocess"
472    } else if read_manifest(&project_root.join("go.mod")).is_ok() {
473        "io, os, path/filepath, strings, net/http"
474    } else {
475        "filesystem, collections, and process APIs"
476    }
477}
478
479fn estimate_raw_input_tokens(compose_output: &str, project_root: &str) -> usize {
480    // Text-derived paths are not receipts for original protected bytes. Avoid
481    // disclosing metadata or claiming savings from an unverified source set.
482    if lean_ctx::core::policy::runtime::is_active() {
483        return 0;
484    }
485    let mut seen = HashSet::new();
486    let mut raw_bytes: u64 = 0;
487    let root = Path::new(project_root);
488
489    for line in compose_output.lines() {
490        let trimmed = line.trim();
491        let candidate = if let Some(rest) = trimmed.strip_prefix("// ") {
492            rest.split(':').next().map(str::trim)
493        } else if trimmed.bytes().next().is_some_and(|b| b.is_ascii_digit()) {
494            trimmed
495                .split_once(". ")
496                .map(|x| x.1)
497                .and_then(|s| s.split(" (").next())
498                .map(str::trim)
499        } else if trimmed.contains(':') && !trimmed.starts_with('#') && !trimmed.starts_with("TASK")
500        {
501            let part = trimmed.split(':').next().unwrap_or("").trim();
502            if part.contains('.') && !part.contains(' ') {
503                Some(part)
504            } else {
505                None
506            }
507        } else {
508            None
509        };
510
511        if let Some(rel) = candidate {
512            if rel.is_empty() || rel.len() > 256 {
513                continue;
514            }
515            let full = root.join(rel);
516            if lean_ctx::core::io_boundary::jail_and_check_path("ctx_compose", &full, root).is_err()
517            {
518                continue;
519            }
520            if seen.insert(full.clone()) {
521                if let Ok(meta) = std::fs::metadata(&full) {
522                    if meta.is_file() {
523                        raw_bytes += meta.len();
524                    }
525                }
526            }
527        }
528    }
529
530    (raw_bytes / 4) as usize
531}
532
533impl McpTool for CtxComposeTool {
534    fn name(&self) -> &'static str {
535        "ctx_compose"
536    }
537
538    fn tool_def(&self) -> Tool {
539        tool_def(
540            "ctx_compose",
541            "First-pass context for one task; returns ranked files and inline source — use instead of search→read chains.",
542            json!({
543                "type": "object",
544                "properties": {
545                    "task": { "type": "string", "description": "Short English task/question or symbol names" },
546                    "path": { "type": "string", "description": "Project root" },
547                    "task_aware": { "type": "boolean", "default": true, "description": "Rank compose sections against the task (default: true)" }
548                },
549                "required": ["task"]
550            }),
551        )
552    }
553
554    fn handle(
555        &self,
556        args: &Map<String, Value>,
557        ctx: &ToolContext,
558    ) -> Result<ToolOutput, ErrorData> {
559        let mut pending_cache = None;
560        let result =
561            lean_ctx::core::policy::runtime::with_project_source_view(&ctx.project_root, || {
562                // Search-style tools taught agents `query`; ctx_compose declares
563                // no other text field, so those spellings can only mean the task.
564                let task = ["task", "query", "question", "prompt", "goal"]
565                    .iter()
566                    .find_map(|key| get_str(args, key).filter(|v| !v.trim().is_empty()))
567                    .ok_or_else(|| {
568                        ErrorData::invalid_params(
569                            "task is required — the question or goal to gather context for, \
570                             e.g. ctx_compose(task=\"how is auth wired\")",
571                            None,
572                        )
573                    })?;
574                let task_aware = get_bool(args, "task_aware").unwrap_or(true);
575                let path = if let Some(p) = ctx.resolved_path("path") {
576                    p.to_string()
577                } else if let Some(err) = ctx.path_error("path") {
578                    return Err(ErrorData::invalid_params(format!("path: {err}"), None));
579                } else {
580                    ctx.project_root.clone()
581                };
582
583                // Share the resident BM25 cache with the composed semantic search.
584                if let Some(ref cache) = ctx.bm25_cache {
585                    crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
586                }
587
588                let cache_enabled = lean_ctx::core::config::Config::load()
589                    .cache
590                    .compose_cache_enabled;
591                let cache_task =
592                    lean_ctx::core::context_kernel::bridge::runtime::compose_cache_task(&task);
593                let cached = cache_enabled
594                    .then(|| lean_ctx::core::ocla::compose_cache::global().check(&cache_task, &path))
595                    .flatten();
596                let (text, _) = if let Some(text) = cached {
597                    let sent = lean_ctx::core::tokens::count_tokens(&text);
598                    (text, sent)
599                } else {
600                    // Cross-process delivery check before expensive computation
601                    let source_revision = lean_ctx::core::ocla::compose_cache::source_revision(&path);
602                    let compose_builder = ComposedContextKey {
603                        task: cache_task.clone(),
604                        path: path.clone(),
605                        source_digests: source_revision.iter().cloned().collect(),
606                    };
607                    let ck = compose_builder.cache_key();
608                    let cv = compose_builder.validator();
609                    if let Some(entry) = source_revision.as_ref().and_then(|_| {
610                        lean_ctx::core::ocla::cache_delivery::check(&ck, &cv, "ctx_compose")
611                    }) {
612                        let stub = lean_ctx::core::ocla::cache_delivery::stub(&entry, "compose");
613                        let sent = lean_ctx::core::tokens::count_tokens(&stub);
614                        (stub, sent)
615                    } else {
616                        let (text, sent) = tokio::task::block_in_place(|| {
617                            crate::tools::ctx_compose::handle(&task, &path, ctx.crp_mode)
618                        });
619                        if cache_enabled && source_revision.is_some() && !text.starts_with("ERROR")
620                        {
621                            pending_cache = Some((
622                                cache_task,
623                                path.clone(),
624                                text.clone(),
625                                source_revision,
626                                ck,
627                                cv,
628                            ));
629                        }
630                        (text, sent)
631                    }
632                };
633
634                if text.starts_with("ERROR") {
635                    return Err(ErrorData::invalid_params(text, None));
636                }
637
638                let text = current_task_profile(ctx).map_or_else(
639                    || text.clone(),
640                    |profile| apply_task_aware_filter(&text, &task, &profile, task_aware),
641                );
642                let text = {
643                    let cfg = lean_ctx::core::config::Config::load();
644                    if cfg.solution.enabled && cfg.solution.inject_in_compose {
645                        let project_root = Path::new(&path);
646                        let project_deps = project_dependencies(project_root);
647                        let helper_files = project_utility_files(project_root);
648                        let dependencies = if project_deps.is_empty() {
649                            "no manifest dependencies detected".to_string()
650                        } else {
651                            project_deps.join(", ")
652                        };
653                        let mut hints = format!(
654                            "--- SOLUTION HINTS ---\n\
655• Found {} existing helpers that may apply\n\
656• Project uses: {} (check before adding new)\n\
657• stdlib covers: {}",
658                            helper_files.len(),
659                            dependencies,
660                            project_stdlib_hint(project_root),
661                        );
662                        if let Some(utility_hint) = utility_hint(&helper_files) {
663                            hints.push('\n');
664                            hints.push_str(&utility_hint);
665                        }
666                        if hints.is_empty() {
667                            text
668                        } else {
669                            format!("{text}\n\n{hints}")
670                        }
671                    } else {
672                        text
673                    }
674                };
675                let sent = lean_ctx::core::tokens::count_tokens(&text);
676
677                let raw_tokens = estimate_raw_input_tokens(&text, &path);
678                let original = if raw_tokens > sent { raw_tokens } else { sent };
679                let saved = original.saturating_sub(sent);
680
681                Ok(ToolOutput {
682                    text,
683                    original_tokens: original,
684                    saved_tokens: saved,
685                    mode: Some("compose".to_string()),
686                    path: Some(path),
687                    changed: false,
688                    shell_outcome: None,
689                    content_blocks: None,
690                })
691            })
692            .unwrap_or_else(|reason| {
693                // The reason is a fixed, content-free policy-runtime string;
694                // naming it separates a real policy change from a stale view.
695                Err(ErrorData::internal_error(
696                    format!(
697                        "Context output withheld: source authority changed or could not be verified ({reason})"
698                    ),
699                    None,
700                ))
701            });
702        // No candidate content is persisted before the source-view guard accepts it.
703        if result.is_ok() {
704            if let Some((task, path, text, Some(revision), key, validator)) = pending_cache {
705                let _ = lean_ctx::core::policy::runtime::with_project_source_view(
706                    &ctx.project_root,
707                    || {
708                        if lean_ctx::core::ocla::compose_cache::global().record(
709                            &task,
710                            &path,
711                            text.clone(),
712                            &revision,
713                        ) {
714                            lean_ctx::core::ocla::cache_delivery::record(
715                                key,
716                                lean_ctx::core::ocla::cache_types::DeliveryKind::ComposedContext,
717                                validator,
718                                Some(path),
719                                &text,
720                                "ctx_compose",
721                            );
722                        }
723                    },
724                );
725            }
726        }
727        result
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use lean_ctx::core::triage::profile::{TaskProfileLocal, TaskScopeLocal};
735
736    fn profile(task_class: &str, intent: &str, context_need_milli: u16) -> TaskProfileLocal {
737        TaskProfileLocal {
738            task_class: task_class.into(),
739            intent: intent.into(),
740            complexity: "low".into(),
741            scope: TaskScopeLocal::SingleFile,
742            context_need_milli,
743            reasoning_need_milli: 0,
744            risk_signal_milli: 0,
745            confidence_milli: 500,
746        }
747    }
748
749    #[test]
750    fn task_aware_filter_can_be_disabled() {
751        let output = "TASK: test\n\n## Unrelated\nwidget catalog\n";
752        assert_eq!(
753            apply_task_aware_filter(
754                output,
755                "target query",
756                &profile("bug_fix", "fix filtering", 700),
757                false
758            ),
759            output
760        );
761    }
762
763    use super::{project_dependencies, project_utility_files};
764
765    #[test]
766    fn detects_root_manifest_dependencies() {
767        let root = tempfile::tempdir().expect("tempdir");
768        std::fs::write(
769            root.path().join("Cargo.toml"),
770            "[dependencies]\ntokio = \"1\"\nserde = { version = \"1\" }\n",
771        )
772        .expect("Cargo.toml");
773        std::fs::write(
774            root.path().join("package.json"),
775            r#"{"dependencies":{"react":"1","zod":"1"},"devDependencies":{"vitest":"1"}}"#,
776        )
777        .expect("package.json");
778        std::fs::write(
779            root.path().join("requirements.txt"),
780            "requests>=2\nrich[markdown]==1\n# comment\n",
781        )
782        .expect("requirements.txt");
783        std::fs::write(
784            root.path().join("go.mod"),
785            "module example.com/project\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.0.0\n\tgolang.org/x/crypto v0.1.0\n)\n",
786        )
787        .expect("go.mod");
788
789        assert_eq!(
790            project_dependencies(root.path()),
791            vec![
792                "tokio".to_string(),
793                "serde".to_string(),
794                "react".to_string(),
795                "zod".to_string(),
796                "vitest".to_string(),
797                "requests".to_string(),
798                "rich".to_string(),
799                "github.com/gin-gonic/gin".to_string(),
800                "golang.org/x/crypto".to_string(),
801            ]
802        );
803    }
804
805    #[test]
806    fn task_aware_filter_uses_the_call_task_over_session_profile() {
807        let output = "TASK: test\n\n## Query A\nrenew oauth token\n\n## Query B\noauth renewal flow\n\n## Session profile\nmaintenance routine\n";
808        let filtered = apply_task_aware_filter(
809            output,
810            "renew oauth token",
811            &profile("maintenance", "routine", 700),
812            true,
813        );
814
815        assert!(filtered.contains("## Query A"));
816        assert!(filtered.contains("## Query B"));
817        assert!(!filtered.contains("## Session profile"));
818    }
819
820    #[test]
821    fn task_aware_filter_downweights_stop_words() {
822        let output =
823            "TASK: test\n\n## Generic\nhow to do this\n\n## Parser\nupdate parser properly\n";
824        let filtered = apply_task_aware_filter(
825            output,
826            "how to update the parser properly",
827            &profile("maintenance", "routine", 700),
828            true,
829        );
830
831        assert!(filtered.contains("## Parser"));
832        assert!(!filtered.contains("## Generic"));
833    }
834
835    #[test]
836    fn task_aware_filter_uses_top_k_then_diverse_coverage() {
837        let output = "TASK: test\n\n## A\ncache storage retries\n\n## B\ncache storage retries duplicate\n\n## C\nmetrics\n\n## D\ncache\n";
838        let filtered = apply_task_aware_filter(
839            output,
840            "cache storage retries metrics",
841            &profile("cache", "storage retries metrics", 700),
842            true,
843        );
844
845        assert!(filtered.contains("## A"));
846        assert!(filtered.contains("## B"));
847        assert!(filtered.contains("## C"));
848        assert!(!filtered.contains("## D"));
849    }
850
851    #[test]
852    fn task_aware_filter_leaves_triage_to_the_global_pipeline() {
853        let output = format!(
854            "TASK: test\n\n## Relevant\nfix context gate\n{}// TODO: keep this\n",
855            "// boilerplate\n".repeat(40)
856        );
857        let filtered = apply_task_aware_filter(
858            &output,
859            "fix context gate",
860            &profile("bug_fix", "fix context gate filtering", 450),
861            true,
862        );
863        assert!(filtered.contains("## Relevant"));
864        assert!(filtered.contains("// TODO: keep this"));
865        assert!(filtered.contains("// boilerplate"));
866        assert!(!filtered.contains("filtered by triage"));
867    }
868
869    #[test]
870    fn finds_files_in_root_utility_directories() {
871        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
872        let root = tempfile::tempdir().expect("tempdir");
873        std::fs::create_dir_all(root.path().join("utils/nested")).expect("utils directory");
874        std::fs::create_dir_all(root.path().join("helpers")).expect("helpers directory");
875        std::fs::create_dir_all(root.path().join("common")).expect("common directory");
876        for file in [
877            "utils/nested/time.rs",
878            "helpers/format.ts",
879            "common/model.py",
880        ] {
881            std::fs::write(root.path().join(file), "").expect("utility file");
882        }
883
884        assert_eq!(
885            project_utility_files(root.path()),
886            vec![
887                "common/model.py".to_string(),
888                "helpers/format.ts".to_string(),
889                "utils/nested/time.rs".to_string(),
890            ]
891        );
892    }
893
894    #[test]
895    fn community_utility_names_obey_secret_path_role_admission() {
896        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
897        let root = tempfile::tempdir().unwrap();
898        std::fs::create_dir(root.path().join("helpers")).unwrap();
899        std::fs::write(root.path().join("helpers/id_rsa"), "private key fixture").unwrap();
900        std::fs::write(root.path().join("helpers/allowed.rs"), "fn allowed() {}\n").unwrap();
901        let mut role = lean_ctx::core::roles::load_role("coder").unwrap();
902        role.io.allow_secret_paths = false;
903        lean_ctx::core::roles::with_test_active_role(role, || {
904            assert_eq!(
905                project_utility_files(root.path()),
906                vec!["helpers/allowed.rs"]
907            );
908        });
909    }
910}