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 classification = intent_engine::classify(query);
27    let briefing_header = intent_engine::format_briefing_header(&classification);
28
29    let intent = classify_intent(query);
30    let strategy = build_strategy(&intent, project_root);
31
32    let file_context: Vec<(String, usize)> = strategy
33        .iter()
34        .filter(|(p, _)| Path::new(p).exists())
35        .filter_map(|(p, _)| {
36            std::fs::read_to_string(p)
37                .ok()
38                .map(|c| (p.clone(), c.lines().count()))
39        })
40        .collect();
41    let briefing = crate::core::task_briefing::build_briefing(query, &file_context);
42    let briefing_block = crate::core::task_briefing::format_briefing(&briefing);
43
44    let mut result = Vec::new();
45    result.push(briefing_block);
46    result.push(briefing_header);
47    result.push(format!(
48        "Strategy: {} files, modes: {}",
49        strategy.len(),
50        strategy
51            .iter()
52            .map(|(_, m)| m.as_str())
53            .collect::<Vec<_>>()
54            .join(", ")
55    ));
56    result.push(String::new());
57
58    for (path, mode) in &strategy {
59        if !Path::new(path).exists() {
60            continue;
61        }
62        let file_result = crate::tools::ctx_read::handle(cache, path, mode, crp_mode);
63        result.push(file_result);
64        result.push("---".to_string());
65    }
66
67    let output = result.join("\n");
68    let tokens = count_tokens(&output);
69    format!("{output}\n\n[ctx_intent: {tokens} tok]")
70}
71
72fn classify_intent(query: &str) -> Intent {
73    let q = query.to_lowercase();
74
75    let area = extract_area(&q);
76
77    if q.contains("fix")
78        || q.contains("bug")
79        || q.contains("error")
80        || q.contains("broken")
81        || q.contains("crash")
82        || q.contains("fail")
83    {
84        return Intent::FixBug { area };
85    }
86    if q.contains("add")
87        || q.contains("create")
88        || q.contains("implement")
89        || q.contains("new")
90        || q.contains("feature")
91    {
92        return Intent::AddFeature { area };
93    }
94    if q.contains("refactor")
95        || q.contains("clean")
96        || q.contains("restructure")
97        || q.contains("rename")
98        || q.contains("move")
99    {
100        return Intent::Refactor { area };
101    }
102    if q.contains("understand")
103        || q.contains("how")
104        || q.contains("what")
105        || q.contains("explain")
106        || q.contains("where")
107    {
108        return Intent::Understand { area };
109    }
110    if q.contains("test") || q.contains("spec") || q.contains("coverage") {
111        return Intent::Test { area };
112    }
113    if q.contains("config") || q.contains("setup") || q.contains("env") || q.contains("install") {
114        return Intent::Config;
115    }
116    if q.contains("deploy") || q.contains("release") || q.contains("publish") || q.contains("ship")
117    {
118        return Intent::Deploy;
119    }
120
121    Intent::Unknown
122}
123
124fn extract_area(query: &str) -> String {
125    let keywords: Vec<&str> = query
126        .split_whitespace()
127        .filter(|w| {
128            w.len() > 3
129                && !matches!(
130                    *w,
131                    "the"
132                        | "this"
133                        | "that"
134                        | "with"
135                        | "from"
136                        | "into"
137                        | "have"
138                        | "please"
139                        | "could"
140                        | "would"
141                        | "should"
142                )
143        })
144        .collect();
145
146    let file_refs: Vec<&&str> = keywords
147        .iter()
148        .filter(|w| w.contains('.') || w.contains('/') || w.contains('\\'))
149        .collect();
150
151    if let Some(path) = file_refs.first() {
152        return path.to_string();
153    }
154
155    let code_terms: Vec<&&str> = keywords
156        .iter()
157        .filter(|w| {
158            w.chars().any(|c| c.is_uppercase())
159                || w.contains('_')
160                || matches!(
161                    **w,
162                    "auth"
163                        | "login"
164                        | "api"
165                        | "database"
166                        | "db"
167                        | "server"
168                        | "client"
169                        | "user"
170                        | "admin"
171                        | "router"
172                        | "handler"
173                        | "middleware"
174                        | "controller"
175                        | "model"
176                        | "view"
177                        | "component"
178                        | "service"
179                        | "repository"
180                        | "cache"
181                        | "queue"
182                        | "worker"
183                )
184        })
185        .collect();
186
187    if let Some(term) = code_terms.first() {
188        return term.to_string();
189    }
190
191    keywords.last().unwrap_or(&"").to_string()
192}
193
194fn build_strategy(intent: &Intent, root: &str) -> Vec<(String, String)> {
195    let mut files = Vec::new();
196    let loaded = crate::core::graph_index::load_or_build(root);
197    let graph = if loaded.files.is_empty() {
198        None
199    } else {
200        Some(loaded)
201    };
202
203    match intent {
204        Intent::FixBug { area } => {
205            if let Some(paths) = find_files_for_area(area, root) {
206                for path in paths.iter().take(3) {
207                    files.push((path.clone(), "full".to_string()));
208                }
209                if let Some(ref idx) = graph {
210                    enrich_with_graph(&mut files, &paths, idx, root, "map", 5);
211                }
212                for path in paths.iter().skip(3).take(5) {
213                    if !files.iter().any(|(f, _)| f == path) {
214                        files.push((path.clone(), "map".to_string()));
215                    }
216                }
217            }
218            if let Some(test_files) = find_test_files(area, root) {
219                for path in test_files.iter().take(2) {
220                    if !files.iter().any(|(f, _)| f == path) {
221                        files.push((path.clone(), "signatures".to_string()));
222                    }
223                }
224            }
225        }
226        Intent::AddFeature { area } => {
227            if let Some(paths) = find_files_for_area(area, root) {
228                for path in paths.iter().take(2) {
229                    files.push((path.clone(), "signatures".to_string()));
230                }
231                if let Some(ref idx) = graph {
232                    enrich_with_graph(&mut files, &paths, idx, root, "map", 5);
233                }
234                for path in paths.iter().skip(2).take(5) {
235                    if !files.iter().any(|(f, _)| f == path) {
236                        files.push((path.clone(), "map".to_string()));
237                    }
238                }
239            }
240        }
241        Intent::Refactor { area } => {
242            if let Some(paths) = find_files_for_area(area, root) {
243                for path in paths.iter().take(5) {
244                    files.push((path.clone(), "full".to_string()));
245                }
246                if let Some(ref idx) = graph {
247                    enrich_with_graph(&mut files, &paths, idx, root, "full", 5);
248                }
249            }
250        }
251        Intent::Understand { area } => {
252            if let Some(paths) = find_files_for_area(area, root) {
253                for path in &paths {
254                    files.push((path.clone(), "map".to_string()));
255                }
256                if let Some(ref idx) = graph {
257                    enrich_with_graph(&mut files, &paths, idx, root, "map", 8);
258                }
259            }
260        }
261        Intent::Test { area } => {
262            if let Some(test_files) = find_test_files(area, root) {
263                for path in test_files.iter().take(3) {
264                    files.push((path.clone(), "full".to_string()));
265                }
266            }
267            if let Some(src_files) = find_files_for_area(area, root) {
268                for path in src_files.iter().take(3) {
269                    if !files.iter().any(|(f, _)| f == path) {
270                        files.push((path.clone(), "signatures".to_string()));
271                    }
272                }
273            }
274        }
275        Intent::Config => {
276            for name in &[
277                "Cargo.toml",
278                "package.json",
279                "pyproject.toml",
280                "go.mod",
281                "tsconfig.json",
282                "docker-compose.yml",
283            ] {
284                let path = format!("{root}/{name}");
285                if Path::new(&path).exists() {
286                    files.push((path, "full".to_string()));
287                }
288            }
289        }
290        Intent::Deploy => {
291            for name in &[
292                "Dockerfile",
293                "docker-compose.yml",
294                "Makefile",
295                ".github/workflows",
296            ] {
297                let path = format!("{root}/{name}");
298                if Path::new(&path).exists() {
299                    files.push((path, "full".to_string()));
300                }
301            }
302        }
303        Intent::Unknown => {}
304    }
305
306    files
307}
308
309fn enrich_with_graph(
310    files: &mut Vec<(String, String)>,
311    seed_paths: &[String],
312    index: &crate::core::graph_index::ProjectIndex,
313    root: &str,
314    mode: &str,
315    max: usize,
316) {
317    let mut added = 0;
318    for seed in seed_paths {
319        let rel = seed
320            .strip_prefix(root)
321            .unwrap_or(seed)
322            .trim_start_matches('/');
323
324        for related in index.get_related(rel, 2) {
325            if added >= max {
326                return;
327            }
328            let abs = format!("{root}/{related}");
329            if !files.iter().any(|(f, _)| *f == abs || *f == related) && Path::new(&abs).exists() {
330                files.push((abs, mode.to_string()));
331                added += 1;
332            }
333        }
334    }
335}
336
337fn find_files_for_area(area: &str, root: &str) -> Option<Vec<String>> {
338    let mut matches = Vec::new();
339    let search_term = area.to_lowercase();
340
341    ignore::WalkBuilder::new(root)
342        .hidden(true)
343        .git_ignore(true)
344        .git_global(true)
345        .git_exclude(true)
346        .max_depth(Some(6))
347        .build()
348        .filter_map(|e| e.ok())
349        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
350        .filter(|e| {
351            let name = e.file_name().to_string_lossy().to_lowercase();
352            name.contains(&search_term)
353                || e.path()
354                    .to_string_lossy()
355                    .to_lowercase()
356                    .contains(&search_term)
357        })
358        .take(10)
359        .for_each(|e| {
360            let path = e.path().to_string_lossy().to_string();
361            if !matches.contains(&path) {
362                matches.push(path);
363            }
364        });
365
366    if matches.is_empty() {
367        None
368    } else {
369        Some(matches)
370    }
371}
372
373fn find_test_files(area: &str, root: &str) -> Option<Vec<String>> {
374    let search_term = area.to_lowercase();
375    let mut matches = Vec::new();
376
377    ignore::WalkBuilder::new(root)
378        .hidden(true)
379        .git_ignore(true)
380        .git_global(true)
381        .git_exclude(true)
382        .max_depth(Some(6))
383        .build()
384        .filter_map(|e| e.ok())
385        .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
386        .filter(|e| {
387            let name = e.file_name().to_string_lossy().to_lowercase();
388            (name.contains("test") || name.contains("spec"))
389                && (name.contains(&search_term)
390                    || e.path()
391                        .to_string_lossy()
392                        .to_lowercase()
393                        .contains(&search_term))
394        })
395        .take(5)
396        .for_each(|e| {
397            matches.push(e.path().to_string_lossy().to_string());
398        });
399
400    if matches.is_empty() {
401        None
402    } else {
403        Some(matches)
404    }
405}