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}