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}