Skip to main content

lean_ctx/tools/
ctx_intent.rs

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