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