1use anyhow::{Result, anyhow};
46use serde::{Deserialize, Serialize};
47use std::collections::BTreeMap;
48use std::str::FromStr;
49
50const CLAUDE_CODE_OPUS_ALIAS: &str = "opus";
57
58#[derive(
64 Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
65)]
66#[serde(rename_all = "lowercase")]
67pub enum Tier {
68 #[default]
70 Auto,
71 Low,
73 Med,
75 High,
77}
78
79impl std::fmt::Display for Tier {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Self::Auto => write!(f, "auto"),
83 Self::Low => write!(f, "low"),
84 Self::Med => write!(f, "med"),
85 Self::High => write!(f, "high"),
86 }
87 }
88}
89
90impl FromStr for Tier {
91 type Err = anyhow::Error;
92
93 fn from_str(s: &str) -> Result<Self> {
94 match s.trim().to_ascii_lowercase().as_str() {
95 "auto" => Ok(Self::Auto),
96 "low" => Ok(Self::Low),
97 "med" | "medium" => Ok(Self::Med),
98 "high" => Ok(Self::High),
99 other => Err(anyhow!(
100 "invalid tier `{}`: expected one of auto|low|med|high",
101 other
102 )),
103 }
104 }
105}
106
107#[derive(Debug, Default, Clone, Serialize, Deserialize)]
111pub struct TierMap {
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub low: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub med: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub high: Option<String>,
118}
119
120impl TierMap {
121 pub fn get(&self, tier: Tier) -> Option<&str> {
122 match tier {
123 Tier::Auto => None,
124 Tier::Low => self.low.as_deref(),
125 Tier::Med => self.med.as_deref(),
126 Tier::High => self.high.as_deref(),
127 }
128 }
129
130 pub fn tier_of(&self, model_name: &str) -> Option<Tier> {
132 if self.low.as_deref() == Some(model_name) {
133 Some(Tier::Low)
134 } else if self.med.as_deref() == Some(model_name) {
135 Some(Tier::Med)
136 } else if self.high.as_deref() == Some(model_name) {
137 Some(Tier::High)
138 } else {
139 None
140 }
141 }
142}
143
144#[derive(Debug, Default, Clone, Serialize, Deserialize)]
146pub struct ModelConfig {
147 #[serde(default = "default_auto")]
149 pub auto: bool,
150 #[serde(default)]
153 pub tiers: BTreeMap<String, TierMap>,
154}
155
156fn default_auto() -> bool {
157 true
158}
159
160fn builtin_claude_code() -> TierMap {
162 TierMap {
163 low: Some("haiku".to_string()),
164 med: Some("sonnet".to_string()),
165 high: Some(CLAUDE_CODE_OPUS_ALIAS.to_string()),
166 }
167}
168
169fn builtin_codex() -> TierMap {
171 TierMap {
172 low: Some("gpt-4o-mini".to_string()),
173 med: Some("gpt-4o".to_string()),
174 high: Some("o3".to_string()),
175 }
176}
177
178fn builtin_default() -> TierMap {
180 TierMap {
181 low: Some("haiku".to_string()),
182 med: Some("sonnet".to_string()),
183 high: Some("opus".to_string()),
184 }
185}
186
187fn builtin_for(harness: &str) -> TierMap {
189 match harness {
190 "claude-code" => builtin_claude_code(),
191 "codex" => builtin_codex(),
192 _ => builtin_default(),
193 }
194}
195
196pub fn detect_harness() -> String {
197 if ["CLAUDE_CODE_SESSION", "CLAUDE_CODE", "CLAUDECODE"]
198 .iter()
199 .any(|key| std::env::var_os(key).is_some())
200 {
201 "claude-code".to_string()
202 } else if ["CODEX_SESSION", "CODEX_THREAD_ID", "CODEX_CLI", "CODEX"]
203 .iter()
204 .any(|key| std::env::var_os(key).is_some())
205 {
206 "codex".to_string()
207 } else if ["OPENCODE_CLIENT", "OPENCODE"]
208 .iter()
209 .any(|key| std::env::var_os(key).is_some())
210 {
211 "opencode".to_string()
212 } else {
213 "default".to_string()
214 }
215}
216
217pub fn harness_key_for_agent_name(agent_name: &str) -> String {
218 let normalized = agent_name
219 .trim()
220 .to_ascii_lowercase()
221 .replace(['_', ' '], "-");
222 match normalized.as_str() {
223 "claude" | "claude-code" | "claudecode" | "claude-code-cli" => "claude-code".to_string(),
224 "codex" | "codex-cli" | "openai-codex" => "codex".to_string(),
225 "opencode" | "open-code" | "opencode-ai" => "opencode".to_string(),
226 "" => "default".to_string(),
227 other => other.to_string(),
228 }
229}
230
231pub fn resolve_tier_to_model(
236 tier: Tier,
237 harness: &str,
238 model_config: &ModelConfig,
239) -> Option<String> {
240 if matches!(tier, Tier::Auto) {
241 return None;
242 }
243 if let Some(map) = model_config.tiers.get(harness)
244 && let Some(name) = map.get(tier)
245 {
246 return Some(name.to_string());
247 }
248 builtin_for(harness).get(tier).map(|s| s.to_string())
249}
250
251fn is_claude_code_opus(model_name: &str) -> bool {
256 let m = model_name.trim();
257 m == CLAUDE_CODE_OPUS_ALIAS || m.starts_with("claude-opus") || m.starts_with("opus-")
258}
259
260pub fn is_bare_model_name(model_name: &str) -> bool {
264 let trimmed = model_name.trim();
265 !trimmed.contains('/') || trimmed.starts_with('/')
266}
267
268pub fn canonical_model_name(
282 model_name: &str,
283 harness: &str,
284 _model_config: &ModelConfig,
285) -> String {
286 if harness == "opencode" && is_bare_model_name(model_name) {
287 eprintln!(
288 "[model_tier] WARNING: opencode model name {:?} lacks a provider prefix \
289 (expected \"provider/model\", e.g. \"zai-coding-plan/glm-5.1\"). \
290 Dispatch may fail or use a wrong provider.",
291 model_name.trim()
292 );
293 }
294 model_name.to_string()
295}
296
297pub fn tier_from_model_name(
302 model_name: &str,
303 harness: &str,
304 model_config: &ModelConfig,
305) -> Option<Tier> {
306 if let Some(map) = model_config.tiers.get(harness)
307 && let Some(t) = map.tier_of(model_name)
308 {
309 return Some(t);
310 }
311 builtin_for(harness).tier_of(model_name).or_else(|| {
312 if harness == "claude-code" && is_claude_code_opus(model_name) {
313 Some(Tier::High)
314 } else {
315 None
316 }
317 })
318}
319
320pub fn extract_model_component(content: &str) -> Option<String> {
326 let comps = crate::component::parse(content).ok()?;
327 let comp = comps.into_iter().find(|c| c.name == "model")?;
328 let inner = &content[comp.open_end..comp.close_start];
329 let trimmed = inner.trim();
330 if trimmed.is_empty() {
331 None
332 } else {
333 Some(trimmed.to_string())
334 }
335}
336
337pub fn component_value_to_tier(
342 value: &str,
343 harness: &str,
344 model_config: &ModelConfig,
345) -> Option<Tier> {
346 if let Ok(tier) = Tier::from_str(value) {
347 return Some(tier);
348 }
349 tier_from_model_name(value, harness, model_config)
350}
351
352pub fn suggested_tier(
370 diff_type: Option<&str>,
371 lines_added: usize,
372 doc_path: &std::path::Path,
373) -> Tier {
374 let base = match diff_type {
375 Some("simple_question")
376 | Some("approval")
377 | Some("boundary_artifact")
378 | Some("annotation") => Tier::Low,
379 Some("content_addition") => {
380 if lines_added < 10 {
381 Tier::Low
382 } else {
383 Tier::Med
384 }
385 }
386 Some("multi_topic") | Some("structural_change") => Tier::Med,
387 _ => Tier::Med,
388 };
389
390 let path_str = doc_path.to_string_lossy();
392 let boost = path_str.contains("tasks/software/")
393 || path_str.contains("/specs/")
394 || path_str.contains("agent-doc-bugs")
395 || path_str.contains("plan-")
396 || path_str.contains("/plan.md");
397 if boost {
398 match base {
399 Tier::Auto | Tier::Low => Tier::Med,
400 Tier::Med => Tier::High,
401 Tier::High => Tier::High,
402 }
403 } else {
404 base
405 }
406}
407
408#[derive(Debug, Clone)]
410pub struct ModelSwitchScan {
411 pub model_switch: Option<String>,
413 pub model_switch_tier: Option<Tier>,
415 pub stripped_diff: String,
417}
418
419pub fn scan_model_switch(diff: &str, harness: &str, model_config: &ModelConfig) -> ModelSwitchScan {
433 let mut model_switch: Option<String> = None;
434 let mut model_switch_tier: Option<Tier> = None;
435 let mut kept_lines: Vec<&str> = Vec::with_capacity(diff.lines().count());
436
437 let mut in_fence = false;
438 let mut fence_char = '`';
439 let mut fence_len = 0usize;
440
441 for line in diff.lines() {
442 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
444 kept_lines.push(line);
445 continue;
446 }
447
448 let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
450 &line[1..]
451 } else {
452 line
453 };
454
455 let trimmed = content.trim_start();
457 if !in_fence {
458 let fc = trimmed.chars().next().unwrap_or('\0');
459 if (fc == '`' || fc == '~')
460 && let fl = trimmed.chars().take_while(|&c| c == fc).count()
461 && fl >= 3
462 {
463 in_fence = true;
464 fence_char = fc;
465 fence_len = fl;
466 kept_lines.push(line);
467 continue;
468 }
469 } else {
470 let fc = trimmed.chars().next().unwrap_or('\0');
471 if fc == fence_char {
472 let fl = trimmed.chars().take_while(|&c| c == fc).count();
473 if fl >= fence_len && trimmed[fl..].trim().is_empty() {
474 in_fence = false;
475 kept_lines.push(line);
476 continue;
477 }
478 }
479 }
480
481 let is_added = line.starts_with('+') && !line.starts_with("+++");
483 if !is_added {
484 kept_lines.push(line);
485 continue;
486 }
487
488 if in_fence {
490 kept_lines.push(line);
491 continue;
492 }
493
494 if content.starts_with('>') {
496 kept_lines.push(line);
497 continue;
498 }
499
500 let stripped = content.trim_end();
502 if let Some(rest) = stripped.strip_prefix("/model")
503 && let Some(arg) = rest.split_whitespace().next()
504 && !arg.is_empty()
505 {
506 if let Some((tier, name)) = parse_model_arg(arg, harness, model_config) {
508 if model_switch.is_none() {
509 model_switch = Some(name);
510 model_switch_tier = Some(tier);
511 }
512 continue;
514 }
515 continue;
517 }
518
519 kept_lines.push(line);
520 }
521
522 ModelSwitchScan {
523 model_switch,
524 model_switch_tier,
525 stripped_diff: kept_lines.join("\n"),
526 }
527}
528
529pub fn compose_effective_tier(
535 model_switch_tier: Option<Tier>,
536 component_tier: Option<Tier>,
537 frontmatter_tier: Option<Tier>,
538 suggested: Tier,
539) -> Tier {
540 for candidate in [model_switch_tier, component_tier, frontmatter_tier] {
541 if let Some(t) = candidate
542 && !matches!(t, Tier::Auto)
543 {
544 return t;
545 }
546 }
547 suggested
548}
549
550pub fn parse_model_arg(
557 arg: &str,
558 harness: &str,
559 model_config: &ModelConfig,
560) -> Option<(Tier, String)> {
561 let trimmed = arg.trim();
562 if let Ok(tier) = Tier::from_str(trimmed) {
564 if matches!(tier, Tier::Auto) {
565 return None;
566 }
567 let name = resolve_tier_to_model(tier, harness, model_config)
568 .unwrap_or_else(|| trimmed.to_string());
569 return Some((tier, name));
570 }
571 if let Some(tier) = tier_from_model_name(trimmed, harness, model_config) {
573 return Some((tier, canonical_model_name(trimmed, harness, model_config)));
574 }
575 if harness == "opencode" && !is_bare_model_name(trimmed) {
577 return Some((
578 Tier::Auto,
579 canonical_model_name(trimmed, harness, model_config),
580 ));
581 }
582 None
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use std::sync::Mutex;
590
591 static ENV_LOCK: Mutex<()> = Mutex::new(());
592
593 struct EnvRestore {
594 values: Vec<(&'static str, Option<std::ffi::OsString>)>,
595 }
596
597 impl EnvRestore {
598 fn clear(keys: &[&'static str]) -> Self {
599 let values = keys
600 .iter()
601 .map(|key| (*key, std::env::var_os(key)))
602 .collect::<Vec<_>>();
603 for key in keys {
604 unsafe { std::env::remove_var(key) };
606 }
607 Self { values }
608 }
609 }
610
611 impl Drop for EnvRestore {
612 fn drop(&mut self) {
613 for (key, value) in &self.values {
614 match value {
615 Some(value) => {
616 unsafe { std::env::set_var(key, value) };
618 }
619 None => {
620 unsafe { std::env::remove_var(key) };
622 }
623 }
624 }
625 }
626 }
627
628 const HARNESS_ENV_KEYS: &[&str] = &[
629 "CLAUDE_CODE_SESSION",
630 "CLAUDE_CODE",
631 "CLAUDECODE",
632 "CODEX_SESSION",
633 "CODEX_THREAD_ID",
634 "CODEX_CLI",
635 "CODEX",
636 "OPENCODE_CLIENT",
637 "OPENCODE",
638 ];
639
640 #[test]
641 fn tier_ordering() {
642 assert!(Tier::Auto < Tier::Low);
643 assert!(Tier::Low < Tier::Med);
644 assert!(Tier::Med < Tier::High);
645 assert!(Tier::High > Tier::Low);
646 assert!(Tier::Med >= Tier::Med);
647 }
648
649 #[test]
650 fn tier_from_str_case_insensitive() {
651 assert_eq!("LOW".parse::<Tier>().unwrap(), Tier::Low);
652 assert_eq!("low".parse::<Tier>().unwrap(), Tier::Low);
653 assert_eq!("Low".parse::<Tier>().unwrap(), Tier::Low);
654 assert_eq!("AUTO".parse::<Tier>().unwrap(), Tier::Auto);
655 assert_eq!("med".parse::<Tier>().unwrap(), Tier::Med);
656 assert_eq!("medium".parse::<Tier>().unwrap(), Tier::Med);
657 assert_eq!("HIGH".parse::<Tier>().unwrap(), Tier::High);
658 }
659
660 #[test]
661 fn tier_from_str_invalid() {
662 assert!("ultra".parse::<Tier>().is_err());
663 assert!("".parse::<Tier>().is_err());
664 assert!("opus".parse::<Tier>().is_err());
665 }
666
667 #[test]
668 fn tier_display() {
669 assert_eq!(Tier::Low.to_string(), "low");
670 assert_eq!(Tier::Med.to_string(), "med");
671 assert_eq!(Tier::High.to_string(), "high");
672 assert_eq!(Tier::Auto.to_string(), "auto");
673 }
674
675 #[test]
676 fn harness_detection_returns_known_value() {
677 let h = detect_harness();
680 assert!(
681 matches!(h.as_str(), "claude-code" | "codex" | "opencode" | "default"),
682 "unexpected harness: {h}"
683 );
684 }
685
686 #[test]
687 fn harness_key_for_agent_name_maps_cli_aliases() {
688 assert_eq!(harness_key_for_agent_name("claude"), "claude-code");
689 assert_eq!(harness_key_for_agent_name("claude_code"), "claude-code");
690 assert_eq!(harness_key_for_agent_name("codex"), "codex");
691 assert_eq!(harness_key_for_agent_name("opencode"), "opencode");
692 }
693
694 #[test]
695 fn harness_detection_recognizes_cli_environment_aliases() {
696 let _env_lock = ENV_LOCK
697 .lock()
698 .unwrap_or_else(|poisoned| poisoned.into_inner());
699 let _restore = EnvRestore::clear(HARNESS_ENV_KEYS);
700
701 unsafe { std::env::set_var("CLAUDE_CODE", "1") };
703 assert_eq!(detect_harness(), "claude-code");
704 unsafe { std::env::remove_var("CLAUDE_CODE") };
706
707 unsafe { std::env::set_var("CODEX_THREAD_ID", "thread-123") };
709 assert_eq!(detect_harness(), "codex");
710 unsafe { std::env::remove_var("CODEX_THREAD_ID") };
712
713 unsafe { std::env::set_var("OPENCODE", "1") };
715 assert_eq!(detect_harness(), "opencode");
716 }
717
718 #[test]
719 fn resolve_builtin_claude_code() {
720 let cfg = ModelConfig::default();
721 assert_eq!(
722 resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
723 Some("opus")
724 );
725 assert_eq!(
726 resolve_tier_to_model(Tier::Med, "claude-code", &cfg).as_deref(),
727 Some("sonnet")
728 );
729 assert_eq!(
730 resolve_tier_to_model(Tier::Low, "claude-code", &cfg).as_deref(),
731 Some("haiku")
732 );
733 assert_eq!(resolve_tier_to_model(Tier::Auto, "claude-code", &cfg), None);
734 }
735
736 #[test]
737 fn resolve_builtin_codex() {
738 let cfg = ModelConfig::default();
739 assert_eq!(
740 resolve_tier_to_model(Tier::High, "codex", &cfg).as_deref(),
741 Some("o3")
742 );
743 assert_eq!(
744 resolve_tier_to_model(Tier::Low, "codex", &cfg).as_deref(),
745 Some("gpt-4o-mini")
746 );
747 }
748
749 #[test]
750 fn resolve_unknown_harness_uses_default() {
751 let cfg = ModelConfig::default();
752 assert_eq!(
754 resolve_tier_to_model(Tier::High, "junie", &cfg).as_deref(),
755 Some("opus")
756 );
757 }
758
759 #[test]
760 fn user_config_overrides_builtin() {
761 let mut cfg = ModelConfig::default();
762 let mut tiers = BTreeMap::new();
763 tiers.insert(
764 "claude-code".to_string(),
765 TierMap {
766 low: Some("haiku-3".to_string()),
767 med: Some("sonnet-4".to_string()),
768 high: Some("opus-4-1".to_string()),
769 },
770 );
771 cfg.tiers = tiers;
772 assert_eq!(
773 resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
774 Some("opus-4-1")
775 );
776 }
777
778 #[test]
779 fn tier_from_model_name_builtin() {
780 let cfg = ModelConfig::default();
781 assert_eq!(
782 tier_from_model_name("opus", "claude-code", &cfg),
783 Some(Tier::High)
784 );
785 assert_eq!(
786 tier_from_model_name("claude-opus-4-8", "claude-code", &cfg),
787 Some(Tier::High)
788 );
789 assert_eq!(
790 tier_from_model_name("sonnet", "claude-code", &cfg),
791 Some(Tier::Med)
792 );
793 assert_eq!(
794 tier_from_model_name("haiku", "claude-code", &cfg),
795 Some(Tier::Low)
796 );
797 assert_eq!(tier_from_model_name("unknown", "claude-code", &cfg), None);
798 }
799
800 #[test]
801 fn parse_model_arg_tier_name() {
802 let cfg = ModelConfig::default();
803 let (tier, name) = parse_model_arg("high", "claude-code", &cfg).unwrap();
804 assert_eq!(tier, Tier::High);
805 assert_eq!(name, "opus");
806 }
807
808 #[test]
809 fn parse_model_arg_concrete_name() {
810 let cfg = ModelConfig::default();
811 let (tier, name) = parse_model_arg("opus", "claude-code", &cfg).unwrap();
812 assert_eq!(tier, Tier::High);
813 assert_eq!(name, "opus");
814 }
815
816 #[test]
817 fn canonical_model_name_defers_claude_code_opus_alias() {
818 let cfg = ModelConfig::default();
819 assert_eq!(canonical_model_name("opus", "claude-code", &cfg), "opus");
822 assert_eq!(canonical_model_name("opus", "codex", &cfg), "opus");
823 assert_eq!(
825 canonical_model_name("claude-opus-4-8", "claude-code", &cfg),
826 "claude-opus-4-8"
827 );
828 }
829
830 #[test]
831 fn tier_from_model_name_classifies_concrete_and_deferred_opus_as_high() {
832 let cfg = ModelConfig::default();
833 assert_eq!(
835 tier_from_model_name("opus", "claude-code", &cfg),
836 Some(Tier::High)
837 );
838 assert_eq!(
839 tier_from_model_name("claude-opus-4-8", "claude-code", &cfg),
840 Some(Tier::High)
841 );
842 assert_eq!(
843 tier_from_model_name("claude-opus-4-9", "claude-code", &cfg),
844 Some(Tier::High)
845 );
846 }
847
848 #[test]
849 fn is_bare_model_name_detects_missing_provider_prefix() {
850 assert!(is_bare_model_name("glm-5.1"));
851 assert!(is_bare_model_name("opus"));
852 assert!(is_bare_model_name("haiku"));
853 assert!(!is_bare_model_name("zai-coding-plan/glm-5.1"));
854 assert!(!is_bare_model_name("anthropic/claude-opus-4-7"));
855 assert!(is_bare_model_name("/leading-slash-only"));
856 }
857
858 #[test]
859 fn canonical_model_name_warns_on_bare_opencode_name() {
860 let cfg = ModelConfig::default();
861 assert_eq!(canonical_model_name("glm-5.1", "opencode", &cfg), "glm-5.1");
862 assert_eq!(
863 canonical_model_name("zai-coding-plan/glm-5.1", "opencode", &cfg),
864 "zai-coding-plan/glm-5.1"
865 );
866 }
867
868 #[test]
869 fn parse_model_arg_opencode_rejects_bare_name() {
870 let cfg = ModelConfig::default();
871 assert!(parse_model_arg("glm-5.1", "opencode", &cfg).is_none());
872 }
873
874 #[test]
875 fn parse_model_arg_opencode_accepts_provider_prefixed_name() {
876 let cfg = ModelConfig::default();
877 let (tier, name) = parse_model_arg("zai-coding-plan/glm-5.1", "opencode", &cfg).unwrap();
878 assert_eq!(tier, Tier::Auto);
879 assert_eq!(name, "zai-coding-plan/glm-5.1");
880 }
881
882 #[test]
883 fn parse_model_arg_unknown() {
884 let cfg = ModelConfig::default();
885 assert!(parse_model_arg("xyz-3000", "claude-code", &cfg).is_none());
886 }
887
888 #[test]
889 fn parse_model_arg_auto_rejected() {
890 let cfg = ModelConfig::default();
891 assert!(parse_model_arg("auto", "claude-code", &cfg).is_none());
892 }
893
894 #[test]
895 fn extract_model_component_present() {
896 let doc = "# Title\n\n<!-- agent:model -->\nhigh\n<!-- /agent:model -->\n\nbody\n";
897 assert_eq!(extract_model_component(doc).as_deref(), Some("high"));
898 }
899
900 #[test]
901 fn extract_model_component_absent() {
902 let doc = "# Title\n\nbody only\n";
903 assert_eq!(extract_model_component(doc), None);
904 }
905
906 #[test]
907 fn extract_model_component_empty_inner() {
908 let doc = "<!-- agent:model -->\n<!-- /agent:model -->\n";
909 assert_eq!(extract_model_component(doc), None);
910 }
911
912 #[test]
913 fn extract_model_component_concrete_name() {
914 let doc = "<!-- agent:model -->\nopus\n<!-- /agent:model -->\n";
915 assert_eq!(extract_model_component(doc).as_deref(), Some("opus"));
916 }
917
918 #[test]
919 fn component_value_to_tier_tier_name() {
920 let cfg = ModelConfig::default();
921 assert_eq!(
922 component_value_to_tier("high", "claude-code", &cfg),
923 Some(Tier::High)
924 );
925 }
926
927 #[test]
928 fn component_value_to_tier_concrete_name() {
929 let cfg = ModelConfig::default();
930 assert_eq!(
931 component_value_to_tier("opus", "claude-code", &cfg),
932 Some(Tier::High)
933 );
934 }
935
936 #[test]
937 fn component_value_to_tier_unknown() {
938 let cfg = ModelConfig::default();
939 assert_eq!(component_value_to_tier("xyz", "claude-code", &cfg), None);
940 }
941
942 #[test]
943 fn suggested_tier_simple_question() {
944 let path = std::path::Path::new("tasks/research/x.md");
945 assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Low);
946 }
947
948 #[test]
949 fn suggested_tier_small_addition() {
950 let path = std::path::Path::new("tasks/research/x.md");
951 assert_eq!(suggested_tier(Some("content_addition"), 5, path), Tier::Low);
952 }
953
954 #[test]
955 fn suggested_tier_large_addition() {
956 let path = std::path::Path::new("tasks/research/x.md");
957 assert_eq!(
958 suggested_tier(Some("content_addition"), 50, path),
959 Tier::Med
960 );
961 }
962
963 #[test]
964 fn suggested_tier_default_for_unknown() {
965 let path = std::path::Path::new("tasks/research/x.md");
966 assert_eq!(suggested_tier(None, 0, path), Tier::Med);
967 }
968
969 #[test]
970 fn suggested_tier_path_boost_software() {
971 let path = std::path::Path::new("tasks/software/foo.md");
972 assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Med);
974 assert_eq!(
976 suggested_tier(Some("content_addition"), 50, path),
977 Tier::High
978 );
979 }
980
981 #[test]
982 fn suggested_tier_path_boost_caps_at_high() {
983 let path = std::path::Path::new("tasks/software/foo.md");
984 let t = suggested_tier(Some("content_addition"), 50, path);
986 assert_eq!(t, Tier::High);
987 }
988
989 #[test]
990 fn compose_effective_tier_model_switch_wins() {
991 let t = compose_effective_tier(
992 Some(Tier::High),
993 Some(Tier::Low),
994 Some(Tier::Med),
995 Tier::Low,
996 );
997 assert_eq!(t, Tier::High);
998 }
999
1000 #[test]
1001 fn compose_effective_tier_component_beats_frontmatter() {
1002 let t = compose_effective_tier(None, Some(Tier::High), Some(Tier::Low), Tier::Med);
1003 assert_eq!(t, Tier::High);
1004 }
1005
1006 #[test]
1007 fn compose_effective_tier_frontmatter_beats_heuristic() {
1008 let t = compose_effective_tier(None, None, Some(Tier::High), Tier::Low);
1009 assert_eq!(t, Tier::High);
1010 }
1011
1012 #[test]
1013 fn compose_effective_tier_falls_through_to_heuristic() {
1014 let t = compose_effective_tier(None, None, None, Tier::Med);
1015 assert_eq!(t, Tier::Med);
1016 }
1017
1018 #[test]
1019 fn scan_model_switch_concrete_name() {
1020 let cfg = ModelConfig::default();
1021 let diff = "@@ -1,3 +1,4 @@\n context\n+/model opus\n+real edit\n";
1022 let result = scan_model_switch(diff, "claude-code", &cfg);
1023 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1024 assert_eq!(result.model_switch_tier, Some(Tier::High));
1025 assert!(!result.stripped_diff.contains("/model opus"));
1026 assert!(result.stripped_diff.contains("real edit"));
1027 }
1028
1029 #[test]
1030 fn scan_model_switch_tier_name() {
1031 let cfg = ModelConfig::default();
1032 let diff = "+/model high\n+other line\n";
1033 let result = scan_model_switch(diff, "claude-code", &cfg);
1034 assert_eq!(result.model_switch_tier, Some(Tier::High));
1035 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1036 assert!(!result.stripped_diff.contains("/model high"));
1037 }
1038
1039 #[test]
1040 fn scan_model_switch_haiku() {
1041 let cfg = ModelConfig::default();
1042 let diff = "+/model haiku\n";
1043 let result = scan_model_switch(diff, "claude-code", &cfg);
1044 assert_eq!(result.model_switch_tier, Some(Tier::Low));
1045 }
1046
1047 #[test]
1048 fn scan_model_switch_inside_fenced_code_ignored() {
1049 let cfg = ModelConfig::default();
1050 let diff = "+```\n+/model opus\n+```\n+real line\n";
1051 let result = scan_model_switch(diff, "claude-code", &cfg);
1052 assert_eq!(result.model_switch, None);
1053 assert!(result.stripped_diff.contains("/model opus"));
1054 }
1055
1056 #[test]
1057 fn scan_model_switch_inside_blockquote_ignored() {
1058 let cfg = ModelConfig::default();
1059 let diff = "+> /model opus\n+real line\n";
1060 let result = scan_model_switch(diff, "claude-code", &cfg);
1061 assert_eq!(result.model_switch, None);
1062 assert!(result.stripped_diff.contains("/model opus"));
1063 }
1064
1065 #[test]
1066 fn scan_model_switch_only_added_lines() {
1067 let cfg = ModelConfig::default();
1068 let diff = " /model opus\n+real line\n";
1070 let result = scan_model_switch(diff, "claude-code", &cfg);
1071 assert_eq!(result.model_switch, None);
1072 }
1073
1074 #[test]
1075 fn scan_model_switch_no_match() {
1076 let cfg = ModelConfig::default();
1077 let diff = "+just a normal line\n+another\n";
1078 let result = scan_model_switch(diff, "claude-code", &cfg);
1079 assert_eq!(result.model_switch, None);
1080 assert!(result.stripped_diff.contains("just a normal line"));
1082 assert!(result.stripped_diff.contains("another"));
1083 }
1084
1085 #[test]
1086 fn scan_model_switch_unknown_arg_still_stripped() {
1087 let cfg = ModelConfig::default();
1088 let diff = "+/model xyz-3000\n+real line\n";
1090 let result = scan_model_switch(diff, "claude-code", &cfg);
1091 assert_eq!(result.model_switch, None);
1092 assert!(!result.stripped_diff.contains("/model xyz-3000"));
1093 assert!(result.stripped_diff.contains("real line"));
1094 }
1095
1096 #[test]
1097 fn scan_model_switch_first_match_wins() {
1098 let cfg = ModelConfig::default();
1099 let diff = "+/model opus\n+/model haiku\n";
1100 let result = scan_model_switch(diff, "claude-code", &cfg);
1101 assert_eq!(result.model_switch.as_deref(), Some("opus"));
1102 assert!(!result.stripped_diff.contains("/model"));
1104 }
1105
1106 #[test]
1107 fn compose_effective_tier_auto_falls_through() {
1108 let t = compose_effective_tier(
1110 Some(Tier::Auto),
1111 Some(Tier::Auto),
1112 Some(Tier::High),
1113 Tier::Low,
1114 );
1115 assert_eq!(t, Tier::High);
1116 }
1117}