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