Skip to main content

lean_ctx/core/
intent_engine.rs

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