Skip to main content

lean_ctx/tools/
ctx_intent.rs

1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::intent_engine;
5use crate::core::tokens::count_tokens;
6use crate::tools::CrpMode;
7
8#[derive(Debug)]
9enum Intent {
10    FixBug { area: String },
11    AddFeature { area: String },
12    Refactor { area: String },
13    Understand { area: String },
14    Test { area: String },
15    Config,
16    Deploy,
17    Unknown,
18}
19
20pub fn handle(
21    cache: &mut SessionCache,
22    query: &str,
23    project_root: &str,
24    crp_mode: CrpMode,
25) -> String {
26    let multi_intents = intent_engine::detect_multi_intent(query);
27    let primary = &multi_intents[0];
28    let briefing_header = intent_engine::format_briefing_header(primary);
29    let complexity = intent_engine::classify_complexity(query, primary);
30
31    let intent = classify_intent(query);
32    let strategy = build_strategy(&intent, project_root);
33
34    let file_context: Vec<(String, usize)> = strategy
35        .iter()
36        .filter(|(p, _)| Path::new(p).exists())
37        .filter_map(|(p, _)| {
38            std::fs::read_to_string(p)
39                .ok()
40                .map(|c| (p.clone(), c.lines().count()))
41        })
42        .collect();
43    let briefing = crate::core::task_briefing::build_briefing(query, &file_context);
44    let briefing_block = crate::core::task_briefing::format_briefing(&briefing);
45
46    let mut result = Vec::new();
47    result.push(briefing_block);
48    result.push(briefing_header);
49    result.push(format!(
50        "Complexity: {} | {}",
51        complexity.instruction_suffix().lines().next().unwrap_or(""),
52        if multi_intents.len() > 1 {
53            format!("{} sub-intents detected", multi_intents.len())
54        } else {
55            "single intent".to_string()
56        }
57    ));
58    result.push(format!(
59        "Strategy: {} files, modes: {}",
60        strategy.len(),
61        strategy
62            .iter()
63            .map(|(_, m)| m.as_str())
64            .collect::<Vec<_>>()
65            .join(", ")
66    ));
67
68    if multi_intents.len() > 1 {
69        result.push("Sub-intents:".to_string());
70        for (i, sub) in multi_intents.iter().enumerate() {
71            result.push(format!(
72                "  {}. {} ({:.0}%)",
73                i + 1,
74                sub.task_type.as_str(),
75                sub.confidence * 100.0
76            ));
77        }
78    }
79
80    result.push(String::new());
81
82    for (path, mode) in &strategy {
83        if !Path::new(path).exists() {
84            continue;
85        }
86        let file_result = crate::tools::ctx_read::handle(cache, path, mode, crp_mode);
87        result.push(file_result);
88        result.push("---".to_string());
89    }
90
91    let output = result.join("\n");
92    let tokens = count_tokens(&output);
93    format!(
94        "{output}\n\n[ctx_intent: {tokens} tok | complexity: {}]",
95        complexity.instruction_suffix().lines().next().unwrap_or("")
96    )
97}
98
99fn classify_intent(query: &str) -> Intent {
100    let q = query.to_lowercase();
101
102    let area = extract_area(&q);
103
104    if q.contains("fix")
105        || q.contains("bug")
106        || q.contains("error")
107        || q.contains("broken")
108        || q.contains("crash")
109        || q.contains("fail")
110    {
111        return Intent::FixBug { area };
112    }
113    if q.contains("add")
114        || q.contains("create")
115        || q.contains("implement")
116        || q.contains("new")
117        || q.contains("feature")
118    {
119        return Intent::AddFeature { area };
120    }
121    if q.contains("refactor")
122        || q.contains("clean")
123        || q.contains("restructure")
124        || q.contains("rename")
125        || q.contains("move")
126    {
127        return Intent::Refactor { area };
128    }
129    if q.contains("understand")
130        || q.contains("how")
131        || q.contains("what")
132        || q.contains("explain")
133        || q.contains("where")
134    {
135        return Intent::Understand { area };
136    }
137    if q.contains("test") || q.contains("spec") || q.contains("coverage") {
138        return Intent::Test { area };
139    }
140    if q.contains("config") || q.contains("setup") || q.contains("env") || q.contains("install") {
141        return Intent::Config;
142    }
143    if q.contains("deploy") || q.contains("release") || q.contains("publish") || q.contains("ship")
144    {
145        return Intent::Deploy;
146    }
147
148    Intent::Unknown
149}
150
151fn extract_area(query: &str) -> String {
152    let keywords: Vec<&str> = query
153        .split_whitespace()
154        .filter(|w| {
155            w.len() > 3
156                && !matches!(
157                    *w,
158                    "the"
159                        | "this"
160                        | "that"
161                        | "with"
162                        | "from"
163                        | "into"
164                        | "have"
165                        | "please"
166                        | "could"
167                        | "would"
168                        | "should"
169                )
170        })
171        .collect();
172
173    let file_refs: Vec<&&str> = keywords
174        .iter()
175        .filter(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
176        .collect();
177
178    if let Some(path) = file_refs.first() {
179        return path.to_string();
180    }
181
182    let code_terms: Vec<&&str> = keywords
183        .iter()
184        .filter(|w| {
185            w.chars().any(|c| c.is_uppercase())
186                || w.contains('_')
187                || matches!(
188                    **w,
189                    "auth"
190                        | "login"
191                        | "api"
192                        | "database"
193                        | "db"
194                        | "server"
195                        | "client"
196                        | "user"
197                        | "admin"
198                        | "router"
199                        | "handler"
200                        | "middleware"
201                        | "controller"
202                        | "model"
203                        | "view"
204                        | "component"
205                        | "service"
206                        | "repository"
207                        | "cache"
208                        | "queue"
209                        | "worker"
210                )
211        })
212        .collect();
213
214    if let Some(term) = code_terms.first() {
215        return term.to_string();
216    }
217
218    keywords.last().unwrap_or(&"").to_string()
219}
220
221pub fn rank_by_heat(files: &mut [(String, String)], root: &str) {
222    let index = crate::core::graph_index::load_or_build(root);
223    if index.files.is_empty() {
224        return;
225    }
226
227    let mut connection_counts: std::collections::HashMap<String, usize> =
228        std::collections::HashMap::new();
229    for edge in &index.edges {
230        *connection_counts.entry(edge.from.clone()).or_default() += 1;
231        *connection_counts.entry(edge.to.clone()).or_default() += 1;
232    }
233
234    let max_tokens = index
235        .files
236        .values()
237        .map(|f| f.token_count)
238        .max()
239        .unwrap_or(1) as f64;
240    let max_conn = connection_counts.values().max().copied().unwrap_or(1) as f64;
241
242    files.sort_by(|a, b| {
243        let heat_a = heat_score_for(&a.0, root, &index, &connection_counts, max_tokens, max_conn);
244        let heat_b = heat_score_for(&b.0, root, &index, &connection_counts, max_tokens, max_conn);
245        heat_b
246            .partial_cmp(&heat_a)
247            .unwrap_or(std::cmp::Ordering::Equal)
248    });
249}
250
251fn heat_score_for(
252    path: &str,
253    root: &str,
254    index: &crate::core::graph_index::ProjectIndex,
255    connections: &std::collections::HashMap<String, usize>,
256    max_tokens: f64,
257    max_conn: f64,
258) -> f64 {
259    let rel = path
260        .strip_prefix(root)
261        .unwrap_or(path)
262        .trim_start_matches('/');
263
264    if let Some(entry) = index.files.get(rel) {
265        let conn = connections.get(rel).copied().unwrap_or(0);
266        let token_norm = entry.token_count as f64 / max_tokens;
267        let conn_norm = conn as f64 / max_conn;
268        token_norm * 0.4 + conn_norm * 0.6
269    } else {
270        0.0
271    }
272}
273
274fn build_strategy(intent: &Intent, root: &str) -> Vec<(String, String)> {
275    let mut files = Vec::new();
276    let loaded = crate::core::graph_index::load_or_build(root);
277    let graph = if loaded.files.is_empty() {
278        None
279    } else {
280        Some(loaded)
281    };
282
283    match intent {
284        Intent::FixBug { area } => {
285            if let Some(paths) = find_files_for_area(area, root) {
286                for path in paths.iter().take(3) {
287                    files.push((path.clone(), "full".to_string()));
288                }
289                if let Some(ref idx) = graph {
290                    enrich_with_graph(&mut files, &paths, idx, root, "map", 5);
291                }
292                for path in paths.iter().skip(3).take(5) {
293                    if !files.iter().any(|(f, _)| f == path) {
294                        files.push((path.clone(), "map".to_string()));
295                    }
296                }
297            }
298            if let Some(test_files) = find_test_files(area, root) {
299                for path in test_files.iter().take(2) {
300                    if !files.iter().any(|(f, _)| f == path) {
301                        files.push((path.clone(), "signatures".to_string()));
302                    }
303                }
304            }
305        }
306        Intent::AddFeature { area } => {
307            if let Some(paths) = find_files_for_area(area, root) {
308                for path in paths.iter().take(2) {
309                    files.push((path.clone(), "signatures".to_string()));
310                }
311                if let Some(ref idx) = graph {
312                    enrich_with_graph(&mut files, &paths, idx, root, "map", 5);
313                }
314                for path in paths.iter().skip(2).take(5) {
315                    if !files.iter().any(|(f, _)| f == path) {
316                        files.push((path.clone(), "map".to_string()));
317                    }
318                }
319            }
320        }
321        Intent::Refactor { area } => {
322            if let Some(paths) = find_files_for_area(area, root) {
323                for path in paths.iter().take(5) {
324                    files.push((path.clone(), "full".to_string()));
325                }
326                if let Some(ref idx) = graph {
327                    enrich_with_graph(&mut files, &paths, idx, root, "full", 5);
328                }
329            }
330        }
331        Intent::Understand { area } => {
332            if let Some(paths) = find_files_for_area(area, root) {
333                for path in &paths {
334                    files.push((path.clone(), "map".to_string()));
335                }
336                if let Some(ref idx) = graph {
337                    enrich_with_graph(&mut files, &paths, idx, root, "map", 8);
338                }
339            }
340        }
341        Intent::Test { area } => {
342            if let Some(test_files) = find_test_files(area, root) {
343                for path in test_files.iter().take(3) {
344                    files.push((path.clone(), "full".to_string()));
345                }
346            }
347            if let Some(src_files) = find_files_for_area(area, root) {
348                for path in src_files.iter().take(3) {
349                    if !files.iter().any(|(f, _)| f == path) {
350                        files.push((path.clone(), "signatures".to_string()));
351                    }
352                }
353            }
354        }
355        Intent::Config => {
356            for name in &[
357                "Cargo.toml",
358                "package.json",
359                "pyproject.toml",
360                "go.mod",
361                "tsconfig.json",
362                "docker-compose.yml",
363            ] {
364                let path = format!("{root}/{name}");
365                if Path::new(&path).exists() {
366                    files.push((path, "full".to_string()));
367                }
368            }
369        }
370        Intent::Deploy => {
371            for name in &[
372                "Dockerfile",
373                "docker-compose.yml",
374                "Makefile",
375                ".github/workflows",
376            ] {
377                let path = format!("{root}/{name}");
378                if Path::new(&path).exists() {
379                    files.push((path, "full".to_string()));
380                }
381            }
382        }
383        Intent::Unknown => {}
384    }
385
386    rank_by_heat(&mut files, root);
387    files
388}
389
390fn enrich_with_graph(
391    files: &mut Vec<(String, String)>,
392    seed_paths: &[String],
393    index: &crate::core::graph_index::ProjectIndex,
394    root: &str,
395    mode: &str,
396    max: usize,
397) {
398    let mut added = 0;
399    for seed in seed_paths {
400        let rel = seed
401            .strip_prefix(root)
402            .unwrap_or(seed)
403            .trim_start_matches('/');
404
405        for related in index.get_related(rel, 2) {
406            if added >= max {
407                return;
408            }
409            let abs = format!("{root}/{related}");
410            if !files.iter().any(|(f, _)| *f == abs || *f == related) && Path::new(&abs).exists() {
411                files.push((abs, mode.to_string()));
412                added += 1;
413            }
414        }
415    }
416}
417
418fn find_files_for_area(area: &str, root: &str) -> Option<Vec<String>> {
419    let mut matches = Vec::new();
420    let search_term = area.to_lowercase();
421
422    ignore::WalkBuilder::new(root)
423        .hidden(true)
424        .git_ignore(true)
425        .git_global(true)
426        .git_exclude(true)
427        .max_depth(Some(6))
428        .build()
429        .filter_map(|e| e.ok())
430        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
431        .filter(|e| {
432            let name = e.file_name().to_string_lossy().to_lowercase();
433            name.contains(&search_term)
434                || e.path()
435                    .to_string_lossy()
436                    .to_lowercase()
437                    .contains(&search_term)
438        })
439        .take(10)
440        .for_each(|e| {
441            let path = e.path().to_string_lossy().to_string();
442            if !matches.contains(&path) {
443                matches.push(path);
444            }
445        });
446
447    if matches.is_empty() {
448        None
449    } else {
450        Some(matches)
451    }
452}
453
454fn find_test_files(area: &str, root: &str) -> Option<Vec<String>> {
455    let search_term = area.to_lowercase();
456    let mut matches = Vec::new();
457
458    ignore::WalkBuilder::new(root)
459        .hidden(true)
460        .git_ignore(true)
461        .git_global(true)
462        .git_exclude(true)
463        .max_depth(Some(6))
464        .build()
465        .filter_map(|e| e.ok())
466        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
467        .filter(|e| {
468            let name = e.file_name().to_string_lossy().to_lowercase();
469            (name.contains("test") || name.contains("spec"))
470                && (name.contains(&search_term)
471                    || e.path()
472                        .to_string_lossy()
473                        .to_lowercase()
474                        .contains(&search_term))
475        })
476        .take(5)
477        .for_each(|e| {
478            matches.push(e.path().to_string_lossy().to_string());
479        });
480
481    if matches.is_empty() {
482        None
483    } else {
484        Some(matches)
485    }
486}