1use super::intent_lang;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4pub enum TaskType {
5 Generate,
6 FixBug,
7 Refactor,
8 Explore,
9 Test,
10 Debug,
11 Config,
12 Deploy,
13 Review,
14}
15
16impl TaskType {
17 pub fn as_str(&self) -> &'static str {
18 match self {
19 Self::Generate => "generate",
20 Self::FixBug => "fix_bug",
21 Self::Refactor => "refactor",
22 Self::Explore => "explore",
23 Self::Test => "test",
24 Self::Debug => "debug",
25 Self::Config => "config",
26 Self::Deploy => "deploy",
27 Self::Review => "review",
28 }
29 }
30
31 pub fn all() -> &'static [TaskType] {
34 &[
35 Self::Generate,
36 Self::FixBug,
37 Self::Refactor,
38 Self::Explore,
39 Self::Test,
40 Self::Debug,
41 Self::Config,
42 Self::Deploy,
43 Self::Review,
44 ]
45 }
46
47 pub fn thinking_budget(&self) -> ThinkingBudget {
48 match self {
49 Self::Generate | Self::FixBug | Self::Test | Self::Config | Self::Deploy => {
50 ThinkingBudget::Minimal
51 }
52 Self::Refactor | Self::Explore | Self::Debug | Self::Review => ThinkingBudget::Medium,
53 }
54 }
55
56 pub fn output_format(&self) -> OutputFormat {
57 match self {
58 Self::Generate | Self::Test | Self::Config => OutputFormat::CodeOnly,
59 Self::FixBug | Self::Refactor => OutputFormat::DiffOnly,
60 Self::Explore | Self::Review => OutputFormat::ExplainConcise,
61 Self::Debug => OutputFormat::Trace,
62 Self::Deploy => OutputFormat::StepList,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ThinkingBudget {
69 Minimal,
70 Medium,
71 Trace,
72 Deep,
73}
74
75impl ThinkingBudget {
76 pub fn instruction(&self) -> &'static str {
77 match self {
78 Self::Minimal => "THINKING: Skip analysis. The task is clear — generate code directly.",
79 Self::Medium => {
80 "THINKING: 2-3 step analysis max. Identify what to change, then act. Do not over-analyze."
81 }
82 Self::Trace => {
83 "THINKING: Short trace only. Identify root cause in 3 steps max, then generate fix."
84 }
85 Self::Deep => {
86 "THINKING: Analyze structure and dependencies. Summarize findings concisely."
87 }
88 }
89 }
90
91 pub fn suppresses_thinking(&self) -> bool {
92 matches!(self, Self::Minimal)
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum OutputFormat {
98 CodeOnly,
99 DiffOnly,
100 ExplainConcise,
101 Trace,
102 StepList,
103}
104
105impl OutputFormat {
106 pub fn instruction(&self) -> &'static str {
107 match self {
108 Self::CodeOnly => {
109 "OUTPUT-HINT: Prefer code blocks. Minimize prose unless user asks for explanation."
110 }
111 Self::DiffOnly => "OUTPUT-HINT: Prefer showing only changed lines as +/- diffs.",
112 Self::ExplainConcise => "OUTPUT-HINT: Brief summary, then code/data if relevant.",
113 Self::Trace => "OUTPUT-HINT: Show cause→effect chain with code references.",
114 Self::StepList => "OUTPUT-HINT: Numbered action list, one step at a time.",
115 }
116 }
117}
118
119#[derive(Debug)]
120pub struct TaskClassification {
121 pub task_type: TaskType,
122 pub confidence: f64,
123 pub targets: Vec<String>,
124 pub keywords: Vec<String>,
125}
126
127const PHRASE_RULES: &[(&[&str], TaskType, f64)] = &[
128 (
129 &[
130 "add",
131 "create",
132 "implement",
133 "build",
134 "write",
135 "generate",
136 "make",
137 "new feature",
138 "new",
139 ],
140 TaskType::Generate,
141 0.9,
142 ),
143 (
144 &[
145 "fix",
146 "bug",
147 "broken",
148 "crash",
149 "error in",
150 "not working",
151 "fails",
152 "wrong output",
153 ],
154 TaskType::FixBug,
155 0.95,
156 ),
157 (
158 &[
159 "refactor",
160 "clean up",
161 "restructure",
162 "rename",
163 "move",
164 "extract",
165 "simplify",
166 "split",
167 ],
168 TaskType::Refactor,
169 0.9,
170 ),
171 (
172 &[
173 "how",
174 "what",
175 "where",
176 "explain",
177 "understand",
178 "show me",
179 "describe",
180 "why does",
181 ],
182 TaskType::Explore,
183 0.85,
184 ),
185 (
186 &[
187 "test",
188 "spec",
189 "coverage",
190 "assert",
191 "unit test",
192 "integration test",
193 "mock",
194 ],
195 TaskType::Test,
196 0.9,
197 ),
198 (
199 &[
200 "debug",
201 "trace",
202 "inspect",
203 "log",
204 "breakpoint",
205 "step through",
206 "stack trace",
207 ],
208 TaskType::Debug,
209 0.9,
210 ),
211 (
212 &[
213 "config",
214 "setup",
215 "install",
216 "env",
217 "configure",
218 "settings",
219 "dotenv",
220 ],
221 TaskType::Config,
222 0.85,
223 ),
224 (
225 &[
226 "deploy", "release", "publish", "ship", "ci/cd", "pipeline", "docker",
227 ],
228 TaskType::Deploy,
229 0.85,
230 ),
231 (
232 &[
233 "review",
234 "check",
235 "audit",
236 "look at",
237 "evaluate",
238 "assess",
239 "pr review",
240 ],
241 TaskType::Review,
242 0.8,
243 ),
244];
245
246pub fn classify(query: &str) -> TaskClassification {
247 let q = query.to_lowercase();
248 let words: Vec<&str> = q.split_whitespace().collect();
249 let lang = intent_lang::detect_query_lang(&words);
250
251 let mut best_type = TaskType::Explore;
252 let mut best_score = 0.0_f64;
253
254 apply_rules(
255 &q,
256 &words,
257 PHRASE_RULES,
258 |word, phrase| word == phrase,
259 &mut best_type,
260 &mut best_score,
261 );
262 apply_rules(
264 &q,
265 &words,
266 intent_lang::STEM_RULES,
267 |word, stem| word.starts_with(stem),
268 &mut best_type,
269 &mut best_score,
270 );
271
272 let targets = extract_targets(query, lang);
273 let keywords = extract_keywords(&q, lang);
274
275 if best_score < 0.1 {
276 best_type = TaskType::Explore;
277 best_score = 0.3;
278 }
279
280 TaskClassification {
281 task_type: best_type,
282 confidence: best_score,
283 targets,
284 keywords,
285 }
286}
287
288fn apply_rules(
291 q: &str,
292 words: &[&str],
293 rules: &[(&[&str], TaskType, f64)],
294 token_match: fn(&str, &str) -> bool,
295 best_type: &mut TaskType,
296 best_score: &mut f64,
297) {
298 for &(phrases, task_type, base_confidence) in rules {
299 let mut match_count = 0usize;
300 for phrase in phrases {
301 if phrase.contains(' ') {
302 if q.contains(phrase) {
303 match_count += 2;
304 }
305 } else if words.iter().any(|w| token_match(w, phrase)) {
306 match_count += 1;
307 }
308 }
309 if match_count > 0 {
310 let score = base_confidence * (match_count as f64).min(2.0) / 2.0;
311 if score > *best_score {
312 *best_score = score;
313 *best_type = task_type;
314 }
315 }
316 }
317}
318
319fn extract_targets(query: &str, lang: intent_lang::QueryLang) -> Vec<String> {
320 let mut targets = Vec::new();
321
322 for word in query.split_whitespace() {
323 if word.contains('.') && !word.starts_with('.') {
324 let clean = word.trim_matches(|c: char| {
325 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
326 });
327 if looks_like_path(clean) {
328 targets.push(clean.to_string());
329 }
330 }
331 if word.contains('/') && !word.starts_with("//") && !word.starts_with("http") {
332 let clean = word.trim_matches(|c: char| {
333 !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
334 });
335 if clean.len() > 2 {
336 targets.push(clean.to_string());
337 }
338 }
339 }
340
341 for word in query.split_whitespace() {
342 let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
343 if w.contains('_') && w.len() > 3 && !targets.contains(&w.to_string()) {
344 targets.push(w.to_string());
345 }
346 if w.chars().any(char::is_uppercase)
347 && w.len() > 2
348 && !is_stop_word(w)
349 && !intent_lang::is_stop_word_for(lang, &w.to_lowercase())
350 && !targets.contains(&w.to_string())
351 {
352 targets.push(w.to_string());
353 }
354 }
355
356 targets.truncate(5);
357 targets
358}
359
360fn looks_like_path(s: &str) -> bool {
361 let exts = [
362 ".rs", ".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".toml", ".yaml", ".yml", ".json", ".md",
363 ];
364 exts.iter().any(|ext| s.ends_with(ext)) || s.contains('/')
365}
366
367fn is_stop_word(w: &str) -> bool {
368 matches!(
369 w.to_lowercase().as_str(),
370 "the"
371 | "this"
372 | "that"
373 | "with"
374 | "from"
375 | "into"
376 | "have"
377 | "please"
378 | "could"
379 | "would"
380 | "should"
381 | "also"
382 | "just"
383 | "then"
384 | "when"
385 | "what"
386 | "where"
387 | "which"
388 | "there"
389 | "here"
390 | "these"
391 | "those"
392 | "does"
393 | "will"
394 | "shall"
395 | "can"
396 | "may"
397 | "must"
398 | "need"
399 | "want"
400 | "like"
401 | "make"
402 | "take"
403 )
404}
405
406fn extract_keywords(query: &str, lang: intent_lang::QueryLang) -> Vec<String> {
407 query
408 .split_whitespace()
409 .filter(|w| w.len() > 3)
410 .filter(|w| !is_stop_word(w))
411 .map(|w| {
412 w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
413 .to_lowercase()
414 })
415 .filter(|w| !w.is_empty())
416 .filter(|w| !intent_lang::is_stop_word_for(lang, w))
417 .take(8)
418 .collect()
419}
420
421pub fn classify_complexity(
422 query: &str,
423 classification: &TaskClassification,
424) -> super::adaptive::TaskComplexity {
425 use super::adaptive::TaskComplexity;
426
427 let q = query.to_lowercase();
428 let word_count = q.split_whitespace().count();
429 let target_count = classification.targets.len();
430
431 let has_multi_file = target_count >= 3;
432 let has_cross_cutting = q.contains("all files")
433 || q.contains("across")
434 || q.contains("everywhere")
435 || q.contains("every")
436 || q.contains("migration")
437 || q.contains("architecture");
438
439 let is_simple = word_count < 8
440 && target_count <= 1
441 && matches!(
442 classification.task_type,
443 TaskType::Generate | TaskType::Config
444 );
445
446 if is_simple {
447 TaskComplexity::Mechanical
448 } else if has_multi_file || has_cross_cutting {
449 TaskComplexity::Architectural
450 } else {
451 TaskComplexity::Standard
452 }
453}
454
455pub fn detect_multi_intent(query: &str) -> Vec<TaskClassification> {
456 let delimiters = [" and then ", " then ", " also ", " + ", ". "];
457
458 let mut parts: Vec<&str> = vec![query];
459 for delim in &delimiters {
460 let mut new_parts = Vec::new();
461 for part in &parts {
462 for sub in part.split(delim) {
463 let trimmed = sub.trim();
464 if !trimmed.is_empty() {
465 new_parts.push(trimmed);
466 }
467 }
468 }
469 parts = new_parts;
470 }
471
472 if parts.len() <= 1 {
473 return vec![classify(query)];
474 }
475
476 parts.iter().map(|part| classify(part)).collect()
477}
478
479pub fn format_briefing_header(classification: &TaskClassification) -> String {
480 format!(
481 "[TASK:{} CONF:{:.0}% TARGETS:{} KW:{}]",
482 classification.task_type.as_str(),
483 classification.confidence * 100.0,
484 if classification.targets.is_empty() {
485 "-".to_string()
486 } else {
487 classification.targets.join(",")
488 },
489 classification.keywords.join(","),
490 )
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
494pub enum IntentScope {
495 SingleFile,
496 MultiFile,
497 CrossModule,
498 ProjectWide,
499}
500
501#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
502pub struct StructuredIntent {
503 pub task_type: TaskType,
504 pub confidence: f64,
505 pub targets: Vec<String>,
506 pub keywords: Vec<String>,
507 pub scope: IntentScope,
508 pub language_hint: Option<String>,
509 pub urgency: f64,
510 pub action_verb: Option<String>,
511}
512
513impl StructuredIntent {
514 pub fn from_query(query: &str) -> Self {
515 let classification = classify(query);
516 let complexity = classify_complexity(query, &classification);
517 let file_targets = classification
518 .targets
519 .iter()
520 .filter(|t| t.contains('.') || t.contains('/'))
521 .count();
522 let scope = match complexity {
523 super::adaptive::TaskComplexity::Mechanical => IntentScope::SingleFile,
524 super::adaptive::TaskComplexity::Standard => {
525 if file_targets > 1 {
526 IntentScope::MultiFile
527 } else {
528 IntentScope::SingleFile
529 }
530 }
531 super::adaptive::TaskComplexity::Architectural => {
532 let q = query.to_lowercase();
533 if q.contains("all files") || q.contains("everywhere") || q.contains("migration") {
534 IntentScope::ProjectWide
535 } else {
536 IntentScope::CrossModule
537 }
538 }
539 };
540
541 let language_hint = detect_language_hint(query, &classification.targets);
542 let urgency = detect_urgency(query);
543 let action_verb = extract_action_verb(query);
544
545 StructuredIntent {
546 task_type: classification.task_type,
547 confidence: classification.confidence,
548 targets: classification.targets,
549 keywords: classification.keywords,
550 scope,
551 language_hint,
552 urgency,
553 action_verb,
554 }
555 }
556
557 pub fn from_file_patterns(touched_files: &[String]) -> Self {
558 if touched_files.is_empty() {
559 return Self {
560 task_type: TaskType::Explore,
561 confidence: 0.3,
562 targets: Vec::new(),
563 keywords: Vec::new(),
564 scope: IntentScope::SingleFile,
565 language_hint: None,
566 urgency: 0.0,
567 action_verb: None,
568 };
569 }
570
571 let has_tests = touched_files
572 .iter()
573 .any(|f| f.contains("test") || f.contains("spec"));
574 let has_config = touched_files.iter().any(|f| {
575 let p = std::path::Path::new(f.as_str());
576 let is_config_ext = p.extension().is_some_and(|e| {
577 e.eq_ignore_ascii_case("toml")
578 || e.eq_ignore_ascii_case("yaml")
579 || e.eq_ignore_ascii_case("yml")
580 || e.eq_ignore_ascii_case("json")
581 });
582 is_config_ext || f.contains("config") || f.contains(".env")
583 });
584
585 let dirs: std::collections::HashSet<&str> = touched_files
586 .iter()
587 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
588 .collect();
589
590 let task_type = if has_tests && touched_files.len() <= 3 {
591 TaskType::Test
592 } else if has_config && touched_files.len() <= 2 {
593 TaskType::Config
594 } else if dirs.len() > 3 {
595 TaskType::Refactor
596 } else {
597 TaskType::Explore
598 };
599
600 let scope = match touched_files.len() {
601 1 => IntentScope::SingleFile,
602 2..=4 => IntentScope::MultiFile,
603 _ => IntentScope::CrossModule,
604 };
605
606 let language_hint = detect_language_from_files(touched_files);
607
608 Self {
609 task_type,
610 confidence: 0.5,
611 targets: touched_files.to_vec(),
612 keywords: Vec::new(),
613 scope,
614 language_hint,
615 urgency: 0.0,
616 action_verb: None,
617 }
618 }
619
620 pub fn from_query_with_session(query: &str, touched_files: &[String]) -> Self {
621 let mut intent = Self::from_query(query);
622
623 if intent.confidence < 0.5 && !touched_files.is_empty() {
626 let behavioral = Self::from_file_patterns(touched_files);
627 if behavioral.confidence > intent.confidence {
628 intent.task_type = behavioral.task_type;
629 intent.confidence = behavioral.confidence;
630 intent.scope = behavioral.scope;
631 }
632 }
633
634 if intent.language_hint.is_none() && !touched_files.is_empty() {
635 intent.language_hint = detect_language_from_files(touched_files);
636 }
637
638 if intent.scope == IntentScope::SingleFile && touched_files.len() > 3 {
639 let dirs: std::collections::HashSet<&str> = touched_files
640 .iter()
641 .filter_map(|f| std::path::Path::new(f).parent()?.to_str())
642 .collect();
643 if dirs.len() > 2 {
644 intent.scope = IntentScope::MultiFile;
645 }
646 }
647
648 intent
649 }
650
651 pub fn format_header(&self) -> String {
652 format!(
653 "[TASK:{} SCOPE:{} CONF:{:.0}%{}{}]",
654 self.task_type.as_str(),
655 match self.scope {
656 IntentScope::SingleFile => "single",
657 IntentScope::MultiFile => "multi",
658 IntentScope::CrossModule => "cross",
659 IntentScope::ProjectWide => "project",
660 },
661 self.confidence * 100.0,
662 self.language_hint
663 .as_ref()
664 .map(|l| format!(" LANG:{l}"))
665 .unwrap_or_default(),
666 if self.urgency > 0.5 { " URGENT" } else { "" },
667 )
668 }
669}
670
671fn detect_language_hint(query: &str, targets: &[String]) -> Option<String> {
672 for t in targets {
673 let ext = std::path::Path::new(t).extension().and_then(|e| e.to_str());
674 match ext {
675 Some("rs") => return Some("rust".into()),
676 Some("ts" | "tsx") => return Some("typescript".into()),
677 Some("js" | "jsx") => return Some("javascript".into()),
678 Some("py") => return Some("python".into()),
679 Some("go") => return Some("go".into()),
680 Some("rb") => return Some("ruby".into()),
681 Some("java") => return Some("java".into()),
682 Some("swift") => return Some("swift".into()),
683 Some("zig") => return Some("zig".into()),
684 _ => {}
685 }
686 }
687
688 let q = query.to_lowercase();
689 let lang_keywords: &[(&str, &str)] = &[
690 ("rust", "rust"),
691 ("python", "python"),
692 ("typescript", "typescript"),
693 ("javascript", "javascript"),
694 ("golang", "go"),
695 (" go ", "go"),
696 ("ruby", "ruby"),
697 ("java ", "java"),
698 ("swift", "swift"),
699 ];
700 for &(kw, lang) in lang_keywords {
701 if q.contains(kw) {
702 return Some(lang.into());
703 }
704 }
705
706 None
707}
708
709fn detect_language_from_files(files: &[String]) -> Option<String> {
710 let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
711 for f in files {
712 let ext = std::path::Path::new(f)
713 .extension()
714 .and_then(|e| e.to_str())
715 .unwrap_or("");
716 let lang = match ext {
717 "rs" => "rust",
718 "ts" | "tsx" => "typescript",
719 "js" | "jsx" => "javascript",
720 "py" => "python",
721 "go" => "go",
722 "rb" => "ruby",
723 "java" => "java",
724 _ => continue,
725 };
726 *counts.entry(lang).or_insert(0) += 1;
727 }
728 counts
729 .into_iter()
730 .max_by_key(|(_, c)| *c)
731 .map(|(l, _)| l.to_string())
732}
733
734fn detect_urgency(query: &str) -> f64 {
735 let q = query.to_lowercase();
736 let urgent_words = [
737 "urgent",
738 "asap",
739 "immediately",
740 "critical",
741 "hotfix",
742 "emergency",
743 "blocker",
744 "breaking",
745 ];
746 let hits = urgent_words.iter().filter(|w| q.contains(*w)).count()
747 + intent_lang::URGENT_WORDS_I18N
748 .iter()
749 .filter(|w| q.contains(*w))
750 .count();
751 (hits as f64 * 0.4).min(1.0)
752}
753
754fn extract_action_verb(query: &str) -> Option<String> {
755 let verbs = [
756 "fix",
757 "add",
758 "create",
759 "implement",
760 "refactor",
761 "debug",
762 "test",
763 "write",
764 "update",
765 "remove",
766 "delete",
767 "rename",
768 "move",
769 "extract",
770 "split",
771 "merge",
772 "deploy",
773 "review",
774 "check",
775 "build",
776 "generate",
777 "optimize",
778 "clean",
779 ];
780 let q = query.to_lowercase();
781 let words: Vec<&str> = q.split_whitespace().collect();
782 for v in &verbs {
783 if words.first() == Some(v) || words.get(1) == Some(v) {
784 return Some(v.to_string());
785 }
786 }
787 for v in &verbs {
788 if words.contains(v) {
789 return Some(v.to_string());
790 }
791 }
792 None
793}
794
795#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
796pub enum IntentDimension {
797 What,
798 How,
799 Do,
800}
801
802impl IntentDimension {
803 pub fn as_str(&self) -> &'static str {
804 match self {
805 Self::What => "what",
806 Self::How => "how",
807 Self::Do => "do",
808 }
809 }
810}
811
812#[derive(Debug, Clone, serde::Serialize)]
813pub struct IntentRoute {
814 pub dimension: IntentDimension,
815 pub confidence: f64,
816 pub reasoning: String,
817}
818
819pub fn route_intent(query: &str, classification: &TaskClassification) -> IntentRoute {
820 let dimension = match classification.task_type {
821 TaskType::Explore | TaskType::Debug => IntentDimension::What,
822 TaskType::Review | TaskType::FixBug | TaskType::Test => IntentDimension::How,
823 TaskType::Generate | TaskType::Refactor | TaskType::Deploy | TaskType::Config => {
824 IntentDimension::Do
825 }
826 };
827 let complexity = classify_complexity(query, classification);
828 let reasoning = format!(
829 "{}({}) + {}complexity",
830 classification.task_type.as_str(),
831 dimension.as_str(),
832 match complexity {
833 super::adaptive::TaskComplexity::Mechanical => "low ",
834 super::adaptive::TaskComplexity::Standard => "",
835 super::adaptive::TaskComplexity::Architectural => "high ",
836 }
837 );
838 IntentRoute {
839 dimension,
840 confidence: classification.confidence,
841 reasoning,
842 }
843}
844
845#[cfg(test)]
846pub mod tests {
847 use super::*;
848
849 #[test]
850 fn classify_fix_bug() {
851 let r = classify("fix the bug in entropy.rs where token_entropy returns NaN");
852 assert_eq!(r.task_type, TaskType::FixBug);
853 assert!(r.confidence > 0.5);
854 assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
855 }
856
857 #[test]
858 fn classify_generate() {
859 let r = classify("add a new function normalized_token_entropy to entropy.rs");
860 assert_eq!(r.task_type, TaskType::Generate);
861 assert!(r.confidence > 0.5);
862 }
863
864 #[test]
865 fn classify_refactor() {
866 let r = classify("refactor the compression pipeline to split into smaller modules");
867 assert_eq!(r.task_type, TaskType::Refactor);
868 }
869
870 #[test]
871 fn classify_explore() {
872 let r = classify("how does the session cache work?");
873 assert_eq!(r.task_type, TaskType::Explore);
874 }
875
876 #[test]
877 fn classify_debug() {
878 let r = classify("debug why the compression ratio drops for large files");
879 assert_eq!(r.task_type, TaskType::Debug);
880 }
881
882 #[test]
883 fn classify_test() {
884 let r = classify("write unit tests for the token_optimizer module");
885 assert_eq!(r.task_type, TaskType::Test);
886 }
887
888 #[test]
889 fn targets_extract_paths() {
890 let r = classify("fix entropy.rs and update core/mod.rs");
891 assert!(r.targets.iter().any(|t| t.contains("entropy.rs")));
892 assert!(r.targets.iter().any(|t| t.contains("core/mod.rs")));
893 }
894
895 #[test]
896 fn targets_extract_identifiers() {
897 let r = classify("refactor SessionCache to use LRU eviction");
898 assert!(r.targets.iter().any(|t| t == "SessionCache"));
899 }
900
901 #[test]
902 fn fallback_to_explore() {
903 let r = classify("xyz qqq bbb");
904 assert_eq!(r.task_type, TaskType::Explore);
905 assert!(r.confidence < 0.5);
906 }
907
908 #[test]
909 fn multi_intent_detection() {
910 let results = detect_multi_intent("fix the bug in auth.rs and then write unit tests");
911 assert!(results.len() >= 2);
912 assert_eq!(results[0].task_type, TaskType::FixBug);
913 assert_eq!(results[1].task_type, TaskType::Test);
914 }
915
916 #[test]
917 fn single_intent_no_split() {
918 let results = detect_multi_intent("fix the bug in auth.rs");
919 assert_eq!(results.len(), 1);
920 assert_eq!(results[0].task_type, TaskType::FixBug);
921 }
922
923 #[test]
924 fn complexity_mechanical() {
925 let r = classify("add a comment");
926 let c = classify_complexity("add a comment", &r);
927 assert_eq!(c, super::super::adaptive::TaskComplexity::Mechanical);
928 }
929
930 #[test]
931 fn complexity_architectural() {
932 let r = classify("refactor auth across all files and update the migration");
933 let c = classify_complexity(
934 "refactor auth across all files and update the migration",
935 &r,
936 );
937 assert_eq!(c, super::super::adaptive::TaskComplexity::Architectural);
938 }
939
940 #[test]
941 fn route_explore_is_what() {
942 let c = TaskClassification {
943 task_type: TaskType::Explore,
944 confidence: 0.8,
945 targets: vec![],
946 keywords: vec!["explore".into()],
947 };
948 let route = route_intent("explore the codebase", &c);
949 assert_eq!(route.dimension, IntentDimension::What);
950 }
951
952 #[test]
953 fn route_fixbug_is_how() {
954 let c = TaskClassification {
955 task_type: TaskType::FixBug,
956 confidence: 0.9,
957 targets: vec!["auth.rs".into()],
958 keywords: vec!["fix".into(), "bug".into()],
959 };
960 let route = route_intent("fix the null pointer bug in auth.rs", &c);
961 assert_eq!(route.dimension, IntentDimension::How);
962 }
963
964 #[test]
965 fn route_generate_is_do() {
966 let c = TaskClassification {
967 task_type: TaskType::Generate,
968 confidence: 0.85,
969 targets: vec![],
970 keywords: vec!["generate".into()],
971 };
972 let route = route_intent("generate a new module", &c);
973 assert_eq!(route.dimension, IntentDimension::Do);
974 }
975
976 #[test]
977 fn route_complexity_describes_context_without_model_advice() {
978 let c = TaskClassification {
979 task_type: TaskType::FixBug,
980 confidence: 0.8,
981 targets: vec!["auth.rs".into(), "middleware.rs".into()],
982 keywords: vec!["fix".into()],
983 };
984 let route = route_intent("fix auth across all files and update the migration", &c);
985 assert_eq!(route.dimension, IntentDimension::How);
986 assert!(route.reasoning.contains("high complexity"));
987 assert!(
988 serde_json::to_value(&route)
989 .unwrap()
990 .get("model_tier")
991 .is_none()
992 );
993 }
994
995 #[test]
996 fn route_low_confidence_preserves_classification() {
997 let c = TaskClassification {
998 task_type: TaskType::Explore,
999 confidence: 0.3,
1000 targets: vec![],
1001 keywords: vec![],
1002 };
1003 let route = route_intent("something vague", &c);
1004 assert_eq!(route.dimension, IntentDimension::What);
1005 assert_eq!(route.confidence, c.confidence);
1006 }
1007
1008 #[test]
1010 fn classify_multilingual_table() {
1011 let table: &[(&str, TaskType)] = &[
1012 ("behebe den fehler in auth.rs", TaskType::FixBug),
1014 (
1015 "erstelle eine neue funktion für das datums-parsing",
1016 TaskType::Generate,
1017 ),
1018 ("räum die funktion auf", TaskType::Refactor),
1019 (
1020 "refaktorisiere das modul in kleinere teile",
1021 TaskType::Refactor,
1022 ),
1023 (
1024 "erkläre wie der session cache funktioniert",
1025 TaskType::Explore,
1026 ),
1027 ("schreibe tests für den parser", TaskType::Test),
1028 (
1029 "prüfe ob die validierung korrekt funktioniert",
1030 TaskType::Test,
1031 ),
1032 ("debugge warum der server abstürzt", TaskType::Debug),
1033 (
1034 "konfiguriere die umgebungsvariablen für den daemon",
1035 TaskType::Config,
1036 ),
1037 ("veröffentliche die neue version", TaskType::Deploy),
1038 ("überprüfe die änderungen vor dem merge", TaskType::Review),
1039 ("corrige le bug dans le parseur", TaskType::FixBug),
1041 ("ajoute une fonction de validation", TaskType::Generate),
1042 ("explique comment fonctionne le cache", TaskType::Explore),
1043 ("nettoie ce module pour le simplifier", TaskType::Refactor),
1044 ("vérifie que les tests passent", TaskType::Test),
1045 ("déploie la nouvelle version", TaskType::Deploy),
1046 ("pourquoi le serveur plante-t-il", TaskType::Explore),
1047 ("corrige el error en el módulo de auth", TaskType::FixBug),
1049 ("crea una función para parsear fechas", TaskType::Generate),
1050 ("explica cómo funciona la caché", TaskType::Explore),
1051 ("agrega soporte para webhooks", TaskType::Generate),
1052 (
1053 "muestra dónde se define la configuración",
1054 TaskType::Explore,
1055 ),
1056 ("revisa este pull request", TaskType::Review),
1057 ("despliega la nueva versión", TaskType::Deploy),
1058 ("escribe pruebas para el parser", TaskType::Test),
1059 ];
1060
1061 let misses: Vec<String> = table
1062 .iter()
1063 .filter_map(|(query, expected)| {
1064 let got = classify(query).task_type;
1065 (got != *expected).then(|| format!("'{query}': want {expected:?}, got {got:?}"))
1066 })
1067 .collect();
1068
1069 let hit_rate = (table.len() - misses.len()) as f64 / table.len() as f64;
1070 assert!(
1071 hit_rate >= 0.9,
1072 "multilingual hit rate {:.0}% < 90%:\n{}",
1073 hit_rate * 100.0,
1074 misses.join("\n")
1075 );
1076 }
1077
1078 #[test]
1080 fn keywords_filter_german_fillers() {
1081 let r = classify("bitte kannst du diese funktion aufräumen, ich möchte sauberen code");
1082 for filler in ["bitte", "kannst", "möchte", "diese"] {
1083 assert!(
1084 !r.keywords.iter().any(|k| k == filler),
1085 "filler '{filler}' leaked into keywords: {:?}",
1086 r.keywords
1087 );
1088 }
1089 assert!(r.keywords.iter().any(|k| k == "funktion"));
1090 assert_eq!(r.task_type, TaskType::Refactor);
1091 }
1092
1093 #[test]
1094 fn targets_filter_capitalized_german_fillers() {
1095 let r = classify("Bitte behebe den Fehler in der Konfiguration");
1096 assert!(!r.targets.iter().any(|t| t == "Bitte"), "{:?}", r.targets);
1097 assert_eq!(r.task_type, TaskType::FixBug);
1098 }
1099
1100 #[test]
1101 fn urgency_detects_german_markers() {
1102 let intent = StructuredIntent::from_query("dringend: behebe den absturz sofort");
1103 assert!(intent.urgency > 0.5);
1104 assert_eq!(intent.task_type, TaskType::FixBug);
1105 assert!(intent.format_header().contains("URGENT"));
1106 }
1107
1108 #[test]
1111 fn behavioral_signals_beat_low_confidence_text() {
1112 let touched = vec![
1113 "tests/foo_test.rs".to_string(),
1114 "tests/bar_test.rs".to_string(),
1115 ];
1116 let intent = StructuredIntent::from_query_with_session("xyz qqq bbb", &touched);
1117 assert_eq!(intent.task_type, TaskType::Test);
1118 assert!((intent.confidence - 0.5).abs() < f64::EPSILON);
1119 }
1120
1121 #[test]
1122 fn german_refactor_gets_diff_output() {
1123 let intent = StructuredIntent::from_query("räum die funktion auf");
1124 assert_eq!(intent.task_type, TaskType::Refactor);
1125 assert_eq!(intent.task_type.output_format(), OutputFormat::DiffOnly);
1126 }
1127}