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}