Skip to main content

agent_doc/
model_tier.rs

1//! # Module: model_tier
2//!
3//! ## Spec
4//! - Defines `Tier`: a harness-agnostic complexity bucket (`Auto`, `Low`, `Med`, `High`)
5//!   used to classify task complexity and gate model selection.
6//! - `Tier` derives `PartialOrd` such that `Auto < Low < Med < High`. Gating is a simple
7//!   `>` comparison: a task whose effective tier exceeds the running model's tier should
8//!   prompt the user to switch.
9//! - Defines `ModelConfig` (under `[model]` in the global TOML config) and `TierMap` (per-harness
10//!   tier→model name resolution under `[model.tiers.<harness>]`).
11//! - `detect_harness()` reads environment variables (`CLAUDE_CODE_SESSION`, `CODEX_SESSION`)
12//!   to identify the active agent harness, falling back to `"default"`.
13//! - `resolve_tier_to_model(tier, harness, config)` maps a `Tier` to the concrete model name
14//!   configured for the given harness, falling back to built-in defaults for the
15//!   `claude-code`, `codex`, and `default` harnesses.
16//! - `tier_from_model_name(name, harness, config)` is the reverse lookup: given a concrete
17//!   model name (e.g., `"opus"`), find the tier it belongs to in the harness mapping.
18//! - `Tier::FromStr` accepts case-insensitive `auto | low | med | high`.
19//!
20//! ## Agentic Contracts
21//! - **Total ordering**: `Tier` implements `PartialOrd` deterministically; gating logic
22//!   is a single comparison and can be safely executed by any model tier.
23//! - **Auto is the lowest**: `Tier::Auto` represents "no preference" and compares less than
24//!   `Low`. The `effective_tier` composition treats `Auto` as "fall through to next source."
25//! - **Built-in defaults**: when no `[model.tiers.<harness>]` section is present, the
26//!   resolver falls back to compiled-in maps for known harnesses. This means a fresh
27//!   install needs zero config for the common case.
28//! - **Reverse lookup is partial**: `tier_from_model_name` returns `None` if the model
29//!   name doesn't appear in any tier slot for the harness. Callers should treat `None`
30//!   as "unknown — leave tier as Auto."
31//!
32//! ## Evals
33//! - `tier_ordering`: `Auto < Low < Med < High` holds for `<`, `>`, `<=`, `>=`.
34//! - `tier_from_str_case_insensitive`: `"LOW"`, `"low"`, `"Low"` all parse to `Tier::Low`.
35//! - `tier_from_str_invalid`: unknown strings return `Err`.
36//! - `harness_detection_default`: with no env vars set, `detect_harness()` returns `"default"`.
37//! - `resolve_builtin_claude_code`: `resolve_tier_to_model(Tier::High, "claude-code", &Config::default())`
38//!   returns `Some("opus")`.
39//! - `resolve_unknown_harness_uses_default`: an unknown harness falls through to the
40//!   `"default"` built-in map.
41//! - `tier_from_model_name_roundtrip`: `tier_from_model_name("opus", "claude-code", ...)`
42//!   returns `Some(Tier::High)`.
43
44use anyhow::{anyhow, Result};
45use serde::{Deserialize, Serialize};
46use std::collections::BTreeMap;
47use std::str::FromStr;
48
49/// Harness-agnostic model complexity tier.
50///
51/// Ordering: `Auto < Low < Med < High`. Gating logic uses a simple `>` comparison —
52/// a task whose effective tier exceeds the running model's tier should prompt the
53/// user to switch models.
54#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Tier {
57    /// No preference; fall through to next source in the precedence chain.
58    #[default]
59    Auto,
60    /// Cheap, fast model — small content additions, simple questions.
61    Low,
62    /// Default working model — multi-section edits, planning, moderate diffs.
63    Med,
64    /// Powerful model — complex debugging, architecture decisions, large code changes.
65    High,
66}
67
68impl std::fmt::Display for Tier {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::Auto => write!(f, "auto"),
72            Self::Low => write!(f, "low"),
73            Self::Med => write!(f, "med"),
74            Self::High => write!(f, "high"),
75        }
76    }
77}
78
79impl FromStr for Tier {
80    type Err = anyhow::Error;
81
82    fn from_str(s: &str) -> Result<Self> {
83        match s.trim().to_ascii_lowercase().as_str() {
84            "auto" => Ok(Self::Auto),
85            "low" => Ok(Self::Low),
86            "med" | "medium" => Ok(Self::Med),
87            "high" => Ok(Self::High),
88            other => Err(anyhow!(
89                "invalid tier `{}`: expected one of auto|low|med|high",
90                other
91            )),
92        }
93    }
94}
95
96/// Per-harness tier → concrete model name map.
97///
98/// Configured under `[model.tiers.<harness>]` in the global config.
99#[derive(Debug, Default, Clone, Serialize, Deserialize)]
100pub struct TierMap {
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub low: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub med: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub high: Option<String>,
107}
108
109impl TierMap {
110    pub fn get(&self, tier: Tier) -> Option<&str> {
111        match tier {
112            Tier::Auto => None,
113            Tier::Low => self.low.as_deref(),
114            Tier::Med => self.med.as_deref(),
115            Tier::High => self.high.as_deref(),
116        }
117    }
118
119    /// Reverse lookup: find which tier a concrete model name belongs to.
120    pub fn tier_of(&self, model_name: &str) -> Option<Tier> {
121        if self.low.as_deref() == Some(model_name) {
122            Some(Tier::Low)
123        } else if self.med.as_deref() == Some(model_name) {
124            Some(Tier::Med)
125        } else if self.high.as_deref() == Some(model_name) {
126            Some(Tier::High)
127        } else {
128            None
129        }
130    }
131}
132
133/// Global `[model]` config section.
134#[derive(Debug, Default, Clone, Serialize, Deserialize)]
135pub struct ModelConfig {
136    /// Whether automatic tier-based recommendations are enabled (default: true).
137    #[serde(default = "default_auto")]
138    pub auto: bool,
139    /// Per-harness tier → model name maps. Key is the harness name
140    /// (e.g., `claude-code`, `codex`, `default`).
141    #[serde(default)]
142    pub tiers: BTreeMap<String, TierMap>,
143}
144
145fn default_auto() -> bool {
146    true
147}
148
149/// Built-in tier map for the `claude-code` harness.
150fn builtin_claude_code() -> TierMap {
151    TierMap {
152        low: Some("haiku".to_string()),
153        med: Some("sonnet".to_string()),
154        high: Some("opus".to_string()),
155    }
156}
157
158/// Built-in tier map for the `codex` harness.
159fn builtin_codex() -> TierMap {
160    TierMap {
161        low: Some("gpt-4o-mini".to_string()),
162        med: Some("gpt-4o".to_string()),
163        high: Some("o3".to_string()),
164    }
165}
166
167/// Built-in fallback tier map.
168fn builtin_default() -> TierMap {
169    TierMap {
170        low: Some("haiku".to_string()),
171        med: Some("sonnet".to_string()),
172        high: Some("opus".to_string()),
173    }
174}
175
176/// Return the built-in tier map for a known harness, or `default` for unknowns.
177fn builtin_for(harness: &str) -> TierMap {
178    match harness {
179        "claude-code" => builtin_claude_code(),
180        "codex" => builtin_codex(),
181        _ => builtin_default(),
182    }
183}
184
185/// Detect the active harness from environment variables.
186///
187/// Returns `"claude-code"` if `CLAUDE_CODE_SESSION` is set, `"codex"` if `CODEX_SESSION`
188/// is set, otherwise `"default"`.
189pub fn detect_harness() -> String {
190    if std::env::var("CLAUDE_CODE_SESSION").is_ok() || std::env::var("CLAUDECODE").is_ok() {
191        "claude-code".to_string()
192    } else if std::env::var("CODEX_SESSION").is_ok() {
193        "codex".to_string()
194    } else {
195        "default".to_string()
196    }
197}
198
199/// Resolve a `Tier` to a concrete model name for the given harness.
200///
201/// Tries the user's `[model.tiers.<harness>]` config first, then falls back to the
202/// built-in map for the harness. Returns `None` for `Tier::Auto`.
203pub fn resolve_tier_to_model(
204    tier: Tier,
205    harness: &str,
206    model_config: &ModelConfig,
207) -> Option<String> {
208    if matches!(tier, Tier::Auto) {
209        return None;
210    }
211    if let Some(map) = model_config.tiers.get(harness)
212        && let Some(name) = map.get(tier)
213    {
214        return Some(name.to_string());
215    }
216    builtin_for(harness).get(tier).map(|s| s.to_string())
217}
218
219/// Reverse lookup: given a concrete model name, find its tier in the harness's mapping.
220///
221/// Tries the user's config first, then falls back to the built-in map. Returns `None`
222/// if the model name doesn't appear in any tier slot for the harness.
223pub fn tier_from_model_name(
224    model_name: &str,
225    harness: &str,
226    model_config: &ModelConfig,
227) -> Option<Tier> {
228    if let Some(map) = model_config.tiers.get(harness)
229        && let Some(t) = map.tier_of(model_name)
230    {
231        return Some(t);
232    }
233    builtin_for(harness).tier_of(model_name)
234}
235
236/// Extract the value inside a `<!-- agent:model -->...<!-- /agent:model -->` component.
237///
238/// Returns the trimmed inner content if the component is present, `None` otherwise.
239/// This uses the existing component parser, so guards against fenced code blocks
240/// and inline code apply automatically.
241pub fn extract_model_component(content: &str) -> Option<String> {
242    let comps = crate::component::parse(content).ok()?;
243    let comp = comps.into_iter().find(|c| c.name == "model")?;
244    let inner = &content[comp.open_end..comp.close_start];
245    let trimmed = inner.trim();
246    if trimmed.is_empty() {
247        None
248    } else {
249        Some(trimmed.to_string())
250    }
251}
252
253/// Resolve a `<!-- agent:model -->` component value to a `Tier`.
254///
255/// Accepts tier names (`auto|low|med|high`) or concrete model names (resolved
256/// via the harness's tier map). Returns `None` if the value is unrecognized.
257pub fn component_value_to_tier(
258    value: &str,
259    harness: &str,
260    model_config: &ModelConfig,
261) -> Option<Tier> {
262    if let Ok(tier) = Tier::from_str(value) {
263        return Some(tier);
264    }
265    tier_from_model_name(value, harness, model_config)
266}
267
268/// Compute a `suggested_tier` from structural diff signals.
269///
270/// This is the deterministic, harness-agnostic heuristic used when no explicit
271/// tier source (inline command, component, frontmatter) is present.
272///
273/// Inputs:
274/// - `diff_type`: classification string from `diff::classify_diff` (e.g., `"simple_question"`)
275/// - `lines_added`: count of `+` lines in the unified diff (excluding `+++`)
276/// - `doc_path`: relative document path; certain prefixes bump the tier
277///
278/// Mapping (primary):
279/// - `simple_question`, `approval`, `boundary_artifact`, `annotation` → `Low`
280/// - `content_addition` < 10 lines → `Low`; ≥ 10 lines → `Med`
281/// - `multi_topic`, `structural_change` → `Med`
282/// - unknown / missing → `Med` (safe default)
283///
284/// Path boost: `tasks/software/` and `src/**/specs/` paths bump one tier (cap `High`).
285pub fn suggested_tier(diff_type: Option<&str>, lines_added: usize, doc_path: &std::path::Path) -> Tier {
286    let base = match diff_type {
287        Some("simple_question") | Some("approval") | Some("boundary_artifact") | Some("annotation") => {
288            Tier::Low
289        }
290        Some("content_addition") => {
291            if lines_added < 10 {
292                Tier::Low
293            } else {
294                Tier::Med
295            }
296        }
297        Some("multi_topic") | Some("structural_change") => Tier::Med,
298        _ => Tier::Med,
299    };
300
301    // Path boost: tasks/software/ → bump one tier (cap at High).
302    let path_str = doc_path.to_string_lossy();
303    let boost = path_str.contains("tasks/software/")
304        || path_str.contains("/specs/")
305        || path_str.contains("agent-doc-bugs")
306        || path_str.contains("plan-")
307        || path_str.contains("/plan.md");
308    if boost {
309        match base {
310            Tier::Auto | Tier::Low => Tier::Med,
311            Tier::Med => Tier::High,
312            Tier::High => Tier::High,
313        }
314    } else {
315        base
316    }
317}
318
319/// Result of scanning a unified diff for an inline `/model <x>` command.
320#[derive(Debug, Clone)]
321pub struct ModelSwitchScan {
322    /// The concrete model name from `/model <name>` (e.g., `"opus"`).
323    pub model_switch: Option<String>,
324    /// The resolved tier for the model switch (e.g., `Tier::High` for `opus`).
325    pub model_switch_tier: Option<Tier>,
326    /// The diff text with the `/model <x>` command line(s) stripped.
327    pub stripped_diff: String,
328}
329
330/// Scan a unified diff for an inline `/model <x>` command in user-added lines.
331///
332/// Behavior:
333/// - Only matches `+` lines (user additions), excluding `+++` headers.
334/// - Skips lines inside fenced code blocks (``` or ~~~).
335/// - Skips blockquote lines (`+>`).
336/// - Pattern: line content matches `/model <arg>` (whitespace allowed).
337/// - On match, the line is removed from the returned diff so it does not
338///   propagate to classification or response generation.
339/// - Only the first match is captured; subsequent `/model` lines are still stripped.
340///
341/// The `arg` is parsed via `parse_model_arg`, which accepts both tier names
342/// (`low|med|high`) and concrete model names (`opus|sonnet|...`).
343pub fn scan_model_switch(
344    diff: &str,
345    harness: &str,
346    model_config: &ModelConfig,
347) -> ModelSwitchScan {
348    let mut model_switch: Option<String> = None;
349    let mut model_switch_tier: Option<Tier> = None;
350    let mut kept_lines: Vec<&str> = Vec::with_capacity(diff.lines().count());
351
352    let mut in_fence = false;
353    let mut fence_char = '`';
354    let mut fence_len = 0usize;
355
356    for line in diff.lines() {
357        // Skip unified diff meta-lines unchanged.
358        if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
359            kept_lines.push(line);
360            continue;
361        }
362
363        // Strip leading diff marker to inspect content.
364        let content = if line.starts_with('+') || line.starts_with('-') || line.starts_with(' ') {
365            &line[1..]
366        } else {
367            line
368        };
369
370        // Track code-fence state across all lines.
371        let trimmed = content.trim_start();
372        if !in_fence {
373            let fc = trimmed.chars().next().unwrap_or('\0');
374            if (fc == '`' || fc == '~')
375                && let fl = trimmed.chars().take_while(|&c| c == fc).count()
376                && fl >= 3
377            {
378                in_fence = true;
379                fence_char = fc;
380                fence_len = fl;
381                kept_lines.push(line);
382                continue;
383            }
384        } else {
385            let fc = trimmed.chars().next().unwrap_or('\0');
386            if fc == fence_char {
387                let fl = trimmed.chars().take_while(|&c| c == fc).count();
388                if fl >= fence_len && trimmed[fl..].trim().is_empty() {
389                    in_fence = false;
390                    kept_lines.push(line);
391                    continue;
392                }
393            }
394        }
395
396        // Only consider `+` lines (excluding `+++`) for stripping.
397        let is_added = line.starts_with('+') && !line.starts_with("+++");
398        if !is_added {
399            kept_lines.push(line);
400            continue;
401        }
402
403        // In a fence — keep as-is (no stripping inside fences).
404        if in_fence {
405            kept_lines.push(line);
406            continue;
407        }
408
409        // Skip blockquotes.
410        if content.starts_with('>') {
411            kept_lines.push(line);
412            continue;
413        }
414
415        // Match `/model <arg>` pattern.
416        let stripped = content.trim_end();
417        if let Some(rest) = stripped.strip_prefix("/model")
418            && let Some(arg) = rest.split_whitespace().next()
419            && !arg.is_empty()
420        {
421            // Parse the arg into (tier, concrete_name).
422            if let Some((tier, name)) = parse_model_arg(arg, harness, model_config) {
423                if model_switch.is_none() {
424                    model_switch = Some(name);
425                    model_switch_tier = Some(tier);
426                }
427                // Drop the line from the diff regardless (always strip /model).
428                continue;
429            }
430            // Unknown arg — still strip the line to avoid /model leaking through.
431            continue;
432        }
433
434        kept_lines.push(line);
435    }
436
437    ModelSwitchScan {
438        model_switch,
439        model_switch_tier,
440        stripped_diff: kept_lines.join("\n"),
441    }
442}
443
444/// Compose the final `effective_tier` from all available sources.
445///
446/// Precedence (highest wins): inline `/model` command, then `<!-- agent:model -->`
447/// component, then `agent_doc_model_tier` frontmatter, then diff heuristic.
448/// `Tier::Auto` is a no-preference sentinel and falls through to the next source.
449pub fn compose_effective_tier(
450    model_switch_tier: Option<Tier>,
451    component_tier: Option<Tier>,
452    frontmatter_tier: Option<Tier>,
453    suggested: Tier,
454) -> Tier {
455    for candidate in [model_switch_tier, component_tier, frontmatter_tier] {
456        if let Some(t) = candidate
457            && !matches!(t, Tier::Auto)
458        {
459            return t;
460        }
461    }
462    suggested
463}
464
465/// Parse a `/model <arg>` argument: either a tier name (`low|med|high`) or a concrete
466/// model name (`opus|sonnet|...`).
467///
468/// Returns the resolved `Tier` and the concrete model name (the original arg if it
469/// was already a concrete name; the resolved name from config/built-ins if it was a
470/// tier name).
471pub fn parse_model_arg(
472    arg: &str,
473    harness: &str,
474    model_config: &ModelConfig,
475) -> Option<(Tier, String)> {
476    let trimmed = arg.trim();
477    // Try parsing as a tier name first.
478    if let Ok(tier) = Tier::from_str(trimmed) {
479        if matches!(tier, Tier::Auto) {
480            return None;
481        }
482        let name = resolve_tier_to_model(tier, harness, model_config)
483            .unwrap_or_else(|| trimmed.to_string());
484        return Some((tier, name));
485    }
486    // Otherwise treat as a concrete model name and reverse-lookup the tier.
487    if let Some(tier) = tier_from_model_name(trimmed, harness, model_config) {
488        return Some((tier, trimmed.to_string()));
489    }
490    // Unknown — accept the name but leave tier as Auto so it doesn't gate.
491    None
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn tier_ordering() {
500        assert!(Tier::Auto < Tier::Low);
501        assert!(Tier::Low < Tier::Med);
502        assert!(Tier::Med < Tier::High);
503        assert!(Tier::High > Tier::Low);
504        assert!(Tier::Med >= Tier::Med);
505    }
506
507    #[test]
508    fn tier_from_str_case_insensitive() {
509        assert_eq!("LOW".parse::<Tier>().unwrap(), Tier::Low);
510        assert_eq!("low".parse::<Tier>().unwrap(), Tier::Low);
511        assert_eq!("Low".parse::<Tier>().unwrap(), Tier::Low);
512        assert_eq!("AUTO".parse::<Tier>().unwrap(), Tier::Auto);
513        assert_eq!("med".parse::<Tier>().unwrap(), Tier::Med);
514        assert_eq!("medium".parse::<Tier>().unwrap(), Tier::Med);
515        assert_eq!("HIGH".parse::<Tier>().unwrap(), Tier::High);
516    }
517
518    #[test]
519    fn tier_from_str_invalid() {
520        assert!("ultra".parse::<Tier>().is_err());
521        assert!("".parse::<Tier>().is_err());
522        assert!("opus".parse::<Tier>().is_err());
523    }
524
525    #[test]
526    fn tier_display() {
527        assert_eq!(Tier::Low.to_string(), "low");
528        assert_eq!(Tier::Med.to_string(), "med");
529        assert_eq!(Tier::High.to_string(), "high");
530        assert_eq!(Tier::Auto.to_string(), "auto");
531    }
532
533    #[test]
534    fn harness_detection_returns_known_value() {
535        // Don't mutate env (Rust 2024 marks env mutators unsafe + tests may run
536        // in parallel). Just assert the function returns one of the known values.
537        let h = detect_harness();
538        assert!(
539            matches!(h.as_str(), "claude-code" | "codex" | "default"),
540            "unexpected harness: {h}"
541        );
542    }
543
544    #[test]
545    fn resolve_builtin_claude_code() {
546        let cfg = ModelConfig::default();
547        assert_eq!(
548            resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
549            Some("opus")
550        );
551        assert_eq!(
552            resolve_tier_to_model(Tier::Med, "claude-code", &cfg).as_deref(),
553            Some("sonnet")
554        );
555        assert_eq!(
556            resolve_tier_to_model(Tier::Low, "claude-code", &cfg).as_deref(),
557            Some("haiku")
558        );
559        assert_eq!(resolve_tier_to_model(Tier::Auto, "claude-code", &cfg), None);
560    }
561
562    #[test]
563    fn resolve_builtin_codex() {
564        let cfg = ModelConfig::default();
565        assert_eq!(
566            resolve_tier_to_model(Tier::High, "codex", &cfg).as_deref(),
567            Some("o3")
568        );
569        assert_eq!(
570            resolve_tier_to_model(Tier::Low, "codex", &cfg).as_deref(),
571            Some("gpt-4o-mini")
572        );
573    }
574
575    #[test]
576    fn resolve_unknown_harness_uses_default() {
577        let cfg = ModelConfig::default();
578        // Unknown harness falls through to the `default` built-in map.
579        assert_eq!(
580            resolve_tier_to_model(Tier::High, "junie", &cfg).as_deref(),
581            Some("opus")
582        );
583    }
584
585    #[test]
586    fn user_config_overrides_builtin() {
587        let mut cfg = ModelConfig::default();
588        let mut tiers = BTreeMap::new();
589        tiers.insert(
590            "claude-code".to_string(),
591            TierMap {
592                low: Some("haiku-3".to_string()),
593                med: Some("sonnet-4".to_string()),
594                high: Some("opus-4-1".to_string()),
595            },
596        );
597        cfg.tiers = tiers;
598        assert_eq!(
599            resolve_tier_to_model(Tier::High, "claude-code", &cfg).as_deref(),
600            Some("opus-4-1")
601        );
602    }
603
604    #[test]
605    fn tier_from_model_name_builtin() {
606        let cfg = ModelConfig::default();
607        assert_eq!(
608            tier_from_model_name("opus", "claude-code", &cfg),
609            Some(Tier::High)
610        );
611        assert_eq!(
612            tier_from_model_name("sonnet", "claude-code", &cfg),
613            Some(Tier::Med)
614        );
615        assert_eq!(
616            tier_from_model_name("haiku", "claude-code", &cfg),
617            Some(Tier::Low)
618        );
619        assert_eq!(tier_from_model_name("unknown", "claude-code", &cfg), None);
620    }
621
622    #[test]
623    fn parse_model_arg_tier_name() {
624        let cfg = ModelConfig::default();
625        let (tier, name) = parse_model_arg("high", "claude-code", &cfg).unwrap();
626        assert_eq!(tier, Tier::High);
627        assert_eq!(name, "opus");
628    }
629
630    #[test]
631    fn parse_model_arg_concrete_name() {
632        let cfg = ModelConfig::default();
633        let (tier, name) = parse_model_arg("opus", "claude-code", &cfg).unwrap();
634        assert_eq!(tier, Tier::High);
635        assert_eq!(name, "opus");
636    }
637
638    #[test]
639    fn parse_model_arg_unknown() {
640        let cfg = ModelConfig::default();
641        assert!(parse_model_arg("xyz-3000", "claude-code", &cfg).is_none());
642    }
643
644    #[test]
645    fn parse_model_arg_auto_rejected() {
646        let cfg = ModelConfig::default();
647        assert!(parse_model_arg("auto", "claude-code", &cfg).is_none());
648    }
649
650    #[test]
651    fn extract_model_component_present() {
652        let doc = "# Title\n\n<!-- agent:model -->\nhigh\n<!-- /agent:model -->\n\nbody\n";
653        assert_eq!(extract_model_component(doc).as_deref(), Some("high"));
654    }
655
656    #[test]
657    fn extract_model_component_absent() {
658        let doc = "# Title\n\nbody only\n";
659        assert_eq!(extract_model_component(doc), None);
660    }
661
662    #[test]
663    fn extract_model_component_empty_inner() {
664        let doc = "<!-- agent:model -->\n<!-- /agent:model -->\n";
665        assert_eq!(extract_model_component(doc), None);
666    }
667
668    #[test]
669    fn extract_model_component_concrete_name() {
670        let doc = "<!-- agent:model -->\nopus\n<!-- /agent:model -->\n";
671        assert_eq!(extract_model_component(doc).as_deref(), Some("opus"));
672    }
673
674    #[test]
675    fn component_value_to_tier_tier_name() {
676        let cfg = ModelConfig::default();
677        assert_eq!(
678            component_value_to_tier("high", "claude-code", &cfg),
679            Some(Tier::High)
680        );
681    }
682
683    #[test]
684    fn component_value_to_tier_concrete_name() {
685        let cfg = ModelConfig::default();
686        assert_eq!(
687            component_value_to_tier("opus", "claude-code", &cfg),
688            Some(Tier::High)
689        );
690    }
691
692    #[test]
693    fn component_value_to_tier_unknown() {
694        let cfg = ModelConfig::default();
695        assert_eq!(component_value_to_tier("xyz", "claude-code", &cfg), None);
696    }
697
698    #[test]
699    fn suggested_tier_simple_question() {
700        let path = std::path::Path::new("tasks/research/x.md");
701        assert_eq!(suggested_tier(Some("simple_question"), 1, path), Tier::Low);
702    }
703
704    #[test]
705    fn suggested_tier_small_addition() {
706        let path = std::path::Path::new("tasks/research/x.md");
707        assert_eq!(suggested_tier(Some("content_addition"), 5, path), Tier::Low);
708    }
709
710    #[test]
711    fn suggested_tier_large_addition() {
712        let path = std::path::Path::new("tasks/research/x.md");
713        assert_eq!(suggested_tier(Some("content_addition"), 50, path), Tier::Med);
714    }
715
716    #[test]
717    fn suggested_tier_default_for_unknown() {
718        let path = std::path::Path::new("tasks/research/x.md");
719        assert_eq!(suggested_tier(None, 0, path), Tier::Med);
720    }
721
722    #[test]
723    fn suggested_tier_path_boost_software() {
724        let path = std::path::Path::new("tasks/software/foo.md");
725        // Low gets boosted to Med
726        assert_eq!(
727            suggested_tier(Some("simple_question"), 1, path),
728            Tier::Med
729        );
730        // Med gets boosted to High
731        assert_eq!(
732            suggested_tier(Some("content_addition"), 50, path),
733            Tier::High
734        );
735    }
736
737    #[test]
738    fn suggested_tier_path_boost_caps_at_high() {
739        let path = std::path::Path::new("tasks/software/foo.md");
740        // Already High stays High
741        let t = suggested_tier(Some("content_addition"), 50, path);
742        assert_eq!(t, Tier::High);
743    }
744
745    #[test]
746    fn compose_effective_tier_model_switch_wins() {
747        let t = compose_effective_tier(
748            Some(Tier::High),
749            Some(Tier::Low),
750            Some(Tier::Med),
751            Tier::Low,
752        );
753        assert_eq!(t, Tier::High);
754    }
755
756    #[test]
757    fn compose_effective_tier_component_beats_frontmatter() {
758        let t = compose_effective_tier(None, Some(Tier::High), Some(Tier::Low), Tier::Med);
759        assert_eq!(t, Tier::High);
760    }
761
762    #[test]
763    fn compose_effective_tier_frontmatter_beats_heuristic() {
764        let t = compose_effective_tier(None, None, Some(Tier::High), Tier::Low);
765        assert_eq!(t, Tier::High);
766    }
767
768    #[test]
769    fn compose_effective_tier_falls_through_to_heuristic() {
770        let t = compose_effective_tier(None, None, None, Tier::Med);
771        assert_eq!(t, Tier::Med);
772    }
773
774    #[test]
775    fn scan_model_switch_concrete_name() {
776        let cfg = ModelConfig::default();
777        let diff = "@@ -1,3 +1,4 @@\n context\n+/model opus\n+real edit\n";
778        let result = scan_model_switch(diff, "claude-code", &cfg);
779        assert_eq!(result.model_switch.as_deref(), Some("opus"));
780        assert_eq!(result.model_switch_tier, Some(Tier::High));
781        assert!(!result.stripped_diff.contains("/model opus"));
782        assert!(result.stripped_diff.contains("real edit"));
783    }
784
785    #[test]
786    fn scan_model_switch_tier_name() {
787        let cfg = ModelConfig::default();
788        let diff = "+/model high\n+other line\n";
789        let result = scan_model_switch(diff, "claude-code", &cfg);
790        assert_eq!(result.model_switch_tier, Some(Tier::High));
791        assert_eq!(result.model_switch.as_deref(), Some("opus"));
792        assert!(!result.stripped_diff.contains("/model high"));
793    }
794
795    #[test]
796    fn scan_model_switch_haiku() {
797        let cfg = ModelConfig::default();
798        let diff = "+/model haiku\n";
799        let result = scan_model_switch(diff, "claude-code", &cfg);
800        assert_eq!(result.model_switch_tier, Some(Tier::Low));
801    }
802
803    #[test]
804    fn scan_model_switch_inside_fenced_code_ignored() {
805        let cfg = ModelConfig::default();
806        let diff = "+```\n+/model opus\n+```\n+real line\n";
807        let result = scan_model_switch(diff, "claude-code", &cfg);
808        assert_eq!(result.model_switch, None);
809        assert!(result.stripped_diff.contains("/model opus"));
810    }
811
812    #[test]
813    fn scan_model_switch_inside_blockquote_ignored() {
814        let cfg = ModelConfig::default();
815        let diff = "+> /model opus\n+real line\n";
816        let result = scan_model_switch(diff, "claude-code", &cfg);
817        assert_eq!(result.model_switch, None);
818        assert!(result.stripped_diff.contains("/model opus"));
819    }
820
821    #[test]
822    fn scan_model_switch_only_added_lines() {
823        let cfg = ModelConfig::default();
824        // Context line with /model is NOT a user addition.
825        let diff = " /model opus\n+real line\n";
826        let result = scan_model_switch(diff, "claude-code", &cfg);
827        assert_eq!(result.model_switch, None);
828    }
829
830    #[test]
831    fn scan_model_switch_no_match() {
832        let cfg = ModelConfig::default();
833        let diff = "+just a normal line\n+another\n";
834        let result = scan_model_switch(diff, "claude-code", &cfg);
835        assert_eq!(result.model_switch, None);
836        // Diff is unchanged (modulo trailing newline normalization).
837        assert!(result.stripped_diff.contains("just a normal line"));
838        assert!(result.stripped_diff.contains("another"));
839    }
840
841    #[test]
842    fn scan_model_switch_unknown_arg_still_stripped() {
843        let cfg = ModelConfig::default();
844        // Unknown arg → no tier captured but line still stripped.
845        let diff = "+/model xyz-3000\n+real line\n";
846        let result = scan_model_switch(diff, "claude-code", &cfg);
847        assert_eq!(result.model_switch, None);
848        assert!(!result.stripped_diff.contains("/model xyz-3000"));
849        assert!(result.stripped_diff.contains("real line"));
850    }
851
852    #[test]
853    fn scan_model_switch_first_match_wins() {
854        let cfg = ModelConfig::default();
855        let diff = "+/model opus\n+/model haiku\n";
856        let result = scan_model_switch(diff, "claude-code", &cfg);
857        assert_eq!(result.model_switch.as_deref(), Some("opus"));
858        // Both lines stripped.
859        assert!(!result.stripped_diff.contains("/model"));
860    }
861
862    #[test]
863    fn compose_effective_tier_auto_falls_through() {
864        // Auto values should fall through to next source.
865        let t = compose_effective_tier(
866            Some(Tier::Auto),
867            Some(Tier::Auto),
868            Some(Tier::High),
869            Tier::Low,
870        );
871        assert_eq!(t, Tier::High);
872    }
873}