Skip to main content

mars_agents/models/
mod.rs

1//! Model catalog — aliases with direct model pinning and optional discovery filters,
2//! dependency-tree config merge, and models cache lifecycle.
3//!
4//! Model aliases map short names (opus, sonnet, codex) to concrete model IDs.
5//! Two modes:
6//! - **Pinned**: explicit model ID, with optional `match`/`exclude` discovery filters.
7//! - **AutoResolve**: pattern-based resolution against a cached model catalog.
8//!
9//! Merge precedence: consumer > deps (declaration order).
10
11use std::collections::HashSet;
12use std::path::Path;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use indexmap::IndexMap;
16use serde::{Deserialize, Serialize};
17
18use crate::diagnostic::DiagnosticCollector;
19use crate::error::MarsError;
20
21pub mod availability;
22mod dependencies;
23pub mod harness;
24pub mod harness_model;
25pub mod probes;
26
27pub use availability::ModelAvailability;
28pub(crate) use dependencies::{declaration_ordered_dep_models, merged_model_aliases};
29
30mod tracing {
31    macro_rules! debug {
32        ($($arg:tt)*) => {
33            if cfg!(debug_assertions) {
34                eprintln!($($arg)*);
35            }
36        };
37    }
38
39    pub(super) use debug;
40}
41
42// ---------------------------------------------------------------------------
43// Core types
44// ---------------------------------------------------------------------------
45
46/// A model alias — either pinned to a specific model ID or auto-resolved
47/// against the models cache at resolution time.
48#[derive(Debug, Clone, PartialEq, Serialize)]
49pub struct ModelAlias {
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub harness: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub default_effort: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub autocompact: Option<u32>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub autocompact_pct: Option<u8>,
60    #[serde(flatten)]
61    pub spec: ModelSpec,
62}
63
64impl ModelAlias {
65    pub fn pinned_model_id(&self) -> Option<&str> {
66        match &self.spec {
67            ModelSpec::Pinned { model, .. } | ModelSpec::PinnedWithMatch { model, .. } => {
68                Some(model.as_str())
69            }
70            ModelSpec::AutoResolve { .. } => None,
71        }
72    }
73}
74
75/// How a model alias resolves to a concrete model ID.
76#[derive(Debug, Clone, PartialEq)]
77pub enum ModelSpec {
78    /// Explicit model ID — no resolution needed.
79    Pinned {
80        model: String,
81        provider: Option<String>,
82    },
83    /// Explicit model ID for resolution, plus discovery filters for list/all views.
84    PinnedWithMatch {
85        model: String,
86        provider: Option<String>,
87        match_patterns: Vec<String>,
88        exclude_patterns: Vec<String>,
89    },
90    /// Pattern-based resolution against models cache.
91    AutoResolve {
92        provider: Option<String>,
93        match_patterns: Vec<String>,
94        exclude_patterns: Vec<String>,
95    },
96}
97
98/// How the harness was determined.
99#[derive(Debug, Clone, PartialEq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum HarnessSource {
102    Explicit,
103    AutoDetected,
104    Unavailable,
105}
106
107/// Fully resolved model alias — everything a consumer needs to launch.
108#[derive(Debug, Clone, Serialize)]
109pub struct ResolvedAlias {
110    pub name: String,
111    pub model_id: String,
112    pub provider: String,
113    pub harness: Option<String>,
114    pub harness_source: HarnessSource,
115    pub harness_candidates: Vec<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub description: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub default_effort: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub autocompact: Option<u32>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub autocompact_pct: Option<u8>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub availability: Option<ModelAvailability>,
126}
127
128// Custom Serialize for ModelSpec to flatten into parent
129impl Serialize for ModelSpec {
130    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
131        use serde::ser::SerializeMap;
132        match self {
133            ModelSpec::Pinned { model, provider } => {
134                let mut count = 1;
135                if provider.is_some() {
136                    count += 1;
137                }
138                let mut map = serializer.serialize_map(Some(count))?;
139                map.serialize_entry("model", model)?;
140                if let Some(provider) = provider {
141                    map.serialize_entry("provider", provider)?;
142                }
143                map.end()
144            }
145            ModelSpec::PinnedWithMatch {
146                model,
147                provider,
148                match_patterns,
149                exclude_patterns,
150            } => {
151                let mut count = 2; // model + match
152                if provider.is_some() {
153                    count += 1;
154                }
155                if !exclude_patterns.is_empty() {
156                    count += 1;
157                }
158                let mut map = serializer.serialize_map(Some(count))?;
159                map.serialize_entry("model", model)?;
160                map.serialize_entry("match", match_patterns)?;
161                if let Some(provider) = provider {
162                    map.serialize_entry("provider", provider)?;
163                }
164                if !exclude_patterns.is_empty() {
165                    map.serialize_entry("exclude", exclude_patterns)?;
166                }
167                map.end()
168            }
169            ModelSpec::AutoResolve {
170                provider,
171                match_patterns,
172                exclude_patterns,
173            } => {
174                let mut count = 1; // match
175                if provider.is_some() {
176                    count += 1;
177                }
178                if !exclude_patterns.is_empty() {
179                    count += 1;
180                }
181                let mut map = serializer.serialize_map(Some(count))?;
182                if let Some(provider) = provider {
183                    map.serialize_entry("provider", provider)?;
184                }
185                map.serialize_entry("match", match_patterns)?;
186                if !exclude_patterns.is_empty() {
187                    map.serialize_entry("exclude", exclude_patterns)?;
188                }
189                map.end()
190            }
191        }
192    }
193}
194
195/// Raw deserialization helper — distinguished by field presence.
196#[derive(Debug, Deserialize)]
197struct RawModelAlias {
198    harness: Option<String>,
199    #[serde(default)]
200    description: Option<String>,
201    #[serde(default)]
202    native: Option<toml::Value>,
203    #[serde(default)]
204    default_effort: Option<String>,
205    #[serde(default)]
206    autocompact: Option<toml::Value>,
207    #[serde(default)]
208    autocompact_pct: Option<toml::Value>,
209    // Pinned mode
210    #[serde(default)]
211    model: Option<String>,
212    // AutoResolve mode
213    #[serde(default)]
214    provider: Option<String>,
215    #[serde(default, rename = "match")]
216    match_patterns: Option<Vec<String>>,
217    #[serde(default)]
218    exclude: Option<Vec<String>>,
219}
220
221impl<'de> Deserialize<'de> for ModelAlias {
222    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
223        let raw = RawModelAlias::deserialize(deserializer)?;
224        let normalized_harness = if let Some(ref harness_name) = raw.harness {
225            Some(
226                harness::normalize_harness_name(harness_name).ok_or_else(|| {
227                    serde::de::Error::custom(format!(
228                        "invalid harness '{harness_name}'; valid harnesses: {}",
229                        harness::VALID_HARNESSES.join(", ")
230                    ))
231                })?,
232            )
233        } else {
234            None
235        };
236        if raw.native.is_some() {
237            return Err(serde::de::Error::custom(
238                "[models.<alias>.native] is no longer supported; Cursor model adaptation is internal",
239            ));
240        }
241        let default_effort = raw.default_effort.filter(|value| !value.trim().is_empty());
242        if let Some(ref effort) = default_effort {
243            const VALID_EFFORTS: &[&str] = &["low", "medium", "high", "xhigh", "auto"];
244            if !VALID_EFFORTS.contains(&effort.as_str()) {
245                return Err(serde::de::Error::custom(format!(
246                    "invalid default_effort '{effort}'; accepted values: {}",
247                    VALID_EFFORTS.join(", ")
248                )));
249            }
250        }
251        let autocompact: Option<u32> = match raw.autocompact {
252            Some(toml::Value::Integer(value)) => match u32::try_from(value) {
253                Ok(v) => Some(v),
254                Err(_) => {
255                    return Err(serde::de::Error::custom(format!(
256                        "autocompact {value} is out of u32 range (0–4294967295)"
257                    )));
258                }
259            },
260            Some(other) => {
261                return Err(serde::de::Error::custom(format!(
262                    "autocompact must be an integer (token count), got {other:?}"
263                )));
264            }
265            None => None,
266        };
267        let autocompact_pct: Option<u8> = match raw.autocompact_pct {
268            Some(toml::Value::Integer(value)) if (1..=100).contains(&value) => Some(value as u8),
269            Some(toml::Value::Integer(value)) => {
270                return Err(serde::de::Error::custom(format!(
271                    "autocompact_pct {value} is out of range 1-100"
272                )));
273            }
274            Some(other) => {
275                return Err(serde::de::Error::custom(format!(
276                    "autocompact_pct must be an integer 1-100, got {other:?}"
277                )));
278            }
279            None => None,
280        };
281
282        let has_match = raw.match_patterns.is_some();
283
284        let spec = if let Some(model) = raw.model {
285            if !has_match && raw.exclude.is_some() {
286                return Err(serde::de::Error::custom(
287                    "model alias with 'exclude' must also include 'match'",
288                ));
289            }
290            if let Some(match_patterns) = raw.match_patterns {
291                ModelSpec::PinnedWithMatch {
292                    model,
293                    provider: raw.provider,
294                    match_patterns,
295                    exclude_patterns: raw.exclude.unwrap_or_default(),
296                }
297            } else {
298                ModelSpec::Pinned {
299                    model,
300                    provider: raw.provider,
301                }
302            }
303        } else if let Some(match_patterns) = raw.match_patterns {
304            ModelSpec::AutoResolve {
305                provider: raw.provider,
306                match_patterns,
307                exclude_patterns: raw.exclude.unwrap_or_default(),
308            }
309        } else {
310            return Err(serde::de::Error::custom(
311                "model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
312            ));
313        };
314
315        Ok(ModelAlias {
316            harness: normalized_harness,
317            description: raw.description,
318            default_effort,
319            autocompact,
320            autocompact_pct,
321            spec,
322        })
323    }
324}
325
326// ---------------------------------------------------------------------------
327// Models cache
328// ---------------------------------------------------------------------------
329
330/// Cached model catalog from external API.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ModelsCache {
333    pub models: Vec<CachedModel>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub fetched_at: Option<String>,
336}
337
338/// A single model entry in the cache.
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct CachedModel {
341    pub id: String,
342    pub provider: String,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub release_date: Option<String>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub description: Option<String>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub context_window: Option<u64>,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub max_output: Option<u64>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub cost_input: Option<f64>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub cost_output: Option<f64>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub cost_cache_read: Option<f64>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub cost_cache_write: Option<f64>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub cost_reasoning: Option<f64>,
361}
362
363/// Provider/model slugs from the models.dev catalog for harness routing comparisons.
364pub fn catalog_model_slugs(cache: &ModelsCache) -> Vec<String> {
365    cache
366        .models
367        .iter()
368        .map(|model| {
369            format!(
370                "{}/{}",
371                crate::routing::slug::normalize_provider(&model.provider),
372                model.id
373            )
374        })
375        .collect()
376}
377
378const CACHE_FILE: &str = "models-cache.json";
379const FETCH_FAIL_MARKER_FILE: &str = ".models-cache.last-fail";
380pub(crate) const FETCH_FAIL_COOLDOWN_SECS: u64 = 300;
381const FETCH_FAIL_COOLDOWN_REASON: &str = "recent fetch attempt failed; backing off (cooldown)";
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum RefreshMode {
385    Auto,
386    Force,
387    Offline,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub enum RefreshOutcome {
392    AlreadyFresh,
393    Refreshed { models_count: usize },
394    StaleFallback { reason: String },
395    Offline,
396}
397
398pub fn now_unix_secs_value() -> u64 {
399    SystemTime::now()
400        .duration_since(UNIX_EPOCH)
401        .unwrap_or_default()
402        .as_secs()
403}
404
405pub fn now_unix_secs() -> String {
406    now_unix_secs_value().to_string()
407}
408
409pub fn is_mars_offline() -> bool {
410    match std::env::var("MARS_OFFLINE") {
411        Ok(value) => matches!(
412            value.trim().to_ascii_lowercase().as_str(),
413            "1" | "true" | "yes"
414        ),
415        Err(_) => false,
416    }
417}
418
419pub fn resolve_refresh_mode(no_refresh_flag: bool) -> RefreshMode {
420    resolve_models_refresh_control(false, no_refresh_flag)
421        .expect("refresh and no-refresh are mutually exclusive")
422        .catalog_mode
423}
424
425/// Catalog + harness probe refresh intent from CLI flags.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub struct ModelsRefreshControl {
428    pub catalog_mode: RefreshMode,
429    pub probe_refresh: crate::models::probes::ProbeRefreshMode,
430}
431
432impl ModelsRefreshControl {
433    pub fn auto() -> Self {
434        Self {
435            catalog_mode: RefreshMode::Auto,
436            probe_refresh: crate::models::probes::ProbeRefreshMode::Background,
437        }
438    }
439}
440
441pub fn resolve_models_refresh_control(
442    refresh_models: bool,
443    no_refresh_models: bool,
444) -> Result<ModelsRefreshControl, crate::error::MarsError> {
445    use crate::error::ConfigError;
446    use crate::models::probes::ProbeRefreshMode;
447
448    if refresh_models && no_refresh_models {
449        return Err(crate::error::MarsError::Config(ConfigError::Invalid {
450            message: "--refresh-models and --no-refresh-models cannot be used together".to_string(),
451        }));
452    }
453
454    Ok(if no_refresh_models {
455        ModelsRefreshControl {
456            catalog_mode: RefreshMode::Offline,
457            probe_refresh: ProbeRefreshMode::Skip,
458        }
459    } else if refresh_models {
460        ModelsRefreshControl {
461            catalog_mode: RefreshMode::Force,
462            probe_refresh: ProbeRefreshMode::Synchronous,
463        }
464    } else {
465        ModelsRefreshControl::auto()
466    })
467}
468
469pub fn dependency_alias_snapshot(deps: &[ResolvedDepModels]) -> IndexMap<String, ModelAlias> {
470    let mut merged = IndexMap::new();
471    for dep in deps {
472        for (name, alias) in &dep.models {
473            if !merged.contains_key(name) {
474                merged.insert(name.clone(), alias.clone());
475            }
476        }
477    }
478    merged
479}
480
481pub fn merged_runtime_aliases(
482    dependency_aliases: &IndexMap<String, ModelAlias>,
483    project_aliases: Option<&IndexMap<String, ModelAlias>>,
484) -> IndexMap<String, ModelAlias> {
485    let has_project_aliases = project_aliases.is_some_and(|aliases| !aliases.is_empty());
486    let mut merged = if dependency_aliases.is_empty() && !has_project_aliases {
487        builtin_aliases()
488    } else {
489        IndexMap::new()
490    };
491    for (name, alias) in dependency_aliases {
492        merged.insert(name.clone(), alias.clone());
493    }
494    if let Some(project_aliases) = project_aliases {
495        for (name, alias) in project_aliases {
496            merged.insert(name.clone(), alias.clone());
497        }
498    }
499    merged
500}
501
502fn read_cache_tolerant(mars_dir: &Path) -> ModelsCache {
503    match read_cache(mars_dir) {
504        Ok(cache) => cache,
505        Err(err) => {
506            tracing::debug!("models cache read failed, treating as empty: {err}");
507            ModelsCache {
508                models: Vec::new(),
509                fetched_at: None,
510            }
511        }
512    }
513}
514
515fn is_fresh(cache: &ModelsCache, ttl_hours: u32) -> bool {
516    if ttl_hours == 0 {
517        return false;
518    }
519    if cache.models.is_empty() {
520        return false;
521    }
522
523    let Some(fetched_str) = &cache.fetched_at else {
524        return false;
525    };
526    let Ok(fetched) = fetched_str.parse::<u64>() else {
527        return false;
528    };
529
530    let now = now_unix_secs_value();
531    if fetched > now {
532        return false;
533    }
534
535    (now - fetched) < (ttl_hours as u64) * 3600
536}
537
538fn is_usable(cache: &ModelsCache) -> bool {
539    !cache.models.is_empty()
540}
541
542fn read_fetch_fail_marker(mars_dir: &Path) -> Option<u64> {
543    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
544    let raw = std::fs::read_to_string(marker).ok()?;
545    raw.trim().parse::<u64>().ok()
546}
547
548fn write_fetch_fail_marker(mars_dir: &Path, timestamp: u64) {
549    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
550    if let Err(err) = crate::fs::atomic_write(&marker, timestamp.to_string().as_bytes()) {
551        tracing::debug!("failed to write models fetch failure marker: {err}");
552    }
553}
554
555fn clear_fetch_fail_marker(mars_dir: &Path) {
556    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
557    if let Err(err) = std::fs::remove_file(marker)
558        && err.kind() != std::io::ErrorKind::NotFound
559    {
560        tracing::debug!("failed to clear models fetch failure marker: {err}");
561    }
562}
563
564pub fn ensure_fresh(
565    mars_dir: &Path,
566    ttl_hours: u32,
567    mode: RefreshMode,
568) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
569    ensure_fresh_with_fetcher(mars_dir, ttl_hours, mode, fetch_models)
570}
571
572fn ensure_fresh_with_fetcher<F>(
573    mars_dir: &Path,
574    ttl_hours: u32,
575    mode: RefreshMode,
576    fetcher: F,
577) -> Result<(ModelsCache, RefreshOutcome), MarsError>
578where
579    F: FnOnce() -> Result<Vec<CachedModel>, MarsError>,
580{
581    std::fs::create_dir_all(mars_dir)?;
582
583    // D1: apply MARS_OFFLINE coercion exactly once here.
584    let effective_mode = match mode {
585        RefreshMode::Auto if is_mars_offline() => RefreshMode::Offline,
586        m => m,
587    };
588
589    let prior = read_cache_tolerant(mars_dir);
590
591    if effective_mode == RefreshMode::Auto && is_fresh(&prior, ttl_hours) {
592        return Ok((prior, RefreshOutcome::AlreadyFresh));
593    }
594
595    if effective_mode == RefreshMode::Offline {
596        if is_usable(&prior) {
597            return Ok((prior, RefreshOutcome::Offline));
598        }
599        return Err(MarsError::ModelCacheUnavailable {
600            reason: offline_unavailable_reason(mode),
601        });
602    }
603
604    let lock_path = mars_dir.join(".models-cache.lock");
605    let _guard = crate::fs::FileLock::acquire(&lock_path)?;
606
607    let under_lock = read_cache_tolerant(mars_dir);
608    if effective_mode == RefreshMode::Auto && is_fresh(&under_lock, ttl_hours) {
609        return Ok((under_lock, RefreshOutcome::AlreadyFresh));
610    }
611
612    if mode != RefreshMode::Force && is_usable(&under_lock) {
613        let now = now_unix_secs_value();
614        if let Some(last_fail) = read_fetch_fail_marker(mars_dir)
615            && now.saturating_sub(last_fail) < FETCH_FAIL_COOLDOWN_SECS
616        {
617            return Ok((
618                under_lock,
619                RefreshOutcome::StaleFallback {
620                    reason: FETCH_FAIL_COOLDOWN_REASON.to_string(),
621                },
622            ));
623        }
624    }
625
626    match fetcher() {
627        Ok(models) if !models.is_empty() => {
628            let models_count = models.len();
629            let cache = ModelsCache {
630                models,
631                fetched_at: Some(now_unix_secs()),
632            };
633            write_cache(mars_dir, &cache)?;
634            clear_fetch_fail_marker(mars_dir);
635            Ok((cache, RefreshOutcome::Refreshed { models_count }))
636        }
637        Ok(_) => fallback_to_stale_or_error(
638            mars_dir,
639            under_lock,
640            "API returned empty catalog".to_string(),
641            "API returned an empty catalog and no prior cache exists".to_string(),
642            true,
643        ),
644        Err(err) => fallback_to_stale_or_error(
645            mars_dir,
646            under_lock,
647            format!("fetch failed: {err}"),
648            format!("automatic refresh failed: {err}"),
649            true,
650        ),
651    }
652}
653
654fn fallback_to_stale_or_error(
655    mars_dir: &Path,
656    under_lock: ModelsCache,
657    stale_reason: String,
658    unavailable_reason: String,
659    mark_fetch_failure: bool,
660) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
661    if is_usable(&under_lock) {
662        if mark_fetch_failure {
663            write_fetch_fail_marker(mars_dir, now_unix_secs_value());
664        }
665        Ok((
666            under_lock,
667            RefreshOutcome::StaleFallback {
668                reason: stale_reason,
669            },
670        ))
671    } else {
672        Err(MarsError::ModelCacheUnavailable {
673            reason: unavailable_reason,
674        })
675    }
676}
677
678fn offline_unavailable_reason(requested_mode: RefreshMode) -> String {
679    match requested_mode {
680        RefreshMode::Offline => {
681            "--no-refresh-models was passed and no cached catalog is available".to_string()
682        }
683        RefreshMode::Auto => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
684        RefreshMode::Force => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
685    }
686}
687
688/// Read models cache from `.mars/models-cache.json`.
689pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
690    let path = mars_dir.join(CACHE_FILE);
691    match std::fs::read_to_string(&path) {
692        Ok(content) => {
693            let cache: ModelsCache =
694                serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
695                    message: format!("failed to parse models cache: {e}"),
696                })?;
697            Ok(cache)
698        }
699        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
700            models: Vec::new(),
701            fetched_at: None,
702        }),
703        Err(source) => Err(MarsError::Io {
704            operation: "read models cache".to_string(),
705            path,
706            source,
707        }),
708    }
709}
710
711/// Write models cache to `.mars/models-cache.json` (atomic via tmp+rename).
712pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
713    std::fs::create_dir_all(mars_dir)?;
714    let path = mars_dir.join(CACHE_FILE);
715    let tmp_path = mars_dir.join(".models-cache.json.tmp");
716    let content =
717        serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
718            message: format!("failed to serialize models cache: {e}"),
719        })?;
720    std::fs::write(&tmp_path, content)?;
721    std::fs::rename(&tmp_path, &path)?;
722    Ok(())
723}
724
725/// Fetch models from the models.dev API.
726///
727/// Returns a list of cached model entries. On network failure, returns an error
728/// (callers should fall back to existing cache or explicit pinned IDs).
729pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
730    let url = models_api_url();
731    let agent: ureq::Agent = ureq::Agent::config_builder()
732        .timeout_connect(Some(Duration::from_secs(15)))
733        .timeout_recv_response(Some(Duration::from_secs(15)))
734        .timeout_recv_body(Some(Duration::from_secs(15)))
735        .build()
736        .into();
737
738    let response = agent.get(&url).call().map_err(|e| match e {
739        ureq::Error::StatusCode(status) => MarsError::Http {
740            url: url.clone(),
741            status,
742            message: format!("request failed with HTTP status {status}"),
743        },
744        _ => MarsError::Http {
745            url: url.clone(),
746            status: 0,
747            message: format!("failed to fetch models catalog: {e}"),
748        },
749    })?;
750    let body = response
751        .into_body()
752        .read_to_string()
753        .map_err(|e| MarsError::Http {
754            url: url.clone(),
755            status: 0,
756            message: format!("failed to read response body: {e}"),
757        })?;
758    let raw: serde_json::Value =
759        serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
760            message: format!("failed to parse models API response: {e}"),
761        })?;
762
763    parse_models_dev_catalog(&raw)
764}
765
766fn models_api_url() -> String {
767    std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
768}
769
770fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
771    let providers = raw
772        .as_object()
773        .ok_or_else(|| crate::error::ConfigError::Invalid {
774            message: "models API response must be an object keyed by provider".to_string(),
775        })?;
776
777    let mut models = Vec::new();
778
779    for (provider_key, provider_obj) in providers {
780        if !is_major_provider(provider_key) {
781            continue;
782        }
783
784        let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
785            continue;
786        };
787
788        for model_obj in provider_models.values() {
789            let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
790                continue;
791            };
792            let release_date = model_obj
793                .get("release_date")
794                .and_then(|v| v.as_str())
795                .map(str::to_string);
796            let description = model_obj
797                .get("name")
798                .and_then(|v| v.as_str())
799                .map(str::to_string);
800            let context_window = model_obj
801                .get("limit")
802                .and_then(|v| v.get("context"))
803                .and_then(|v| v.as_u64());
804            let max_output = model_obj
805                .get("limit")
806                .and_then(|v| v.get("output"))
807                .and_then(|v| v.as_u64());
808            let cost = model_obj.get("cost");
809            let cost_input = cost.and_then(|v| v.get("input")).and_then(|v| v.as_f64());
810            let cost_output = cost.and_then(|v| v.get("output")).and_then(|v| v.as_f64());
811            let cost_cache_read = cost
812                .and_then(|v| v.get("cache_read"))
813                .and_then(|v| v.as_f64());
814            let cost_cache_write = cost
815                .and_then(|v| v.get("cache_write"))
816                .and_then(|v| v.as_f64());
817            let cost_reasoning = cost
818                .and_then(|v| v.get("reasoning"))
819                .and_then(|v| v.as_f64());
820
821            models.push(CachedModel {
822                id: model_id.to_string(),
823                provider: normalize_provider(provider_key),
824                release_date,
825                description,
826                context_window,
827                max_output,
828                cost_input,
829                cost_output,
830                cost_cache_read,
831                cost_cache_write,
832                cost_reasoning,
833            });
834        }
835    }
836
837    Ok(models)
838}
839
840fn is_major_provider(provider_key: &str) -> bool {
841    matches!(
842        provider_key,
843        "anthropic"
844            | "openai"
845            | "google"
846            | "meta-llama"
847            | "meta"
848            | "mistralai"
849            | "mistral"
850            | "deepseek"
851            | "cohere"
852    )
853}
854
855/// Normalize models.dev provider keys to canonical names.
856fn normalize_provider(slug: &str) -> String {
857    match slug {
858        "anthropic" => "Anthropic".to_string(),
859        "openai" => "OpenAI".to_string(),
860        "google" => "Google".to_string(),
861        "meta-llama" | "meta" => "Meta".to_string(),
862        "mistralai" | "mistral" => "Mistral".to_string(),
863        "deepseek" => "DeepSeek".to_string(),
864        "cohere" => "Cohere".to_string(),
865        _ => slug.to_string(),
866    }
867}
868
869// ---------------------------------------------------------------------------
870// Auto-resolve algorithm
871// ---------------------------------------------------------------------------
872
873/// Resolve an auto-resolve spec against the models cache.
874///
875/// Algorithm:
876/// 1. Filter by provider (case-insensitive) when specified
877/// 2. All match patterns must hit (AND)
878/// 3. No exclude patterns may hit (OR)
879/// 4. Skip entries ending with `-latest` (synthetic aliases)
880/// 5. Sort by newest release_date, then shortest ID, then lexical ID
881/// 6. Return all candidates
882pub fn auto_resolve_all<'a>(
883    provider: Option<&str>,
884    match_patterns: &[String],
885    exclude_patterns: &[String],
886    cache: &'a ModelsCache,
887) -> Vec<&'a CachedModel> {
888    let mut candidates: Vec<&CachedModel> = cache
889        .models
890        .iter()
891        .filter(|m| {
892            // Provider match (case-insensitive) — skip filter when provider is None
893            provider.is_none_or(|p| m.provider.eq_ignore_ascii_case(p))
894        })
895        .filter(|m| {
896            // Skip -latest suffix (synthetic aliases)
897            !m.id.ends_with("-latest")
898        })
899        .filter(|m| {
900            // All match patterns must hit (AND)
901            match_patterns.iter().all(|p| glob_match(p, &m.id))
902        })
903        .filter(|m| {
904            // No exclude patterns may hit (OR)
905            !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
906        })
907        .collect();
908
909    // Sort: newest release_date first, then shortest ID, then lexical ID.
910    candidates.sort_by(|a, b| {
911        let date_cmp = b
912            .release_date
913            .as_deref()
914            .unwrap_or("")
915            .cmp(a.release_date.as_deref().unwrap_or(""));
916        date_cmp
917            .then_with(|| a.id.len().cmp(&b.id.len()))
918            .then_with(|| a.id.cmp(&b.id))
919    });
920
921    candidates
922}
923
924/// Resolve an auto-resolve spec against the models cache.
925///
926/// Algorithm:
927/// 1. Filter by provider (case-insensitive) when specified
928/// 2. All match patterns must hit (AND)
929/// 3. No exclude patterns may hit (OR)
930/// 4. Skip entries ending with `-latest` (synthetic aliases)
931/// 5. Sort by newest release_date, then shortest ID, then lexical ID
932/// 6. Pick first
933pub fn auto_resolve(
934    provider: Option<&str>,
935    match_patterns: &[String],
936    exclude_patterns: &[String],
937    cache: &ModelsCache,
938) -> Option<String> {
939    auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
940        .first()
941        .map(|model| model.id.clone())
942}
943
944/// Resolve an input like `opus-4-6` by matching it against alias filter candidates.
945///
946/// Algorithm:
947/// 1. Build a glob pattern `*{input}*` from the user input
948/// 2. For each auto-resolve alias, run its filters against the cache
949/// 3. From those candidates, keep models matching the glob
950/// 4. Collect union across aliases, deduplicated by model ID
951/// 5. Sort by newest release_date, then shortest ID
952/// 6. Return the best candidate
953pub fn resolve_with_alias_prefix(
954    input: &str,
955    aliases: &IndexMap<String, ModelAlias>,
956    cache: &ModelsCache,
957) -> Option<ResolvedAlias> {
958    let opencode_probe = probes::opencode_cache::read_cached_probe_result_usable();
959    let cursor_probe = probes::cursor_cache::read_cached_probe_result_usable();
960    resolve_with_alias_prefix_with_probe(
961        input,
962        aliases,
963        cache,
964        opencode_probe.as_ref(),
965        None,
966        cursor_probe.as_ref(),
967    )
968}
969
970pub fn resolve_with_alias_prefix_with_probe(
971    input: &str,
972    aliases: &IndexMap<String, ModelAlias>,
973    cache: &ModelsCache,
974    opencode_probe: Option<&probes::OpenCodeProbeResult>,
975    pi_probe: Option<&probes::PiProbeResult>,
976    cursor_probe: Option<&probes::CursorProbeResult>,
977) -> Option<ResolvedAlias> {
978    let pattern = if input.contains('*') {
979        input.to_string()
980    } else {
981        format!("*{}*", input)
982    };
983    let base_alias = alias_prefix_base(input, aliases);
984    let mut deduped: IndexMap<String, CachedModel> = IndexMap::new();
985
986    if let Some(alias) = base_alias
987        && let Some((model, provider)) = match &alias.spec {
988            ModelSpec::Pinned { model, provider } => Some((model, provider)),
989            ModelSpec::PinnedWithMatch {
990                model, provider, ..
991            } => Some((model, provider)),
992            ModelSpec::AutoResolve { .. } => None,
993        }
994    {
995        let provider_filter = provider
996            .as_deref()
997            .or_else(|| infer_provider_from_model_id(model));
998        for candidate in &cache.models {
999            if !glob_match(&pattern, &candidate.id) {
1000                continue;
1001            }
1002            if let Some(provider_filter) = provider_filter
1003                && !candidate.provider.eq_ignore_ascii_case(provider_filter)
1004            {
1005                continue;
1006            }
1007            deduped
1008                .entry(candidate.id.clone())
1009                .or_insert_with(|| candidate.clone());
1010        }
1011    }
1012
1013    for (_alias_name, alias) in aliases {
1014        match &alias.spec {
1015            ModelSpec::AutoResolve {
1016                provider,
1017                match_patterns,
1018                exclude_patterns,
1019            } => {
1020                for candidate in
1021                    auto_resolve_all(provider.as_deref(), match_patterns, exclude_patterns, cache)
1022                {
1023                    if glob_match(&pattern, &candidate.id) {
1024                        deduped
1025                            .entry(candidate.id.clone())
1026                            .or_insert_with(|| candidate.clone());
1027                    }
1028                }
1029            }
1030            ModelSpec::PinnedWithMatch {
1031                model,
1032                provider,
1033                match_patterns,
1034                exclude_patterns,
1035            } => {
1036                let provider = provider
1037                    .as_deref()
1038                    .or_else(|| infer_provider_from_model_id(model));
1039                for candidate in auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
1040                {
1041                    if glob_match(&pattern, &candidate.id) {
1042                        deduped
1043                            .entry(candidate.id.clone())
1044                            .or_insert_with(|| candidate.clone());
1045                    }
1046                }
1047            }
1048            ModelSpec::Pinned { .. } => {}
1049        }
1050    }
1051
1052    let mut candidates: Vec<CachedModel> = deduped.into_values().collect();
1053    candidates.sort_by(|a, b| {
1054        let date_cmp = b
1055            .release_date
1056            .as_deref()
1057            .unwrap_or("")
1058            .cmp(a.release_date.as_deref().unwrap_or(""));
1059        date_cmp
1060            .then_with(|| a.id.len().cmp(&b.id.len()))
1061            .then_with(|| a.id.cmp(&b.id))
1062    });
1063
1064    let winner = candidates.into_iter().next()?;
1065    let provider = winner.provider.to_ascii_lowercase();
1066    let (default_effort, autocompact, autocompact_pct) = match base_alias {
1067        Some(ModelAlias {
1068            default_effort,
1069            autocompact,
1070            autocompact_pct,
1071            spec: ModelSpec::Pinned { .. } | ModelSpec::PinnedWithMatch { .. },
1072            ..
1073        }) => (default_effort.clone(), *autocompact, *autocompact_pct),
1074        _ => (None, None, None),
1075    };
1076    let installed = harness::detect_installed_harnesses();
1077    let catalog_slugs = catalog_model_slugs(cache);
1078    let default_harness_order = crate::harness::registry::default_harness_order_names();
1079    let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
1080        model_id: &winner.id,
1081        provider_for_order: Some(&provider),
1082        provider_constraint: None,
1083        settings_provider_order: None,
1084        settings_harness_order: Some(default_harness_order.as_slice()),
1085        config_default_harness: None,
1086        installed_harnesses: &installed,
1087        linked_harnesses: None,
1088        opencode_probe_result: opencode_probe,
1089        pi_probe_result: pi_probe,
1090        cursor_probe_result: cursor_probe,
1091        catalog_model_slugs: Some(catalog_slugs.as_slice()),
1092    });
1093    let (harness, harness_source) = match crate::routing::acceptance::accept_route(
1094        &trace,
1095        &installed,
1096        crate::routing::acceptance::MatchPolicy::InstalledOnly,
1097    ) {
1098        Ok(()) => (Some(trace.harness), HarnessSource::AutoDetected),
1099        Err(_) => (None, HarnessSource::Unavailable),
1100    };
1101
1102    Some(ResolvedAlias {
1103        name: input.to_string(),
1104        model_id: winner.id,
1105        provider: provider.clone(),
1106        harness,
1107        harness_source,
1108        harness_candidates: harness::harness_candidates_for_provider(&provider),
1109        description: winner.description,
1110        default_effort,
1111        autocompact,
1112        autocompact_pct,
1113        availability: None,
1114    })
1115}
1116
1117fn alias_prefix_base<'a>(
1118    input: &str,
1119    aliases: &'a IndexMap<String, ModelAlias>,
1120) -> Option<&'a ModelAlias> {
1121    aliases
1122        .iter()
1123        .filter(|(name, _)| {
1124            !name.is_empty()
1125                && input.len() > name.len()
1126                && input.starts_with(name.as_str())
1127                && input.as_bytes().get(name.len()) == Some(&b'-')
1128        })
1129        .max_by_key(|(name, _)| name.len())
1130        .map(|(_, alias)| alias)
1131}
1132
1133/// Simple glob matching: `*` matches any sequence of characters.
1134/// Everything else is literal. Case-sensitive.
1135pub fn glob_match(pattern: &str, text: &str) -> bool {
1136    // Split pattern on '*' and match segments in order
1137    let segments: Vec<&str> = pattern.split('*').collect();
1138
1139    if segments.len() == 1 {
1140        // No wildcards — exact match
1141        return pattern == text;
1142    }
1143
1144    let mut pos = 0;
1145
1146    // First segment must be a prefix
1147    if let Some(first) = segments.first()
1148        && !first.is_empty()
1149    {
1150        if !text.starts_with(first) {
1151            return false;
1152        }
1153        pos = first.len();
1154    }
1155
1156    // Last segment must be a suffix
1157    if let Some(last) = segments.last()
1158        && !last.is_empty()
1159        && !text[pos..].ends_with(last)
1160    {
1161        return false;
1162    }
1163
1164    // Middle segments must appear in order
1165    let end = if let Some(last) = segments.last() {
1166        if !last.is_empty() {
1167            text.len() - last.len()
1168        } else {
1169            text.len()
1170        }
1171    } else {
1172        text.len()
1173    };
1174
1175    for segment in &segments[1..segments.len().saturating_sub(1)] {
1176        if segment.is_empty() {
1177            continue;
1178        }
1179        if let Some(idx) = text[pos..end].find(segment) {
1180            pos += idx + segment.len();
1181        } else {
1182            return false;
1183        }
1184    }
1185
1186    pos <= end
1187}
1188
1189/// Match a visibility pattern against a resolved model identity.
1190///
1191/// Pattern forms:
1192/// - 0 slashes: bare model ID, e.g. `gpt-5*`
1193/// - 1 slash: provider/model, e.g. `anthropic/*`
1194/// - 2 slashes: OpenCode runnable slug, e.g. `openrouter/anthropic/*`
1195pub fn matches_visibility_pattern(
1196    pattern: &str,
1197    model_id: &str,
1198    provider: &str,
1199    runnable_paths: &[availability::RunnablePath],
1200) -> bool {
1201    let pattern = pattern.to_ascii_lowercase();
1202    let slash_count = pattern.chars().filter(|c| *c == '/').count();
1203
1204    match slash_count {
1205        0 => glob_match_no_slash(&pattern, &model_id.to_ascii_lowercase()),
1206        1 => {
1207            let candidate = format!(
1208                "{}/{}",
1209                provider.to_ascii_lowercase(),
1210                model_id.to_ascii_lowercase()
1211            );
1212            glob_match_no_slash(&pattern, &candidate)
1213        }
1214        2 => runnable_paths
1215            .iter()
1216            .any(|path| glob_match_no_slash(&pattern, &path.harness_model_id.to_ascii_lowercase())),
1217        _ => false,
1218    }
1219}
1220
1221fn glob_match_no_slash(pattern: &str, text: &str) -> bool {
1222    let pattern_parts: Vec<&str> = pattern.split('*').collect();
1223    if pattern_parts.len() == 1 {
1224        return pattern == text;
1225    }
1226
1227    let mut pos = 0;
1228    for (i, part) in pattern_parts.iter().enumerate() {
1229        if part.is_empty() {
1230            continue;
1231        }
1232        let Some(found) = text[pos..].find(part) else {
1233            return false;
1234        };
1235        if i == 0 && found != 0 {
1236            return false;
1237        }
1238        if text[pos..pos + found].contains('/') {
1239            return false;
1240        }
1241        pos += found + part.len();
1242    }
1243
1244    if pattern.ends_with('*') {
1245        !text[pos..].contains('/')
1246    } else {
1247        pos == text.len()
1248    }
1249}
1250
1251// ---------------------------------------------------------------------------
1252// Builtin aliases — bare convenience mappings, no descriptions
1253// ---------------------------------------------------------------------------
1254
1255/// Minimal builtin aliases so common model names work out of the box.
1256/// Suppressed as soon as consumer or dependency aliases exist.
1257pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
1258    let mut m = IndexMap::new();
1259    let add = |m: &mut IndexMap<String, ModelAlias>,
1260               name: &str,
1261               provider: &str,
1262               match_patterns: &[&str],
1263               exclude: &[&str]| {
1264        m.insert(
1265            name.to_string(),
1266            ModelAlias {
1267                harness: None,
1268                description: None,
1269                default_effort: None,
1270                autocompact: None,
1271                autocompact_pct: None,
1272                spec: ModelSpec::AutoResolve {
1273                    provider: Some(provider.to_string()),
1274                    match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1275                    exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
1276                },
1277            },
1278        );
1279    };
1280    add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
1281    add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
1282    add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
1283    add(
1284        &mut m,
1285        "codex",
1286        "openai",
1287        &["*codex*"],
1288        &["*-mini", "*-spark", "*-max"],
1289    );
1290    add(
1291        &mut m,
1292        "gpt",
1293        "openai",
1294        &["gpt-5*"],
1295        &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
1296    );
1297    add(
1298        &mut m,
1299        "gemini",
1300        "google",
1301        &["gemini*", "*pro*"],
1302        &["*-customtools"],
1303    );
1304    m
1305}
1306
1307// ---------------------------------------------------------------------------
1308// Dependency-tree merge
1309// ---------------------------------------------------------------------------
1310
1311/// Info about a resolved dependency's model config.
1312pub struct ResolvedDepModels {
1313    pub source_name: String,
1314    pub models: IndexMap<String, ModelAlias>,
1315}
1316
1317/// Merge model aliases from dependency tree.
1318///
1319/// Precedence: consumer > deps (declaration order).
1320/// Builtins appear only when consumer and dependency aliases are both empty.
1321/// When two deps define the same alias, first in declaration order wins
1322/// with a diagnostic warning.
1323pub fn merge_model_config(
1324    consumer: &IndexMap<String, ModelAlias>,
1325    deps: &[ResolvedDepModels],
1326    diag: &mut DiagnosticCollector,
1327    cache: Option<&ModelsCache>,
1328) -> IndexMap<String, ModelAlias> {
1329    #[derive(Clone)]
1330    struct DepWinner {
1331        source_name: String,
1332        alias: ModelAlias,
1333    }
1334
1335    let has_dep_aliases = deps.iter().any(|dep| !dep.models.is_empty());
1336    let mut merged = if consumer.is_empty() && !has_dep_aliases {
1337        builtin_aliases()
1338    } else {
1339        IndexMap::new()
1340    };
1341
1342    // Track which dep won each alias
1343    let mut dep_provided: std::collections::HashMap<String, DepWinner> =
1344        std::collections::HashMap::new();
1345
1346    // Dependencies: first dep wins on conflicts
1347    for dep in deps {
1348        for (name, alias) in &dep.models {
1349            if consumer.contains_key(name) {
1350                // Consumer will override — skip dep's version silently
1351                continue;
1352            }
1353            if let Some(winner) = dep_provided.get(name) {
1354                // Two deps define same alias — first dep wins, warn
1355                let message = if let Some(cache) = cache {
1356                    let (winner_formatted, winner_model_id) =
1357                        format_alias_resolution_for_diag(&winner.alias, &winner.source_name, cache);
1358                    let (loser_formatted, loser_model_id) =
1359                        format_alias_resolution_for_diag(alias, &dep.source_name, cache);
1360                    if winner_model_id.is_some() && winner_model_id == loser_model_id {
1361                        format!(
1362                            "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  both resolve to {}\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1363                            winner.source_name,
1364                            dep.source_name,
1365                            winner.source_name,
1366                            winner_model_id.unwrap_or_default(),
1367                        )
1368                    } else {
1369                        format!(
1370                            "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  {winner_formatted}, {loser_formatted}\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1371                            winner.source_name, dep.source_name, winner.source_name,
1372                        )
1373                    }
1374                } else {
1375                    format!(
1376                        "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1377                        winner.source_name, dep.source_name, winner.source_name,
1378                    )
1379                };
1380                diag.warn_with_context("model-alias-conflict", message, dep.source_name.clone());
1381            } else {
1382                merged.insert(name.clone(), alias.clone());
1383                dep_provided.insert(
1384                    name.clone(),
1385                    DepWinner {
1386                        source_name: dep.source_name.clone(),
1387                        alias: alias.clone(),
1388                    },
1389                );
1390            }
1391        }
1392    }
1393
1394    // Consumer config overrides dependency aliases.
1395    for (name, alias) in consumer {
1396        merged.insert(name.clone(), alias.clone());
1397    }
1398
1399    merged
1400}
1401
1402/// Resolve all aliases to concrete model IDs + harnesses.
1403///
1404/// Harness detection is encapsulated — callers don't pass installed harnesses.
1405pub fn resolve_all(
1406    aliases: &IndexMap<String, ModelAlias>,
1407    cache: &ModelsCache,
1408    diag: &mut DiagnosticCollector,
1409) -> IndexMap<String, ResolvedAlias> {
1410    let opencode_probe = probes::opencode_cache::read_cached_probe_result_usable();
1411    let cursor_probe = probes::cursor_cache::read_cached_probe_result_usable();
1412    resolve_all_with_probe(
1413        aliases,
1414        cache,
1415        diag,
1416        opencode_probe.as_ref(),
1417        None,
1418        cursor_probe.as_ref(),
1419    )
1420}
1421
1422/// Resolve aliases without any harness detection or probe/auth checks.
1423///
1424/// This is intended for static list views where only alias -> model/provider
1425/// resolution is needed.
1426pub fn resolve_all_static(
1427    aliases: &IndexMap<String, ModelAlias>,
1428    cache: &ModelsCache,
1429) -> IndexMap<String, ResolvedAlias> {
1430    let mut resolved = IndexMap::new();
1431
1432    for (name, alias) in aliases {
1433        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
1434            continue; // unresolvable — omit
1435        };
1436
1437        resolved.insert(
1438            name.clone(),
1439            ResolvedAlias {
1440                name: name.clone(),
1441                model_id,
1442                provider,
1443                harness: None,
1444                harness_source: HarnessSource::Unavailable,
1445                harness_candidates: Vec::new(),
1446                description: alias.description.clone(),
1447                default_effort: alias.default_effort.clone(),
1448                autocompact: alias.autocompact,
1449                autocompact_pct: alias.autocompact_pct,
1450                availability: None,
1451            },
1452        );
1453    }
1454
1455    resolved
1456}
1457
1458pub fn resolve_all_with_probe(
1459    aliases: &IndexMap<String, ModelAlias>,
1460    cache: &ModelsCache,
1461    diag: &mut DiagnosticCollector,
1462    opencode_probe: Option<&probes::OpenCodeProbeResult>,
1463    pi_probe: Option<&probes::PiProbeResult>,
1464    cursor_probe: Option<&probes::CursorProbeResult>,
1465) -> IndexMap<String, ResolvedAlias> {
1466    let _ = diag;
1467    let installed = harness::detect_installed_harnesses();
1468    let mut resolved = IndexMap::new();
1469
1470    for (name, alias) in aliases {
1471        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
1472            continue; // unresolvable — omit
1473        };
1474
1475        let candidates = harness::harness_candidates_for_provider(&provider);
1476        let (h, source) = resolve_harness(
1477            alias,
1478            &provider,
1479            &model_id,
1480            &installed,
1481            opencode_probe,
1482            pi_probe,
1483            cursor_probe,
1484        );
1485
1486        resolved.insert(
1487            name.clone(),
1488            ResolvedAlias {
1489                name: name.clone(),
1490                model_id,
1491                provider,
1492                harness: h,
1493                harness_source: source,
1494                harness_candidates: candidates,
1495                description: alias.description.clone(),
1496                default_effort: alias.default_effort.clone(),
1497                autocompact: alias.autocompact,
1498                autocompact_pct: alias.autocompact_pct,
1499                availability: None,
1500            },
1501        );
1502    }
1503
1504    resolved
1505}
1506
1507/// Resolve a single alias and emit diagnostics only for that alias.
1508pub fn resolve_one(
1509    name: &str,
1510    aliases: &IndexMap<String, ModelAlias>,
1511    cache: &ModelsCache,
1512    diag: &mut DiagnosticCollector,
1513) -> Option<ResolvedAlias> {
1514    let opencode_probe = probes::opencode_cache::read_cached_probe_result_usable();
1515    let cursor_probe = probes::cursor_cache::read_cached_probe_result_usable();
1516    resolve_one_with_probe(
1517        name,
1518        aliases,
1519        cache,
1520        diag,
1521        opencode_probe.as_ref(),
1522        None,
1523        cursor_probe.as_ref(),
1524    )
1525}
1526
1527pub fn resolve_one_with_probe(
1528    name: &str,
1529    aliases: &IndexMap<String, ModelAlias>,
1530    cache: &ModelsCache,
1531    diag: &mut DiagnosticCollector,
1532    opencode_probe: Option<&probes::OpenCodeProbeResult>,
1533    pi_probe: Option<&probes::PiProbeResult>,
1534    cursor_probe: Option<&probes::CursorProbeResult>,
1535) -> Option<ResolvedAlias> {
1536    let alias = aliases.get(name)?;
1537    let installed = harness::detect_installed_harnesses();
1538    let (model_id, provider) = resolve_model_and_provider(alias, cache)?;
1539    let candidates = harness::harness_candidates_for_provider(&provider);
1540    let (harness, harness_source) = resolve_harness(
1541        alias,
1542        &provider,
1543        &model_id,
1544        &installed,
1545        opencode_probe,
1546        pi_probe,
1547        cursor_probe,
1548    );
1549    let _ = diag;
1550    Some(ResolvedAlias {
1551        name: name.to_string(),
1552        model_id,
1553        provider,
1554        harness,
1555        harness_source,
1556        harness_candidates: candidates,
1557        description: alias.description.clone(),
1558        default_effort: alias.default_effort.clone(),
1559        autocompact: alias.autocompact,
1560        autocompact_pct: alias.autocompact_pct,
1561        availability: None,
1562    })
1563}
1564
1565/// Resolve a concrete model id for one alias.
1566///
1567/// Used by build-time launch routing so model resolution logic stays shared
1568/// with `mars models resolve`.
1569pub fn resolve_model_id_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1570    resolve_model_and_provider(alias, cache).map(|(model_id, _provider)| model_id)
1571}
1572
1573/// Resolve provider identity for one alias.
1574///
1575/// Returns `None` when provider cannot be inferred.
1576pub fn resolve_provider_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1577    let provider = resolve_model_and_provider(alias, cache)
1578        .map(|(_model_id, provider)| provider)
1579        .or_else(|| provider_from_alias_spec(alias));
1580
1581    provider.filter(|value| !value.eq_ignore_ascii_case("unknown"))
1582}
1583
1584/// Filter resolved aliases by visibility config.
1585/// - `include` patterns: keep only aliases where at least one pattern matches
1586/// - `exclude` patterns: remove aliases where any pattern matches
1587/// - No config (both None): return all aliases unchanged
1588pub fn filter_by_visibility(
1589    mut aliases: IndexMap<String, ResolvedAlias>,
1590    visibility: &crate::config::ModelVisibility,
1591) -> IndexMap<String, ResolvedAlias> {
1592    let include = visibility
1593        .include
1594        .as_ref()
1595        .filter(|patterns| !patterns.is_empty());
1596    let exclude = visibility
1597        .exclude
1598        .as_ref()
1599        .filter(|patterns| !patterns.is_empty());
1600
1601    if include.is_none() && exclude.is_none() {
1602        return aliases;
1603    }
1604
1605    if let Some(includes) = include {
1606        aliases.retain(|_, alias| {
1607            let paths = alias
1608                .availability
1609                .as_ref()
1610                .map(|availability| availability.runnable_paths.as_slice())
1611                .unwrap_or(&[]);
1612            includes.iter().any(|pattern| {
1613                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1614            })
1615        });
1616    }
1617
1618    if let Some(excludes) = exclude {
1619        aliases.retain(|_, alias| {
1620            let paths = alias
1621                .availability
1622                .as_ref()
1623                .map(|availability| availability.runnable_paths.as_slice())
1624                .unwrap_or(&[]);
1625            !excludes.iter().any(|pattern| {
1626                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1627            })
1628        });
1629    }
1630    aliases
1631}
1632
1633fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
1634    match &alias.spec {
1635        ModelSpec::Pinned {
1636            model, provider, ..
1637        } => {
1638            let p = provider
1639                .clone()
1640                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1641                .unwrap_or_else(|| "unknown".to_string());
1642            Some((model.clone(), p))
1643        }
1644        ModelSpec::PinnedWithMatch {
1645            model, provider, ..
1646        } => {
1647            let p = provider
1648                .clone()
1649                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1650                .unwrap_or_else(|| "unknown".to_string());
1651            Some((model.clone(), p))
1652        }
1653        ModelSpec::AutoResolve {
1654            provider,
1655            match_patterns,
1656            exclude_patterns,
1657        } => {
1658            let model_id =
1659                auto_resolve(provider.as_deref(), match_patterns, exclude_patterns, cache)?;
1660            // When provider is known from the alias, use it; otherwise look up
1661            // the resolved model's provider in the cache.
1662            let resolved_provider = provider
1663                .clone()
1664                .or_else(|| {
1665                    cache
1666                        .models
1667                        .iter()
1668                        .find(|m| m.id == model_id)
1669                        .map(|m| m.provider.clone())
1670                })
1671                .unwrap_or_else(|| "unknown".to_string());
1672            Some((model_id, resolved_provider))
1673        }
1674    }
1675}
1676
1677fn provider_from_alias_spec(alias: &ModelAlias) -> Option<String> {
1678    match &alias.spec {
1679        ModelSpec::Pinned { model, provider }
1680        | ModelSpec::PinnedWithMatch {
1681            model, provider, ..
1682        } => provider
1683            .clone()
1684            .or_else(|| infer_provider_from_model_id(model).map(str::to_string)),
1685        ModelSpec::AutoResolve { provider, .. } => provider.clone(),
1686    }
1687}
1688
1689fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
1690    match &alias.spec {
1691        ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
1692            provider.clone()
1693        }
1694        ModelSpec::AutoResolve { provider, .. } => provider.clone(),
1695    }
1696    .map(|provider| provider.trim().to_ascii_lowercase())
1697}
1698
1699fn format_alias_resolution_for_diag(
1700    alias: &ModelAlias,
1701    source_name: &str,
1702    cache: &ModelsCache,
1703) -> (String, Option<String>) {
1704    match &alias.spec {
1705        ModelSpec::Pinned { model, .. } => (
1706            format!("{source_name} → {model} (pinned)"),
1707            Some(model.clone()),
1708        ),
1709        ModelSpec::PinnedWithMatch { model, .. } => (
1710            format!("{source_name} → {model} (pinned+match)"),
1711            Some(model.clone()),
1712        ),
1713        ModelSpec::AutoResolve {
1714            provider,
1715            match_patterns,
1716            exclude_patterns,
1717        } => {
1718            let resolved =
1719                auto_resolve(provider.as_deref(), match_patterns, exclude_patterns, cache);
1720            match resolved {
1721                Some(model_id) => (format!("{source_name} → {model_id}"), Some(model_id)),
1722                None => (format!("{source_name} → <unresolvable>"), None),
1723            }
1724        }
1725    }
1726}
1727
1728fn resolve_harness(
1729    alias: &ModelAlias,
1730    provider: &str,
1731    model_id: &str,
1732    installed: &HashSet<String>,
1733    opencode_probe_result: Option<&probes::OpenCodeProbeResult>,
1734    pi_probe_result: Option<&probes::PiProbeResult>,
1735    cursor_probe_result: Option<&probes::CursorProbeResult>,
1736) -> (Option<String>, HarnessSource) {
1737    if let Some(h) = &alias.harness {
1738        if installed.contains(h) {
1739            (Some(h.clone()), HarnessSource::Explicit)
1740        } else {
1741            (Some(h.clone()), HarnessSource::Unavailable)
1742        }
1743    } else {
1744        let provider_constraint = provider_constraint_for_alias(alias);
1745        let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
1746            model_id,
1747            provider_for_order: Some(provider),
1748            provider_constraint: provider_constraint.as_deref(),
1749            settings_provider_order: None,
1750            settings_harness_order: None,
1751            config_default_harness: None,
1752            installed_harnesses: installed,
1753            linked_harnesses: None,
1754            opencode_probe_result,
1755            pi_probe_result,
1756            cursor_probe_result,
1757            catalog_model_slugs: None,
1758        });
1759        match crate::routing::acceptance::accept_route(
1760            &trace,
1761            installed,
1762            crate::routing::acceptance::MatchPolicy::InstalledOnly,
1763        ) {
1764            Ok(()) => (Some(trace.harness), HarnessSource::AutoDetected),
1765            Err(_) => (None, HarnessSource::Unavailable),
1766        }
1767    }
1768}
1769
1770/// Best-effort provider inference from model ID prefixes.
1771/// Returns None for unrecognized patterns.
1772pub fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
1773    let id = model_id.to_lowercase();
1774    if id.starts_with("claude-") {
1775        return Some("anthropic");
1776    }
1777    if id.starts_with("gpt-")
1778        || id.starts_with("o1")
1779        || id.starts_with("o3")
1780        || id.starts_with("o4")
1781        || id.starts_with("codex-")
1782    {
1783        return Some("openai");
1784    }
1785    if id.starts_with("gemini") {
1786        return Some("google");
1787    }
1788    if id.starts_with("llama") {
1789        return Some("meta");
1790    }
1791    if id.starts_with("mistral") || id.starts_with("codestral") {
1792        return Some("mistral");
1793    }
1794    if id.starts_with("deepseek") {
1795        return Some("deepseek");
1796    }
1797    if id.starts_with("command") {
1798        return Some("cohere");
1799    }
1800    None
1801}
1802
1803/// Split a token shaped like `provider/model` into `(model, provider_constraint)`.
1804///
1805/// Returns `(trimmed_token, None)` when the token is not a valid constrained slug.
1806pub fn split_provider_constrained_model_token(token: &str) -> (String, Option<String>) {
1807    let trimmed = token.trim();
1808    let Some((provider, model_name)) = trimmed.split_once('/') else {
1809        return (trimmed.to_string(), None);
1810    };
1811    let provider = provider.trim();
1812    let model_name = model_name.trim();
1813    if provider.is_empty() || model_name.is_empty() {
1814        return (trimmed.to_string(), None);
1815    }
1816    (model_name.to_string(), Some(provider.to_ascii_lowercase()))
1817}
1818
1819// ---------------------------------------------------------------------------
1820// Tests
1821// ---------------------------------------------------------------------------
1822
1823#[cfg(test)]
1824mod tests {
1825    use super::*;
1826    use httpmock::prelude::*;
1827    use std::sync::atomic::{AtomicUsize, Ordering};
1828    use std::sync::{Arc, mpsc};
1829    use std::thread;
1830    use tempfile::tempdir;
1831
1832    use serial_test::serial;
1833
1834    #[test]
1835    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
1836        let raw = serde_json::json!({
1837            "anthropic": {
1838                "models": {
1839                    "claude-opus-4-6": {
1840                        "id": "claude-opus-4-6",
1841                        "name": "Claude Opus 4.6",
1842                        "release_date": "2026-02-05",
1843                        "limit": {
1844                            "context": 1000000,
1845                            "output": 128000
1846                        },
1847                        "cost": {
1848                            "input": 5.0,
1849                            "output": 25.0,
1850                            "cache_read": 0.5,
1851                            "cache_write": 6.25,
1852                            "reasoning": 15.0
1853                        }
1854                    }
1855                }
1856            },
1857            "openai": {
1858                "models": {
1859                    "gpt-5": {
1860                        "id": "gpt-5",
1861                        "name": "GPT-5"
1862                    }
1863                }
1864            },
1865            "random-host": {
1866                "models": {
1867                    "foo": {
1868                        "id": "foo"
1869                    }
1870                }
1871            }
1872        });
1873
1874        let models = parse_models_dev_catalog(&raw).unwrap();
1875        assert_eq!(models.len(), 2);
1876
1877        let opus = models
1878            .iter()
1879            .find(|m| m.id == "claude-opus-4-6")
1880            .expect("missing claude-opus-4-6");
1881        assert_eq!(opus.provider, "Anthropic");
1882        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1883        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1884        assert_eq!(opus.context_window, Some(1_000_000));
1885        assert_eq!(opus.max_output, Some(128_000));
1886        assert_eq!(opus.cost_input, Some(5.0));
1887        assert_eq!(opus.cost_output, Some(25.0));
1888        assert_eq!(opus.cost_cache_read, Some(0.5));
1889        assert_eq!(opus.cost_cache_write, Some(6.25));
1890        assert_eq!(opus.cost_reasoning, Some(15.0));
1891
1892        let gpt = models
1893            .iter()
1894            .find(|m| m.id == "gpt-5")
1895            .expect("missing gpt-5");
1896        assert_eq!(gpt.provider, "OpenAI");
1897        assert_eq!(gpt.release_date, None);
1898        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1899        assert_eq!(gpt.context_window, None);
1900        assert_eq!(gpt.max_output, None);
1901        assert_eq!(gpt.cost_input, None);
1902        assert_eq!(gpt.cost_output, None);
1903        assert_eq!(gpt.cost_cache_read, None);
1904        assert_eq!(gpt.cost_cache_write, None);
1905        assert_eq!(gpt.cost_reasoning, None);
1906    }
1907
1908    #[test]
1909    fn parse_models_dev_catalog_requires_object_root() {
1910        let raw = serde_json::json!(["not", "an", "object"]);
1911        let err = parse_models_dev_catalog(&raw).unwrap_err();
1912        assert!(err.to_string().contains("keyed by provider"));
1913    }
1914
1915    // -- glob_match tests --
1916
1917    #[test]
1918    fn glob_exact_match() {
1919        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1920        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1921    }
1922
1923    #[test]
1924    fn glob_star_suffix() {
1925        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1926        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1927        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1928    }
1929
1930    #[test]
1931    fn glob_star_prefix() {
1932        assert!(glob_match("*-opus-4", "claude-opus-4"));
1933        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1934    }
1935
1936    #[test]
1937    fn glob_star_middle() {
1938        assert!(glob_match("claude-*-4", "claude-opus-4"));
1939        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1940        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1941    }
1942
1943    #[test]
1944    fn glob_multiple_stars() {
1945        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1946        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1947        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1948    }
1949
1950    #[test]
1951    fn glob_star_only() {
1952        assert!(glob_match("*", "anything"));
1953        assert!(glob_match("*", ""));
1954    }
1955
1956    #[test]
1957    fn glob_empty_pattern() {
1958        assert!(glob_match("", ""));
1959        assert!(!glob_match("", "something"));
1960    }
1961
1962    // -- auto_resolve tests --
1963
1964    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1965        ModelsCache {
1966            models: models
1967                .into_iter()
1968                .map(|(id, provider, date)| CachedModel {
1969                    id: id.to_string(),
1970                    provider: provider.to_string(),
1971                    release_date: date.map(String::from),
1972                    description: None,
1973                    context_window: None,
1974                    max_output: None,
1975                    cost_input: None,
1976                    cost_output: None,
1977                    cost_cache_read: None,
1978                    cost_cache_write: None,
1979                    cost_reasoning: None,
1980                })
1981                .collect(),
1982            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1983        }
1984    }
1985
1986    #[test]
1987    fn auto_resolve_basic() {
1988        let cache = make_cache(vec![
1989            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1990            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1991            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1992        ]);
1993
1994        let result = auto_resolve(
1995            Some("Anthropic"),
1996            &["claude-opus-*".to_string()],
1997            &[],
1998            &cache,
1999        );
2000        // Newest date wins
2001        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
2002    }
2003
2004    #[test]
2005    fn auto_resolve_exclude() {
2006        let cache = make_cache(vec![
2007            ("gpt-5", "OpenAI", Some("2025-06-01")),
2008            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
2009            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
2010        ]);
2011
2012        let result = auto_resolve(
2013            Some("OpenAI"),
2014            &["gpt-*".to_string()],
2015            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
2016            &cache,
2017        );
2018        assert_eq!(result, Some("gpt-5".to_string()));
2019    }
2020
2021    #[test]
2022    fn auto_resolve_skip_latest() {
2023        let cache = make_cache(vec![
2024            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
2025            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
2026        ]);
2027
2028        let result = auto_resolve(
2029            Some("Anthropic"),
2030            &["claude-opus-*".to_string()],
2031            &[],
2032            &cache,
2033        );
2034        // Should skip -latest even though it has a newer date
2035        assert_eq!(result, Some("claude-opus-4".to_string()));
2036    }
2037
2038    #[test]
2039    fn auto_resolve_empty_cache() {
2040        let cache = ModelsCache {
2041            models: Vec::new(),
2042            fetched_at: None,
2043        };
2044
2045        let result = auto_resolve(
2046            Some("Anthropic"),
2047            &["claude-opus-*".to_string()],
2048            &[],
2049            &cache,
2050        );
2051        assert_eq!(result, None);
2052    }
2053
2054    #[test]
2055    fn auto_resolve_no_match() {
2056        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
2057
2058        let result = auto_resolve(Some("OpenAI"), &["gpt-*".to_string()], &[], &cache);
2059        assert_eq!(result, None);
2060    }
2061
2062    #[test]
2063    fn auto_resolve_provider_case_insensitive() {
2064        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
2065
2066        let result = auto_resolve(
2067            Some("anthropic"),
2068            &["claude-opus-*".to_string()],
2069            &[],
2070            &cache,
2071        );
2072        assert_eq!(result, Some("claude-opus-4".to_string()));
2073    }
2074
2075    #[test]
2076    fn auto_resolve_shortest_id_tiebreaker() {
2077        let cache = make_cache(vec![
2078            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
2079            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
2080        ]);
2081
2082        let result = auto_resolve(
2083            Some("Anthropic"),
2084            &["claude-opus-*".to_string()],
2085            &[],
2086            &cache,
2087        );
2088        // Same date — shorter ID wins
2089        assert_eq!(result, Some("claude-opus-4".to_string()));
2090    }
2091
2092    #[test]
2093    fn auto_resolve_lexical_id_tiebreaker_when_date_and_length_equal() {
2094        let cache = make_cache(vec![
2095            ("claude-opus-4-b", "Anthropic", Some("2025-03-01")),
2096            ("claude-opus-4-a", "Anthropic", Some("2025-03-01")),
2097        ]);
2098
2099        let result = auto_resolve(
2100            Some("Anthropic"),
2101            &["claude-opus-4-*".to_string()],
2102            &[],
2103            &cache,
2104        );
2105        // Same date + same length — lexical ID wins for deterministic ordering.
2106        assert_eq!(result, Some("claude-opus-4-a".to_string()));
2107    }
2108
2109    #[test]
2110    fn auto_resolve_all_returns_all_candidates() {
2111        let cache = make_cache(vec![
2112            ("claude-opus-4-5", "Anthropic", Some("2025-12-01")),
2113            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
2114            ("claude-opus-4-6-long", "Anthropic", Some("2026-02-05")),
2115            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2116            ("claude-opus-3", "Anthropic", Some("2024-02-05")),
2117        ]);
2118
2119        let result = auto_resolve_all(
2120            Some("Anthropic"),
2121            &["claude-opus-*".to_string()],
2122            &["*opus-3".to_string()],
2123            &cache,
2124        );
2125        let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
2126        assert_eq!(
2127            ids,
2128            vec!["claude-opus-4-6", "claude-opus-4-6-long", "claude-opus-4-5"]
2129        );
2130    }
2131
2132    // -- merge_model_config tests --
2133
2134    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
2135        ModelAlias {
2136            harness: harness.map(|h| h.to_string()),
2137            description: None,
2138            default_effort: None,
2139            autocompact: None,
2140            autocompact_pct: None,
2141            spec: ModelSpec::Pinned {
2142                model: model.to_string(),
2143                provider: None,
2144            },
2145        }
2146    }
2147
2148    fn auto_alias(
2149        provider: &str,
2150        match_patterns: &[&str],
2151        exclude_patterns: &[&str],
2152    ) -> ModelAlias {
2153        ModelAlias {
2154            harness: None,
2155            description: None,
2156            default_effort: None,
2157            autocompact: None,
2158            autocompact_pct: None,
2159            spec: ModelSpec::AutoResolve {
2160                provider: Some(provider.to_string()),
2161                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
2162                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
2163            },
2164        }
2165    }
2166
2167    fn pinned_match_alias(
2168        model: &str,
2169        provider: &str,
2170        match_patterns: &[&str],
2171        exclude_patterns: &[&str],
2172    ) -> ModelAlias {
2173        ModelAlias {
2174            harness: None,
2175            description: None,
2176            default_effort: None,
2177            autocompact: None,
2178            autocompact_pct: None,
2179            spec: ModelSpec::PinnedWithMatch {
2180                model: model.to_string(),
2181                provider: Some(provider.to_string()),
2182                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
2183                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
2184            },
2185        }
2186    }
2187
2188    #[test]
2189    fn resolve_with_alias_prefix_basic() {
2190        let aliases = builtin_aliases();
2191        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
2192
2193        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
2194        assert_eq!(resolved.name, "opus-4-6");
2195        assert_eq!(resolved.model_id, "claude-opus-4-6");
2196        assert_eq!(resolved.provider, "anthropic");
2197        assert_eq!(
2198            resolved.harness_candidates,
2199            vec!["claude", "codex", "pi", "cursor", "opencode"]
2200        );
2201
2202        let installed = harness::detect_installed_harnesses();
2203        let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
2204            model_id: "claude-opus-4-6",
2205            provider_for_order: Some("anthropic"),
2206            provider_constraint: None,
2207            settings_provider_order: None,
2208            settings_harness_order: None,
2209            config_default_harness: None,
2210            installed_harnesses: &installed,
2211            linked_harnesses: None,
2212            opencode_probe_result: None,
2213            pi_probe_result: None,
2214            cursor_probe_result: None,
2215            catalog_model_slugs: None,
2216        });
2217        let (expected_harness, expected_source) = if installed.contains(&trace.harness) {
2218            (Some(trace.harness), HarnessSource::AutoDetected)
2219        } else {
2220            (None, HarnessSource::Unavailable)
2221        };
2222        assert_eq!(resolved.harness, expected_harness);
2223        assert_eq!(resolved.harness_source, expected_source);
2224    }
2225
2226    #[test]
2227    fn resolve_with_alias_prefix_no_candidates() {
2228        let aliases = builtin_aliases();
2229        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
2230
2231        let resolved = resolve_with_alias_prefix("opus-9-9", &aliases, &cache);
2232        assert!(resolved.is_none());
2233    }
2234
2235    #[test]
2236    fn resolve_with_alias_prefix_picks_newest() {
2237        let aliases = builtin_aliases();
2238        let cache = make_cache(vec![
2239            ("claude-opus-4-6-20250101", "Anthropic", Some("2025-01-01")),
2240            ("claude-opus-4-6-20260101", "Anthropic", Some("2026-01-01")),
2241        ]);
2242
2243        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
2244        assert_eq!(resolved.model_id, "claude-opus-4-6-20260101");
2245    }
2246
2247    #[test]
2248    fn resolve_with_alias_prefix_lexical_id_tiebreaker_when_date_and_length_equal() {
2249        let aliases = builtin_aliases();
2250        let cache = make_cache(vec![
2251            ("claude-opus-4-b", "Anthropic", Some("2026-02-05")),
2252            ("claude-opus-4-a", "Anthropic", Some("2026-02-05")),
2253        ]);
2254
2255        let resolved = resolve_with_alias_prefix("opus-4-", &aliases, &cache).unwrap();
2256        assert_eq!(resolved.model_id, "claude-opus-4-a");
2257    }
2258
2259    #[test]
2260    fn resolve_with_alias_prefix_pinned_base_inherits_defaults() {
2261        let mut aliases = IndexMap::new();
2262        let mut alias = pinned_alias(Some("claude"), "claude-opus-4-6");
2263        alias.default_effort = Some("high".to_string());
2264        alias.autocompact = Some(42);
2265        aliases.insert("opus".to_string(), alias);
2266        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
2267
2268        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
2269        assert_eq!(resolved.model_id, "claude-opus-4-7");
2270        assert_eq!(resolved.default_effort.as_deref(), Some("high"));
2271        assert_eq!(resolved.autocompact, Some(42));
2272    }
2273
2274    #[test]
2275    fn resolve_with_alias_prefix_auto_base_does_not_inherit_defaults() {
2276        let mut aliases = IndexMap::new();
2277        let mut alias = auto_alias("anthropic", &["claude-opus-*"], &[]);
2278        alias.default_effort = Some("high".to_string());
2279        alias.autocompact = Some(42);
2280        aliases.insert("opus".to_string(), alias);
2281        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
2282
2283        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
2284        assert_eq!(resolved.model_id, "claude-opus-4-7");
2285        assert_eq!(resolved.default_effort, None);
2286        assert_eq!(resolved.autocompact, None);
2287    }
2288
2289    #[test]
2290    fn resolve_with_alias_prefix_exact_name_matches() {
2291        // When the input equals an alias name, this function still finds matches
2292        // via glob *opus*. The caller (run_resolve) handles exact alias lookup
2293        // before calling this function, so this path is only reached for
2294        // non-alias inputs in practice.
2295        let aliases = builtin_aliases();
2296        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
2297
2298        let resolved = resolve_with_alias_prefix("opus", &aliases, &cache);
2299        assert!(resolved.is_some());
2300        assert_eq!(resolved.unwrap().model_id, "claude-opus-4-6");
2301    }
2302
2303    #[test]
2304    fn resolve_with_alias_prefix_multiple_aliases_union() {
2305        let mut aliases = IndexMap::new();
2306        aliases.insert(
2307            "g".to_string(),
2308            auto_alias("openai", &["gpt-2026-08*"], &[]),
2309        );
2310        aliases.insert(
2311            "gpt".to_string(),
2312            auto_alias("openai", &["gpt-2026-03*"], &[]),
2313        );
2314        let cache = make_cache(vec![
2315            ("gpt-2026-03-01", "OpenAI", Some("2026-03-01")),
2316            ("gpt-2026-08-07", "OpenAI", Some("2026-08-07")),
2317        ]);
2318
2319        let resolved = resolve_with_alias_prefix("gpt-2026", &aliases, &cache).unwrap();
2320        assert_eq!(resolved.model_id, "gpt-2026-08-07");
2321    }
2322
2323    #[test]
2324    fn merge_empty_returns_builtins() {
2325        let mut diag = DiagnosticCollector::new();
2326        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag, None);
2327        // Empty consumer + no deps = builtins only
2328        assert!(merged.contains_key("opus"));
2329        assert!(merged.contains_key("sonnet"));
2330        assert!(merged.contains_key("codex"));
2331    }
2332
2333    #[test]
2334    fn merge_consumer_aliases_suppress_builtins() {
2335        let mut consumer = IndexMap::new();
2336        consumer.insert(
2337            "opus".to_string(),
2338            pinned_alias(Some("custom"), "my-opus-model"),
2339        );
2340
2341        let mut diag = DiagnosticCollector::new();
2342        let merged = merge_model_config(&consumer, &[], &mut diag, None);
2343        assert_eq!(
2344            merged.get("opus").unwrap().spec,
2345            ModelSpec::Pinned {
2346                model: "my-opus-model".to_string(),
2347                provider: None
2348            }
2349        );
2350        assert!(!merged.contains_key("sonnet"));
2351        assert!(!merged.contains_key("codex"));
2352    }
2353
2354    #[test]
2355    fn merge_dependency_aliases_suppress_builtins() {
2356        let dep = ResolvedDepModels {
2357            source_name: "my-pkg".to_string(),
2358            models: {
2359                let mut m = IndexMap::new();
2360                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
2361                m
2362            },
2363        };
2364
2365        let mut diag = DiagnosticCollector::new();
2366        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag, None);
2367        assert_eq!(
2368            merged.get("opus").unwrap().spec,
2369            ModelSpec::Pinned {
2370                model: "pkg-opus".to_string(),
2371                provider: None
2372            }
2373        );
2374        assert!(!merged.contains_key("sonnet"));
2375        assert!(!merged.contains_key("codex"));
2376    }
2377
2378    #[test]
2379    fn merge_consumer_beats_dep() {
2380        let mut consumer = IndexMap::new();
2381        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
2382
2383        let dep = ResolvedDepModels {
2384            source_name: "pkg".to_string(),
2385            models: {
2386                let mut m = IndexMap::new();
2387                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
2388                m
2389            },
2390        };
2391
2392        let mut diag = DiagnosticCollector::new();
2393        let merged = merge_model_config(&consumer, &[dep], &mut diag, None);
2394        assert_eq!(
2395            merged.get("opus").unwrap().spec,
2396            ModelSpec::Pinned {
2397                model: "consumer-opus".to_string(),
2398                provider: None
2399            }
2400        );
2401    }
2402
2403    #[test]
2404    fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
2405        let dep1 = ResolvedDepModels {
2406            source_name: "pkg-a".to_string(),
2407            models: {
2408                let mut m = IndexMap::new();
2409                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2410                m
2411            },
2412        };
2413        let dep2 = ResolvedDepModels {
2414            source_name: "pkg-b".to_string(),
2415            models: {
2416                let mut m = IndexMap::new();
2417                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2418                m
2419            },
2420        };
2421
2422        let mut diag = DiagnosticCollector::new();
2423        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2424        // First dep wins
2425        assert_eq!(
2426            merged.get("custom").unwrap().spec,
2427            ModelSpec::Pinned {
2428                model: "model-a".to_string(),
2429                provider: None
2430            }
2431        );
2432        // Should have warned
2433        let warnings = diag.drain();
2434        assert_eq!(warnings.len(), 1);
2435        assert_eq!(warnings[0].code, "model-alias-conflict");
2436        assert_eq!(
2437            warnings[0].message,
2438            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2439        );
2440    }
2441
2442    #[test]
2443    fn merge_dep_conflict_with_cache_shows_resolution_diff() {
2444        let cache = make_cache(vec![
2445            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2446            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2447        ]);
2448        let dep1 = ResolvedDepModels {
2449            source_name: "dep-a".to_string(),
2450            models: {
2451                let mut m = IndexMap::new();
2452                m.insert(
2453                    "opus".to_string(),
2454                    pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2455                );
2456                m
2457            },
2458        };
2459        let dep2 = ResolvedDepModels {
2460            source_name: "dep-b".to_string(),
2461            models: {
2462                let mut m = IndexMap::new();
2463                m.insert(
2464                    "opus".to_string(),
2465                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2466                );
2467                m
2468            },
2469        };
2470
2471        let mut diag = DiagnosticCollector::new();
2472        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2473        let warnings = diag.drain();
2474        assert_eq!(warnings.len(), 1);
2475        let message = &warnings[0].message;
2476        assert!(message.contains("dep-a → claude-opus-4-6 (pinned+match)"));
2477        assert!(message.contains("dep-b → claude-opus-4-7 (pinned+match)"));
2478    }
2479
2480    #[test]
2481    fn merge_dep_conflict_with_cache_same_resolution() {
2482        let cache = make_cache(vec![
2483            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2484            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2485        ]);
2486        let dep1 = ResolvedDepModels {
2487            source_name: "dep-a".to_string(),
2488            models: {
2489                let mut m = IndexMap::new();
2490                m.insert(
2491                    "opus".to_string(),
2492                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2493                );
2494                m
2495            },
2496        };
2497        let dep2 = ResolvedDepModels {
2498            source_name: "dep-b".to_string(),
2499            models: {
2500                let mut m = IndexMap::new();
2501                m.insert(
2502                    "opus".to_string(),
2503                    auto_alias("Anthropic", &["claude-opus-*"], &[]),
2504                );
2505                m
2506            },
2507        };
2508
2509        let mut diag = DiagnosticCollector::new();
2510        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2511        let warnings = diag.drain();
2512        assert_eq!(warnings.len(), 1);
2513        assert!(
2514            warnings[0]
2515                .message
2516                .contains("both resolve to claude-opus-4-7")
2517        );
2518    }
2519
2520    #[test]
2521    fn merge_dep_conflict_without_cache_uses_old_format() {
2522        let dep1 = ResolvedDepModels {
2523            source_name: "dep-a".to_string(),
2524            models: {
2525                let mut m = IndexMap::new();
2526                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2527                m
2528            },
2529        };
2530        let dep2 = ResolvedDepModels {
2531            source_name: "dep-b".to_string(),
2532            models: {
2533                let mut m = IndexMap::new();
2534                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2535                m
2536            },
2537        };
2538
2539        let mut diag = DiagnosticCollector::new();
2540        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2541        let warnings = diag.drain();
2542        assert_eq!(warnings.len(), 1);
2543        assert_eq!(
2544            warnings[0].message,
2545            "model alias `custom` defined by both `dep-a` and `dep-b` — using dep-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2546        );
2547    }
2548
2549    #[test]
2550    fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
2551        let dep1 = ResolvedDepModels {
2552            source_name: "pkg-a".to_string(),
2553            models: {
2554                let mut m = IndexMap::new();
2555                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2556                m
2557            },
2558        };
2559        let dep2 = ResolvedDepModels {
2560            source_name: "pkg-b".to_string(),
2561            models: {
2562                let mut m = IndexMap::new();
2563                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2564                m
2565            },
2566        };
2567        let dep3 = ResolvedDepModels {
2568            source_name: "pkg-c".to_string(),
2569            models: {
2570                let mut m = IndexMap::new();
2571                m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
2572                m
2573            },
2574        };
2575
2576        let mut diag = DiagnosticCollector::new();
2577        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag, None);
2578
2579        assert_eq!(
2580            merged.get("custom").unwrap().spec,
2581            ModelSpec::Pinned {
2582                model: "model-a".to_string(),
2583                provider: None
2584            }
2585        );
2586
2587        let warnings = diag.drain();
2588        assert_eq!(warnings.len(), 2);
2589        assert_eq!(
2590            warnings[0].message,
2591            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2592        );
2593        assert_eq!(
2594            warnings[1].message,
2595            "model alias `custom` defined by both `pkg-a` and `pkg-c` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
2596        );
2597    }
2598
2599    #[test]
2600    fn merge_consumer_override_suppresses_dep_conflict_warning() {
2601        let mut consumer = IndexMap::new();
2602        consumer.insert(
2603            "custom".to_string(),
2604            pinned_alias(Some("consumer"), "consumer-model"),
2605        );
2606
2607        let dep1 = ResolvedDepModels {
2608            source_name: "pkg-a".to_string(),
2609            models: {
2610                let mut m = IndexMap::new();
2611                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2612                m
2613            },
2614        };
2615        let dep2 = ResolvedDepModels {
2616            source_name: "pkg-b".to_string(),
2617            models: {
2618                let mut m = IndexMap::new();
2619                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2620                m
2621            },
2622        };
2623
2624        let mut diag = DiagnosticCollector::new();
2625        let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag, None);
2626
2627        assert_eq!(
2628            merged.get("custom").unwrap().spec,
2629            ModelSpec::Pinned {
2630                model: "consumer-model".to_string(),
2631                provider: None
2632            }
2633        );
2634        assert!(diag.drain().is_empty());
2635    }
2636
2637    #[test]
2638    fn merge_dep_conflicts_are_non_blocking() {
2639        let dep1 = ResolvedDepModels {
2640            source_name: "pkg-a".to_string(),
2641            models: {
2642                let mut m = IndexMap::new();
2643                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2644                m
2645            },
2646        };
2647        let dep2 = ResolvedDepModels {
2648            source_name: "pkg-b".to_string(),
2649            models: {
2650                let mut m = IndexMap::new();
2651                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2652                m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
2653                m
2654            },
2655        };
2656
2657        let mut diag = DiagnosticCollector::new();
2658        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2659
2660        assert!(!merged.contains_key("opus"));
2661        assert_eq!(
2662            merged.get("custom").unwrap().spec,
2663            ModelSpec::Pinned {
2664                model: "model-a".to_string(),
2665                provider: None
2666            }
2667        );
2668        assert_eq!(
2669            merged.get("extra").unwrap().spec,
2670            ModelSpec::Pinned {
2671                model: "model-extra".to_string(),
2672                provider: None
2673            }
2674        );
2675        assert_eq!(diag.drain().len(), 1);
2676    }
2677
2678    // -- resolve_all tests --
2679
2680    #[test]
2681    fn resolve_all_pinned() {
2682        let mut aliases = IndexMap::new();
2683        aliases.insert(
2684            "fast".to_string(),
2685            pinned_alias(Some("claude"), "claude-haiku-4-5"),
2686        );
2687
2688        let cache = ModelsCache {
2689            models: Vec::new(),
2690            fetched_at: None,
2691        };
2692
2693        let mut diag = DiagnosticCollector::new();
2694        let resolved = resolve_all(&aliases, &cache, &mut diag);
2695        let entry = resolved.get("fast").unwrap();
2696        assert_eq!(entry.model_id, "claude-haiku-4-5");
2697        assert_eq!(entry.provider, "anthropic");
2698    }
2699
2700    #[test]
2701    fn resolve_all_copies_alias_defaults() {
2702        let mut aliases = IndexMap::new();
2703        let mut alias = pinned_alias(Some("claude"), "claude-haiku-4-5");
2704        alias.default_effort = Some("medium".to_string());
2705        alias.autocompact = Some(30);
2706        aliases.insert("fast".to_string(), alias);
2707
2708        let cache = ModelsCache {
2709            models: Vec::new(),
2710            fetched_at: None,
2711        };
2712
2713        let mut diag = DiagnosticCollector::new();
2714        let resolved = resolve_all(&aliases, &cache, &mut diag);
2715        let entry = resolved.get("fast").unwrap();
2716        assert_eq!(entry.default_effort.as_deref(), Some("medium"));
2717        assert_eq!(entry.autocompact, Some(30));
2718    }
2719
2720    #[test]
2721    fn resolve_all_pinned_with_provider() {
2722        let mut aliases = IndexMap::new();
2723        aliases.insert(
2724            "fast".to_string(),
2725            ModelAlias {
2726                harness: None,
2727                description: None,
2728                default_effort: None,
2729                autocompact: None,
2730                autocompact_pct: None,
2731                spec: ModelSpec::Pinned {
2732                    model: "gpt-5.3-codex".to_string(),
2733                    provider: Some("openai".to_string()),
2734                },
2735            },
2736        );
2737
2738        let cache = ModelsCache {
2739            models: Vec::new(),
2740            fetched_at: None,
2741        };
2742
2743        let mut diag = DiagnosticCollector::new();
2744        let resolved = resolve_all(&aliases, &cache, &mut diag);
2745        let entry = resolved.get("fast").unwrap();
2746        assert_eq!(entry.model_id, "gpt-5.3-codex");
2747        assert_eq!(entry.provider, "openai");
2748        assert_eq!(
2749            entry.harness_candidates,
2750            vec!["codex", "claude", "pi", "cursor", "opencode"]
2751        );
2752    }
2753
2754    #[test]
2755    fn resolve_all_unavailable_harness_still_included() {
2756        let mut aliases = IndexMap::new();
2757        aliases.insert(
2758            "opus".to_string(),
2759            ModelAlias {
2760                harness: Some("missing-harness-xyz".to_string()),
2761                description: None,
2762                default_effort: None,
2763                autocompact: None,
2764                autocompact_pct: None,
2765                spec: ModelSpec::Pinned {
2766                    model: "claude-opus-4-6".to_string(),
2767                    provider: None,
2768                },
2769            },
2770        );
2771
2772        let cache = ModelsCache {
2773            models: Vec::new(),
2774            fetched_at: None,
2775        };
2776
2777        let mut diag = DiagnosticCollector::new();
2778        let resolved = resolve_all(&aliases, &cache, &mut diag);
2779        let entry = resolved.get("opus").unwrap();
2780        assert_eq!(entry.model_id, "claude-opus-4-6");
2781        assert_eq!(entry.provider, "anthropic");
2782        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
2783        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
2784    }
2785
2786    #[test]
2787    fn resolve_all_empty_cache_omits_unresolvable() {
2788        let mut aliases = IndexMap::new();
2789        aliases.insert(
2790            "opus".to_string(),
2791            ModelAlias {
2792                harness: Some("claude".to_string()),
2793                description: None,
2794                default_effort: None,
2795                autocompact: None,
2796                autocompact_pct: None,
2797                spec: ModelSpec::AutoResolve {
2798                    provider: Some("Anthropic".to_string()),
2799                    match_patterns: vec!["claude-opus-*".to_string()],
2800                    exclude_patterns: vec![],
2801                },
2802            },
2803        );
2804        let cache = ModelsCache {
2805            models: Vec::new(),
2806            fetched_at: None,
2807        };
2808
2809        let mut diag = DiagnosticCollector::new();
2810        let resolved = resolve_all(&aliases, &cache, &mut diag);
2811        // No cache → auto-resolve can't match → alias omitted from results
2812        assert!(!resolved.contains_key("opus"));
2813    }
2814
2815    #[test]
2816    fn resolve_all_pinned_with_match_uses_model_field() {
2817        let mut aliases = IndexMap::new();
2818        aliases.insert(
2819            "opus".to_string(),
2820            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2821        );
2822        let cache = make_cache(vec![
2823            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2824            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2825        ]);
2826
2827        let mut diag = DiagnosticCollector::new();
2828        let resolved = resolve_all(&aliases, &cache, &mut diag);
2829        assert_eq!(resolved.get("opus").unwrap().model_id, "claude-opus-4-6");
2830        assert!(diag.drain().is_empty());
2831    }
2832
2833    #[test]
2834    fn resolve_one_scopes_diagnostics_to_requested_alias() {
2835        let mut aliases = IndexMap::new();
2836        aliases.insert(
2837            "opus".to_string(),
2838            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2839        );
2840        aliases.insert(
2841            "sonnet".to_string(),
2842            pinned_match_alias("claude-sonnet-4-5", "Anthropic", &["claude-sonnet-*"], &[]),
2843        );
2844        let cache = make_cache(vec![
2845            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2846            ("claude-sonnet-4-7", "Anthropic", Some("2026-04-16")),
2847        ]);
2848
2849        let mut diag = DiagnosticCollector::new();
2850        let resolved = resolve_one("opus", &aliases, &cache, &mut diag).unwrap();
2851        assert_eq!(resolved.name, "opus");
2852        assert!(diag.drain().is_empty());
2853    }
2854
2855    fn make_resolved_alias(name: &str) -> ResolvedAlias {
2856        ResolvedAlias {
2857            name: name.to_string(),
2858            model_id: format!("model-{name}"),
2859            provider: "openai".to_string(),
2860            harness: Some("codex".to_string()),
2861            harness_source: HarnessSource::Explicit,
2862            harness_candidates: vec!["codex".to_string()],
2863            description: None,
2864            default_effort: None,
2865            autocompact: None,
2866            autocompact_pct: None,
2867            availability: None,
2868        }
2869    }
2870
2871    #[test]
2872    fn filter_by_visibility_include_mode_keeps_matches_only() {
2873        let mut aliases = IndexMap::new();
2874        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2875        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2876        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2877
2878        let filtered = filter_by_visibility(
2879            aliases,
2880            &crate::config::ModelVisibility {
2881                include: Some(vec!["model-opus*".to_string(), "model-gpt-*".to_string()]),
2882                exclude: None,
2883            },
2884        );
2885
2886        assert_eq!(filtered.len(), 2);
2887        assert!(filtered.contains_key("opus"));
2888        assert!(filtered.contains_key("gpt-5"));
2889        assert!(!filtered.contains_key("sonnet"));
2890    }
2891
2892    #[test]
2893    fn filter_by_visibility_exclude_mode_removes_matches() {
2894        let mut aliases = IndexMap::new();
2895        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2896        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
2897        aliases.insert(
2898            "deprecated-gpt".to_string(),
2899            make_resolved_alias("deprecated-gpt"),
2900        );
2901
2902        let filtered = filter_by_visibility(
2903            aliases,
2904            &crate::config::ModelVisibility {
2905                include: None,
2906                exclude: Some(vec![
2907                    "model-test-*".to_string(),
2908                    "model-deprecated-*".to_string(),
2909                ]),
2910            },
2911        );
2912
2913        assert_eq!(filtered.len(), 1);
2914        assert!(filtered.contains_key("opus"));
2915        assert!(!filtered.contains_key("test-opus"));
2916        assert!(!filtered.contains_key("deprecated-gpt"));
2917    }
2918
2919    #[test]
2920    fn filter_by_visibility_empty_config_returns_all() {
2921        let mut aliases = IndexMap::new();
2922        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2923        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2924        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
2925        assert_eq!(filtered.len(), 2);
2926        assert!(filtered.contains_key("opus"));
2927        assert!(filtered.contains_key("sonnet"));
2928    }
2929
2930    #[test]
2931    fn filter_by_visibility_empty_lists_return_all() {
2932        let mut aliases = IndexMap::new();
2933        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2934        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2935        let filtered = filter_by_visibility(
2936            aliases,
2937            &crate::config::ModelVisibility {
2938                include: Some(Vec::new()),
2939                exclude: Some(Vec::new()),
2940            },
2941        );
2942        assert_eq!(filtered.len(), 2);
2943        assert!(filtered.contains_key("opus"));
2944        assert!(filtered.contains_key("sonnet"));
2945    }
2946
2947    #[test]
2948    fn visibility_pattern_matches_bare_provider_and_opencode_slug_forms() {
2949        let paths = vec![availability::RunnablePath {
2950            harness: "opencode".to_string(),
2951            mars_provider: "Anthropic".to_string(),
2952            harness_model_id: "openrouter/anthropic/claude-opus-4.7".to_string(),
2953        }];
2954
2955        assert!(matches_visibility_pattern(
2956            "claude-opus-*",
2957            "claude-opus-4-7",
2958            "Anthropic",
2959            &paths
2960        ));
2961        assert!(matches_visibility_pattern(
2962            "anthropic/claude-opus-*",
2963            "claude-opus-4-7",
2964            "Anthropic",
2965            &paths
2966        ));
2967        assert!(matches_visibility_pattern(
2968            "openrouter/anthropic/*",
2969            "claude-opus-4-7",
2970            "Anthropic",
2971            &paths
2972        ));
2973        assert!(!matches_visibility_pattern(
2974            "anthropic/*/opus",
2975            "claude-opus-4-7",
2976            "Anthropic",
2977            &paths
2978        ));
2979    }
2980
2981    #[test]
2982    fn filter_by_visibility_applies_include_then_exclude() {
2983        let mut aliases = IndexMap::new();
2984        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2985        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2986        aliases.insert("gpt-4".to_string(), make_resolved_alias("gpt-4"));
2987
2988        let filtered = filter_by_visibility(
2989            aliases,
2990            &crate::config::ModelVisibility {
2991                include: Some(vec!["openai/model-*".to_string()]),
2992                exclude: Some(vec!["model-gpt-4".to_string()]),
2993            },
2994        );
2995
2996        assert_eq!(filtered.len(), 2);
2997        assert!(filtered.contains_key("opus"));
2998        assert!(filtered.contains_key("gpt-5"));
2999        assert!(!filtered.contains_key("gpt-4"));
3000    }
3001
3002    #[test]
3003    fn resolve_model_and_provider_pinned_explicit_provider() {
3004        let alias = ModelAlias {
3005            harness: None,
3006            description: None,
3007            default_effort: None,
3008            autocompact: None,
3009            autocompact_pct: None,
3010            spec: ModelSpec::Pinned {
3011                model: "claude-opus-4-6".to_string(),
3012                provider: Some("anthropic".to_string()),
3013            },
3014        };
3015        let cache = ModelsCache {
3016            models: Vec::new(),
3017            fetched_at: None,
3018        };
3019
3020        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
3021        assert_eq!(
3022            resolved,
3023            ("claude-opus-4-6".to_string(), "anthropic".to_string())
3024        );
3025    }
3026
3027    #[test]
3028    fn resolve_model_and_provider_pinned_inferred() {
3029        let alias = ModelAlias {
3030            harness: None,
3031            description: None,
3032            default_effort: None,
3033            autocompact: None,
3034            autocompact_pct: None,
3035            spec: ModelSpec::Pinned {
3036                model: "claude-opus-4-6".to_string(),
3037                provider: None,
3038            },
3039        };
3040        let cache = ModelsCache {
3041            models: Vec::new(),
3042            fetched_at: None,
3043        };
3044
3045        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
3046        assert_eq!(
3047            resolved,
3048            ("claude-opus-4-6".to_string(), "anthropic".to_string())
3049        );
3050    }
3051
3052    #[test]
3053    fn resolve_model_and_provider_pinned_unknown() {
3054        let alias = ModelAlias {
3055            harness: None,
3056            description: None,
3057            default_effort: None,
3058            autocompact: None,
3059            autocompact_pct: None,
3060            spec: ModelSpec::Pinned {
3061                model: "my-custom-model".to_string(),
3062                provider: None,
3063            },
3064        };
3065        let cache = ModelsCache {
3066            models: Vec::new(),
3067            fetched_at: None,
3068        };
3069
3070        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
3071        assert_eq!(
3072            resolved,
3073            ("my-custom-model".to_string(), "unknown".to_string())
3074        );
3075    }
3076
3077    #[test]
3078    fn resolve_model_and_provider_auto_resolve() {
3079        let alias = ModelAlias {
3080            harness: None,
3081            description: None,
3082            default_effort: None,
3083            autocompact: None,
3084            autocompact_pct: None,
3085            spec: ModelSpec::AutoResolve {
3086                provider: Some("openai".to_string()),
3087                match_patterns: vec!["gpt-5*".to_string()],
3088                exclude_patterns: vec![],
3089            },
3090        };
3091        let cache = make_cache(vec![
3092            ("gpt-4o", "OpenAI", Some("2024-06-01")),
3093            ("gpt-5", "OpenAI", Some("2025-06-01")),
3094        ]);
3095
3096        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
3097        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
3098    }
3099
3100    // -- serde roundtrip tests --
3101
3102    #[test]
3103    fn harness_source_serializes_snake_case() {
3104        assert_eq!(
3105            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
3106            "\"explicit\""
3107        );
3108        assert_eq!(
3109            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
3110            "\"auto_detected\""
3111        );
3112        assert_eq!(
3113            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
3114            "\"unavailable\""
3115        );
3116    }
3117
3118    #[test]
3119    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
3120        let toml_str = r#"
3121[models.fast]
3122harness = "claude"
3123model = "claude-haiku-4-5"
3124description = "Fast and cheap"
3125"#;
3126
3127        #[derive(Debug, Deserialize)]
3128        struct Wrapper {
3129            #[allow(dead_code)]
3130            models: IndexMap<String, ModelAlias>,
3131        }
3132
3133        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3134        let alias = parsed.models.get("fast").unwrap();
3135        assert_eq!(
3136            alias.spec,
3137            ModelSpec::Pinned {
3138                model: "claude-haiku-4-5".to_string(),
3139                provider: None
3140            }
3141        );
3142        assert_eq!(alias.harness.as_deref(), Some("claude"));
3143        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
3144
3145        let json = serde_json::to_string(alias).unwrap();
3146        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3147        assert_eq!(roundtripped, *alias);
3148    }
3149
3150    #[test]
3151    fn model_alias_native_overrides_removed_errors() {
3152        let toml_str = r#"
3153[models.fast]
3154model = "gpt-5.5"
3155
3156[models.fast.native]
3157cursor = "gpt-5.5-high"
3158"#;
3159
3160        #[derive(Debug, Deserialize)]
3161        struct Wrapper {
3162            #[allow(dead_code)]
3163            models: IndexMap<String, ModelAlias>,
3164        }
3165
3166        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3167        assert!(err.contains("no longer supported"));
3168    }
3169
3170    #[test]
3171    fn model_alias_pinned_toml_roundtrip_without_harness() {
3172        let toml_str = r#"
3173[models.fast]
3174model = "claude-haiku-4-5"
3175"#;
3176
3177        #[derive(Debug, Deserialize)]
3178        struct Wrapper {
3179            #[allow(dead_code)]
3180            models: IndexMap<String, ModelAlias>,
3181        }
3182
3183        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3184        let alias = parsed.models.get("fast").unwrap();
3185        assert_eq!(alias.harness, None);
3186        assert_eq!(
3187            alias.spec,
3188            ModelSpec::Pinned {
3189                model: "claude-haiku-4-5".to_string(),
3190                provider: None
3191            }
3192        );
3193
3194        let json = serde_json::to_string(alias).unwrap();
3195        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3196        assert!(value.get("harness").is_none());
3197        assert!(value.get("provider").is_none());
3198        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3199        assert_eq!(roundtripped, *alias);
3200    }
3201
3202    #[test]
3203    fn model_alias_pinned_toml_roundtrip_with_provider() {
3204        let toml_str = r#"
3205[models.fast]
3206model = "claude-haiku-4-5"
3207provider = "anthropic"
3208"#;
3209
3210        #[derive(Debug, Deserialize)]
3211        struct Wrapper {
3212            #[allow(dead_code)]
3213            models: IndexMap<String, ModelAlias>,
3214        }
3215
3216        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3217        let alias = parsed.models.get("fast").unwrap();
3218        assert_eq!(alias.harness, None);
3219        assert_eq!(
3220            alias.spec,
3221            ModelSpec::Pinned {
3222                model: "claude-haiku-4-5".to_string(),
3223                provider: Some("anthropic".to_string())
3224            }
3225        );
3226
3227        let json = serde_json::to_string(alias).unwrap();
3228        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3229        assert_eq!(
3230            value.get("provider").and_then(serde_json::Value::as_str),
3231            Some("anthropic")
3232        );
3233        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3234        assert_eq!(roundtripped, *alias);
3235    }
3236
3237    #[test]
3238    fn model_alias_pinned_json_roundtrip_with_provider() {
3239        let json = r#"{
3240            "model": "gpt-5.3-codex",
3241            "provider": "openai"
3242        }"#;
3243
3244        let alias: ModelAlias = serde_json::from_str(json).unwrap();
3245        assert_eq!(alias.harness, None);
3246        assert_eq!(alias.description, None);
3247        assert_eq!(
3248            alias.spec,
3249            ModelSpec::Pinned {
3250                model: "gpt-5.3-codex".to_string(),
3251                provider: Some("openai".to_string())
3252            }
3253        );
3254
3255        let encoded = serde_json::to_string(&alias).unwrap();
3256        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
3257        assert_eq!(roundtripped, alias);
3258    }
3259
3260    #[test]
3261    fn model_alias_auto_resolve_toml_roundtrip() {
3262        let toml_str = r#"
3263[models.opus]
3264harness = "claude"
3265provider = "Anthropic"
3266match = ["claude-opus-*"]
3267exclude = ["claude-opus-3*"]
3268description = "Best reasoning"
3269"#;
3270
3271        #[derive(Debug, Deserialize)]
3272        struct Wrapper {
3273            #[allow(dead_code)]
3274            models: IndexMap<String, ModelAlias>,
3275        }
3276
3277        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3278        let alias = parsed.models.get("opus").unwrap();
3279        assert_eq!(alias.harness.as_deref(), Some("claude"));
3280        match &alias.spec {
3281            ModelSpec::AutoResolve {
3282                provider,
3283                match_patterns,
3284                exclude_patterns,
3285            } => {
3286                assert_eq!(provider.as_deref(), Some("Anthropic"));
3287                assert_eq!(match_patterns, &["claude-opus-*"]);
3288                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3289            }
3290            _ => panic!("expected AutoResolve"),
3291        }
3292    }
3293
3294    #[test]
3295    fn model_alias_model_and_match_toml_roundtrip() {
3296        let toml_str = r#"
3297[models.opus]
3298model = "claude-opus-4-6"
3299provider = "anthropic"
3300match = ["claude-opus-*"]
3301exclude = ["claude-opus-3*"]
3302"#;
3303
3304        #[derive(Debug, Deserialize)]
3305        struct Wrapper {
3306            #[allow(dead_code)]
3307            models: IndexMap<String, ModelAlias>,
3308        }
3309
3310        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3311        let alias = parsed.models.get("opus").unwrap();
3312        match &alias.spec {
3313            ModelSpec::PinnedWithMatch {
3314                model,
3315                provider,
3316                match_patterns,
3317                exclude_patterns,
3318            } => {
3319                assert_eq!(model, "claude-opus-4-6");
3320                assert_eq!(provider.as_deref(), Some("anthropic"));
3321                assert_eq!(match_patterns, &["claude-opus-*"]);
3322                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3323            }
3324            _ => panic!("expected PinnedWithMatch"),
3325        }
3326
3327        let json = serde_json::to_string(alias).unwrap();
3328        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3329        assert_eq!(roundtripped, *alias);
3330    }
3331
3332    #[test]
3333    fn model_alias_model_with_exclude_without_match_errors() {
3334        let toml_str = r#"
3335[models.opus]
3336model = "claude-opus-4-7"
3337exclude = ["claude-opus-3*"]
3338"#;
3339
3340        #[derive(Debug, Deserialize)]
3341        struct Wrapper {
3342            #[allow(dead_code)]
3343            models: IndexMap<String, ModelAlias>,
3344        }
3345
3346        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3347        assert!(err.contains("must also include 'match'"));
3348    }
3349
3350    #[test]
3351    fn model_alias_defaults_toml_roundtrip() {
3352        let toml_str = r#"
3353[models.opus]
3354provider = "Anthropic"
3355match = ["claude-opus-*"]
3356default_effort = "high"
3357autocompact = 25
3358"#;
3359
3360        #[derive(Debug, Deserialize)]
3361        struct Wrapper {
3362            models: IndexMap<String, ModelAlias>,
3363        }
3364
3365        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3366        let alias = parsed.models.get("opus").unwrap();
3367        assert_eq!(alias.default_effort.as_deref(), Some("high"));
3368        assert_eq!(alias.autocompact, Some(25));
3369
3370        let json = serde_json::to_string(alias).unwrap();
3371        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3372        assert_eq!(roundtripped, *alias);
3373    }
3374
3375    #[test]
3376    fn model_alias_empty_default_effort_treated_as_none() {
3377        let toml_str = r#"
3378[models.opus]
3379provider = "Anthropic"
3380match = ["claude-opus-*"]
3381default_effort = ""
3382"#;
3383
3384        #[derive(Debug, Deserialize)]
3385        struct Wrapper {
3386            models: IndexMap<String, ModelAlias>,
3387        }
3388
3389        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3390        let alias = parsed.models.get("opus").unwrap();
3391        assert_eq!(alias.default_effort, None);
3392    }
3393
3394    #[test]
3395    fn model_alias_invalid_default_effort_errors() {
3396        let toml_str = r#"
3397[models.opus]
3398provider = "Anthropic"
3399match = ["claude-opus-*"]
3400default_effort = "maximum"
3401"#;
3402
3403        #[derive(Debug, Deserialize)]
3404        struct Wrapper {
3405            #[allow(dead_code)]
3406            models: IndexMap<String, ModelAlias>,
3407        }
3408
3409        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3410        assert!(err.contains("invalid default_effort"));
3411        assert!(err.contains("accepted values"));
3412    }
3413
3414    #[test]
3415    fn model_alias_invalid_harness_errors() {
3416        let toml_str = r#"
3417[models.opus]
3418harness = "gemini"
3419provider = "Anthropic"
3420match = ["claude-opus-*"]
3421"#;
3422
3423        #[derive(Debug, Deserialize)]
3424        struct Wrapper {
3425            #[allow(dead_code)]
3426            models: IndexMap<String, ModelAlias>,
3427        }
3428
3429        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3430        assert!(err.contains("invalid harness 'gemini'"));
3431        assert!(err.contains("valid harnesses: claude, codex, pi, cursor, opencode"));
3432    }
3433
3434    #[test]
3435    fn model_alias_harness_normalizes_mixed_case() {
3436        let toml_str = r#"
3437[models.opus]
3438harness = "OpenCode"
3439model = "gpt-5"
3440"#;
3441
3442        #[derive(Debug, Deserialize)]
3443        struct Wrapper {
3444            models: IndexMap<String, ModelAlias>,
3445        }
3446
3447        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3448        let alias = parsed.models.get("opus").unwrap();
3449        assert_eq!(alias.harness.as_deref(), Some("opencode"));
3450    }
3451
3452    #[test]
3453    fn model_alias_autocompact_out_of_range_errors() {
3454        // autocompact_pct out of range (>100) is a hard error
3455        let toml_str = r#"
3456[models.opus]
3457provider = "Anthropic"
3458match = ["claude-opus-*"]
3459autocompact_pct = 101
3460"#;
3461
3462        #[derive(Debug, Deserialize)]
3463        struct Wrapper {
3464            #[allow(dead_code)]
3465            models: IndexMap<String, ModelAlias>,
3466        }
3467
3468        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3469        assert!(err.contains("out of range 1-100"));
3470    }
3471
3472    #[test]
3473    fn model_alias_autocompact_boolean_errors() {
3474        let toml_str = r#"
3475[models.opus]
3476provider = "Anthropic"
3477match = ["claude-opus-*"]
3478autocompact = true
3479"#;
3480
3481        #[derive(Debug, Deserialize)]
3482        struct Wrapper {
3483            #[allow(dead_code)]
3484            models: IndexMap<String, ModelAlias>,
3485        }
3486
3487        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3488        assert!(err.contains("autocompact must be an integer (token count)"));
3489    }
3490
3491    #[test]
3492    fn parses_autocompact_pct() {
3493        let toml_str = r#"
3494[models.opus]
3495provider = "Anthropic"
3496match = ["claude-opus-*"]
3497autocompact_pct = 75
3498"#;
3499
3500        #[derive(Debug, Deserialize)]
3501        struct Wrapper {
3502            models: IndexMap<String, ModelAlias>,
3503        }
3504
3505        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3506        let alias = parsed.models.get("opus").unwrap();
3507        assert_eq!(alias.autocompact_pct, Some(75));
3508        assert_eq!(alias.autocompact, None);
3509    }
3510
3511    #[test]
3512    fn autocompact_pct_out_of_range_errors() {
3513        let toml_str = r#"
3514[models.opus]
3515provider = "Anthropic"
3516match = ["claude-opus-*"]
3517autocompact_pct = 150
3518"#;
3519
3520        #[derive(Debug, Deserialize)]
3521        struct Wrapper {
3522            #[allow(dead_code)]
3523            models: IndexMap<String, ModelAlias>,
3524        }
3525
3526        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3527        assert!(err.contains("autocompact_pct"));
3528        assert!(err.contains("out of range 1-100"));
3529    }
3530
3531    #[test]
3532    fn autocompact_pct_zero_errors() {
3533        let toml_str = r#"
3534[models.opus]
3535provider = "Anthropic"
3536match = ["claude-opus-*"]
3537autocompact_pct = 0
3538"#;
3539
3540        #[derive(Debug, Deserialize)]
3541        struct Wrapper {
3542            #[allow(dead_code)]
3543            models: IndexMap<String, ModelAlias>,
3544        }
3545
3546        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3547        assert!(err.contains("autocompact_pct"));
3548        assert!(err.contains("out of range 1-100"));
3549    }
3550
3551    #[test]
3552    fn model_alias_autocompact_zero_accepted() {
3553        let toml_str = r#"
3554[models.opus]
3555model = "claude-opus-4-6"
3556autocompact = 0
3557"#;
3558
3559        #[derive(Debug, Deserialize)]
3560        struct Wrapper {
3561            models: IndexMap<String, ModelAlias>,
3562        }
3563
3564        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3565        let alias = parsed.models.get("opus").unwrap();
3566        assert_eq!(alias.autocompact, Some(0u32));
3567    }
3568
3569    #[test]
3570    fn model_alias_autocompact_max_u32_accepted() {
3571        let toml_str = r#"
3572[models.opus]
3573model = "claude-opus-4-6"
3574autocompact = 4294967295
3575"#;
3576
3577        #[derive(Debug, Deserialize)]
3578        struct Wrapper {
3579            models: IndexMap<String, ModelAlias>,
3580        }
3581
3582        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3583        let alias = parsed.models.get("opus").unwrap();
3584        assert_eq!(alias.autocompact, Some(4294967295u32));
3585    }
3586
3587    #[test]
3588    fn model_alias_autocompact_overflow_errors() {
3589        // 4294967296 == u32::MAX + 1 — should be rejected
3590        let toml_str = r#"
3591[models.opus]
3592model = "claude-opus-4-6"
3593autocompact = 4294967296
3594"#;
3595
3596        #[derive(Debug, Deserialize)]
3597        struct Wrapper {
3598            #[allow(dead_code)]
3599            models: IndexMap<String, ModelAlias>,
3600        }
3601
3602        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3603        assert!(err.contains("out of u32 range"));
3604    }
3605
3606    #[test]
3607    fn both_autocompact_fields_round_trip() {
3608        let toml_str = r#"
3609[models.opus]
3610model = "claude-opus-4-6"
3611autocompact = 50000
3612autocompact_pct = 80
3613"#;
3614
3615        #[derive(Debug, Deserialize)]
3616        struct Wrapper {
3617            models: IndexMap<String, ModelAlias>,
3618        }
3619
3620        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3621        let alias = parsed.models.get("opus").unwrap();
3622        assert_eq!(alias.autocompact, Some(50000u32));
3623        assert_eq!(alias.autocompact_pct, Some(80u8));
3624
3625        // Verify both propagate through resolve_all
3626        let mut aliases = IndexMap::new();
3627        aliases.insert("opus".to_string(), alias.clone());
3628        let cache = ModelsCache {
3629            models: Vec::new(),
3630            fetched_at: None,
3631        };
3632        let mut diag = DiagnosticCollector::new();
3633        let resolved = resolve_all(&aliases, &cache, &mut diag);
3634        let entry = resolved.get("opus").unwrap();
3635        assert_eq!(entry.autocompact, Some(50000u32));
3636        assert_eq!(entry.autocompact_pct, Some(80u8));
3637    }
3638
3639    #[test]
3640    fn model_alias_both_model_and_match_is_hybrid_pinned() {
3641        let toml_str = r#"
3642[models.bad]
3643harness = "claude"
3644model = "some-model"
3645match = ["pattern-*"]
3646"#;
3647
3648        #[derive(Debug, Deserialize)]
3649        struct Wrapper {
3650            #[allow(dead_code)]
3651            models: IndexMap<String, ModelAlias>,
3652        }
3653
3654        let result = toml::from_str::<Wrapper>(toml_str).unwrap();
3655        let alias = result.models.get("bad").unwrap();
3656        match &alias.spec {
3657            ModelSpec::PinnedWithMatch {
3658                model,
3659                match_patterns,
3660                ..
3661            } => {
3662                assert_eq!(model, "some-model");
3663                assert_eq!(match_patterns, &["pattern-*"]);
3664            }
3665            _ => panic!("expected pinned-with-match alias"),
3666        }
3667    }
3668
3669    #[test]
3670    fn model_alias_neither_model_nor_match_errors() {
3671        let toml_str = r#"
3672[models.bad]
3673harness = "claude"
3674"#;
3675
3676        #[derive(Debug, Deserialize)]
3677        struct Wrapper {
3678            #[allow(dead_code)]
3679            models: IndexMap<String, ModelAlias>,
3680        }
3681
3682        let result = toml::from_str::<Wrapper>(toml_str);
3683        assert!(result.is_err());
3684    }
3685
3686    #[test]
3687    fn infer_provider_from_model_id_detects_known_prefixes() {
3688        assert_eq!(
3689            infer_provider_from_model_id("claude-opus-4-6"),
3690            Some("anthropic")
3691        );
3692        assert_eq!(
3693            infer_provider_from_model_id("gpt-5.3-codex"),
3694            Some("openai")
3695        );
3696        assert_eq!(
3697            infer_provider_from_model_id("gemini-2.5-pro"),
3698            Some("google")
3699        );
3700        assert_eq!(
3701            infer_provider_from_model_id("llama-4-maverick"),
3702            Some("meta")
3703        );
3704        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
3705        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
3706        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
3707        assert_eq!(
3708            infer_provider_from_model_id("codex-mini-latest"),
3709            Some("openai")
3710        );
3711        assert_eq!(
3712            infer_provider_from_model_id("mistral-large"),
3713            Some("mistral")
3714        );
3715        assert_eq!(
3716            infer_provider_from_model_id("codestral-latest"),
3717            Some("mistral")
3718        );
3719        assert_eq!(
3720            infer_provider_from_model_id("deepseek-chat"),
3721            Some("deepseek")
3722        );
3723        assert_eq!(
3724            infer_provider_from_model_id("command-r-plus"),
3725            Some("cohere")
3726        );
3727    }
3728
3729    #[test]
3730    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
3731        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
3732    }
3733
3734    #[test]
3735    fn infer_provider_from_model_id_returns_none_for_empty_string() {
3736        assert_eq!(infer_provider_from_model_id(""), None);
3737    }
3738
3739    #[test]
3740    fn infer_provider_from_model_id_is_case_insensitive() {
3741        assert_eq!(
3742            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
3743            Some("anthropic")
3744        );
3745        assert_eq!(
3746            infer_provider_from_model_id("GPT-5.3-codex"),
3747            Some("openai")
3748        );
3749        assert_eq!(
3750            infer_provider_from_model_id("CoDeStRaL-latest"),
3751            Some("mistral")
3752        );
3753    }
3754
3755    #[allow(unused_unsafe)]
3756    fn env_set(key: &str, value: &str) {
3757        unsafe {
3758            std::env::set_var(key, value);
3759        }
3760    }
3761
3762    #[allow(unused_unsafe)]
3763    fn env_remove(key: &str) {
3764        unsafe {
3765            std::env::remove_var(key);
3766        }
3767    }
3768
3769    struct EnvVarGuard {
3770        key: String,
3771        prev: Option<String>,
3772    }
3773
3774    impl EnvVarGuard {
3775        fn set(key: &str, value: &str) -> Self {
3776            let prev = std::env::var(key).ok();
3777            env_set(key, value);
3778            Self {
3779                key: key.to_string(),
3780                prev,
3781            }
3782        }
3783    }
3784
3785    impl Drop for EnvVarGuard {
3786        fn drop(&mut self) {
3787            if let Some(prev) = &self.prev {
3788                env_set(&self.key, prev);
3789            } else {
3790                env_remove(&self.key);
3791            }
3792        }
3793    }
3794
3795    fn sample_catalog_json() -> serde_json::Value {
3796        serde_json::json!({
3797            "openai": {
3798                "models": {
3799                    "gpt-5": {
3800                        "id": "gpt-5",
3801                        "name": "GPT-5",
3802                        "release_date": "2025-06-01",
3803                        "limit": {
3804                            "context": 400000,
3805                            "output": 128000
3806                        }
3807                    }
3808                }
3809            },
3810            "anthropic": {
3811                "models": {
3812                    "claude-sonnet-4-5": {
3813                        "id": "claude-sonnet-4-5",
3814                        "name": "Claude Sonnet 4.5",
3815                        "release_date": "2025-03-01"
3816                    }
3817                }
3818            }
3819        })
3820    }
3821
3822    fn sample_cached_model(id: &str) -> CachedModel {
3823        CachedModel {
3824            id: id.to_string(),
3825            provider: "OpenAI".to_string(),
3826            release_date: None,
3827            description: None,
3828            context_window: None,
3829            max_output: None,
3830            cost_input: None,
3831            cost_output: None,
3832            cost_cache_read: None,
3833            cost_cache_write: None,
3834            cost_reasoning: None,
3835        }
3836    }
3837
3838    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
3839        write_cache(
3840            mars_dir,
3841            &ModelsCache {
3842                models,
3843                fetched_at: Some(fetched_at.to_string()),
3844            },
3845        )
3846        .expect("failed to write cache fixture");
3847    }
3848
3849    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
3850        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
3851        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
3852    }
3853
3854    fn stale_timestamp() -> String {
3855        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
3856    }
3857
3858    fn fresh_timestamp() -> String {
3859        now_unix_secs_value().saturating_sub(60).to_string()
3860    }
3861
3862    fn assert_model_cache_unavailable(
3863        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
3864        reason_contains: &str,
3865    ) {
3866        match result {
3867            Err(MarsError::ModelCacheUnavailable { reason }) => {
3868                assert!(
3869                    reason.contains(reason_contains),
3870                    "unexpected reason: {reason}"
3871                );
3872            }
3873            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
3874        }
3875    }
3876
3877    #[test]
3878    #[serial]
3879    fn ensure_fresh_1_missing_cache_offline_errors() {
3880        let mars = tempdir().unwrap();
3881        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3882
3883        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3884        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
3885    }
3886
3887    #[test]
3888    #[serial]
3889    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
3890        let mars = tempdir().unwrap();
3891        let server = MockServer::start();
3892        let mock = server.mock(|when, then| {
3893            when.method(GET).path("/api.json");
3894            then.status(500).body("server error");
3895        });
3896        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3897
3898        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3899        assert_model_cache_unavailable(result, "automatic refresh failed");
3900        assert_eq!(mock.hits(), 1);
3901    }
3902
3903    #[test]
3904    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
3905        let mars = tempdir().unwrap();
3906        write_cache_state(
3907            mars.path(),
3908            vec![sample_cached_model("stale-model")],
3909            &stale_timestamp(),
3910        );
3911
3912        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
3913        assert_eq!(cache.models.len(), 1);
3914        assert_eq!(cache.models[0].id, "stale-model");
3915        assert_eq!(outcome, RefreshOutcome::Offline);
3916    }
3917
3918    #[test]
3919    #[serial]
3920    fn ensure_fresh_4_fresh_auto_skips_http() {
3921        let mars = tempdir().unwrap();
3922        write_cache_state(
3923            mars.path(),
3924            vec![sample_cached_model("fresh-model")],
3925            &fresh_timestamp(),
3926        );
3927
3928        let server = MockServer::start();
3929        let mock = server.mock(|when, then| {
3930            when.method(GET).path("/api.json");
3931            then.status(200).json_body(sample_catalog_json());
3932        });
3933        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3934
3935        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3936        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
3937        assert_eq!(mock.hits(), 0);
3938    }
3939
3940    #[test]
3941    #[serial]
3942    fn ensure_fresh_5_stale_auto_success_refreshes() {
3943        let mars = tempdir().unwrap();
3944        write_cache_state(
3945            mars.path(),
3946            vec![sample_cached_model("old-model")],
3947            &stale_timestamp(),
3948        );
3949
3950        let server = MockServer::start();
3951        let mock = server.mock(|when, then| {
3952            when.method(GET).path("/api.json");
3953            then.status(200).json_body(sample_catalog_json());
3954        });
3955        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3956
3957        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3958        assert!(matches!(
3959            outcome,
3960            RefreshOutcome::Refreshed { models_count } if models_count == 2
3961        ));
3962        assert_eq!(cache.models.len(), 2);
3963        assert!(!cache.models.is_empty());
3964        assert!(cache.fetched_at.is_some());
3965        assert_eq!(mock.hits(), 1);
3966    }
3967
3968    #[test]
3969    #[serial]
3970    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
3971        let mars = tempdir().unwrap();
3972        write_cache_state(
3973            mars.path(),
3974            vec![sample_cached_model("stale-model")],
3975            &stale_timestamp(),
3976        );
3977
3978        let server = MockServer::start();
3979        let mock = server.mock(|when, then| {
3980            when.method(GET).path("/api.json");
3981            then.status(500).body("server error");
3982        });
3983        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3984
3985        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3986        assert_eq!(cache.models[0].id, "stale-model");
3987        assert!(matches!(
3988            outcome,
3989            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
3990        ));
3991        assert_eq!(mock.hits(), 1);
3992    }
3993
3994    #[test]
3995    #[serial]
3996    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
3997        let mars = tempdir().unwrap();
3998        write_cache_state(
3999            mars.path(),
4000            vec![sample_cached_model("stale-model")],
4001            &stale_timestamp(),
4002        );
4003
4004        let server = MockServer::start();
4005        let mock = server.mock(|when, then| {
4006            when.method(GET).path("/api.json");
4007            then.status(200).json_body(serde_json::json!({}));
4008        });
4009        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4010
4011        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4012        assert_eq!(cache.models[0].id, "stale-model");
4013        assert!(matches!(
4014            outcome,
4015            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
4016        ));
4017        assert_eq!(mock.hits(), 1);
4018    }
4019
4020    #[test]
4021    #[serial]
4022    fn ensure_fresh_8_empty_cache_auto_refetches() {
4023        let mars = tempdir().unwrap();
4024        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
4025
4026        let server = MockServer::start();
4027        let mock = server.mock(|when, then| {
4028            when.method(GET).path("/api.json");
4029            then.status(200).json_body(sample_catalog_json());
4030        });
4031        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4032
4033        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4034        assert!(!cache.models.is_empty());
4035        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4036        assert_eq!(mock.hits(), 1);
4037    }
4038
4039    #[test]
4040    fn ensure_fresh_9_empty_cache_offline_errors() {
4041        let mars = tempdir().unwrap();
4042        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
4043
4044        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
4045        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
4046    }
4047
4048    #[test]
4049    #[serial]
4050    fn ensure_fresh_10_corrupt_json_auto_refetches() {
4051        let mars = tempdir().unwrap();
4052        write_raw_cache_file(mars.path(), "{ not-json ");
4053
4054        let server = MockServer::start();
4055        let mock = server.mock(|when, then| {
4056            when.method(GET).path("/api.json");
4057            then.status(200).json_body(sample_catalog_json());
4058        });
4059        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4060
4061        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4062        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4063        assert!(!cache.models.is_empty());
4064        assert_eq!(mock.hits(), 1);
4065    }
4066
4067    #[test]
4068    fn ensure_fresh_11_corrupt_json_offline_errors() {
4069        let mars = tempdir().unwrap();
4070        write_raw_cache_file(mars.path(), "{ not-json ");
4071
4072        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
4073        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
4074    }
4075
4076    #[test]
4077    fn read_cache_io_error_includes_operation_and_path() {
4078        let mars = tempdir().unwrap();
4079        let cache_path = mars.path().join(CACHE_FILE);
4080        std::fs::create_dir(&cache_path).unwrap();
4081
4082        let err = read_cache(mars.path()).unwrap_err();
4083        let msg = err.to_string();
4084
4085        assert!(
4086            msg.contains("read models cache"),
4087            "error should include operation context: {msg}"
4088        );
4089        assert!(
4090            msg.contains(CACHE_FILE),
4091            "error should include cache path: {msg}"
4092        );
4093    }
4094
4095    #[test]
4096    #[serial]
4097    fn ensure_fresh_12_ttl_zero_always_refetches() {
4098        let mars = tempdir().unwrap();
4099        write_cache_state(
4100            mars.path(),
4101            vec![sample_cached_model("fresh-model")],
4102            &fresh_timestamp(),
4103        );
4104
4105        let server = MockServer::start();
4106        let mock = server.mock(|when, then| {
4107            when.method(GET).path("/api.json");
4108            then.status(200).json_body(sample_catalog_json());
4109        });
4110        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4111
4112        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
4113        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4114        assert_eq!(mock.hits(), 1);
4115    }
4116
4117    #[test]
4118    #[serial]
4119    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
4120        let mars = tempdir().unwrap();
4121        write_cache_state(
4122            mars.path(),
4123            vec![sample_cached_model("stale-model")],
4124            "not-a-timestamp",
4125        );
4126
4127        let server = MockServer::start();
4128        let mock = server.mock(|when, then| {
4129            when.method(GET).path("/api.json");
4130            then.status(200).json_body(sample_catalog_json());
4131        });
4132        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4133
4134        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4135        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4136        assert_eq!(mock.hits(), 1);
4137    }
4138
4139    #[test]
4140    #[serial]
4141    fn ensure_fresh_14_future_fetched_at_is_stale() {
4142        let mars = tempdir().unwrap();
4143        let future = now_unix_secs_value() + 3600;
4144        write_cache_state(
4145            mars.path(),
4146            vec![sample_cached_model("future-model")],
4147            &future.to_string(),
4148        );
4149
4150        let server = MockServer::start();
4151        let mock = server.mock(|when, then| {
4152            when.method(GET).path("/api.json");
4153            then.status(200).json_body(sample_catalog_json());
4154        });
4155        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4156
4157        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4158        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4159        assert_eq!(mock.hits(), 1);
4160    }
4161
4162    #[test]
4163    #[serial]
4164    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
4165        let mars = tempdir().unwrap();
4166        write_cache_state(
4167            mars.path(),
4168            vec![sample_cached_model("fresh-model")],
4169            &fresh_timestamp(),
4170        );
4171
4172        let server = MockServer::start();
4173        let mock = server.mock(|when, then| {
4174            when.method(GET).path("/api.json");
4175            then.status(200).json_body(sample_catalog_json());
4176        });
4177        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4178        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
4179
4180        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4181        assert_eq!(outcome, RefreshOutcome::Offline);
4182        assert_eq!(mock.hits(), 0);
4183    }
4184
4185    #[test]
4186    #[serial]
4187    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
4188        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
4189        assert!(!is_mars_offline());
4190        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
4191    }
4192
4193    #[test]
4194    fn resolve_models_refresh_control_defaults_to_auto_background() {
4195        let control = resolve_models_refresh_control(false, false).unwrap();
4196        assert_eq!(control.catalog_mode, RefreshMode::Auto);
4197        assert_eq!(
4198            control.probe_refresh,
4199            crate::models::probes::ProbeRefreshMode::Background
4200        );
4201    }
4202
4203    #[test]
4204    fn resolve_models_refresh_control_no_refresh_is_offline_skip() {
4205        let control = resolve_models_refresh_control(false, true).unwrap();
4206        assert_eq!(control.catalog_mode, RefreshMode::Offline);
4207        assert_eq!(
4208            control.probe_refresh,
4209            crate::models::probes::ProbeRefreshMode::Skip
4210        );
4211    }
4212
4213    #[test]
4214    fn resolve_models_refresh_control_refresh_is_force_sync() {
4215        let control = resolve_models_refresh_control(true, false).unwrap();
4216        assert_eq!(control.catalog_mode, RefreshMode::Force);
4217        assert_eq!(
4218            control.probe_refresh,
4219            crate::models::probes::ProbeRefreshMode::Synchronous
4220        );
4221    }
4222
4223    #[test]
4224    fn resolve_models_refresh_control_rejects_both_flags() {
4225        assert!(resolve_models_refresh_control(true, true).is_err());
4226    }
4227
4228    #[test]
4229    #[serial]
4230    fn ensure_fresh_17_offline_env_truthy_is_offline() {
4231        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
4232        assert!(is_mars_offline());
4233        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
4234    }
4235
4236    #[test]
4237    #[serial]
4238    fn ensure_fresh_18_force_ignores_offline_env() {
4239        let mars = tempdir().unwrap();
4240        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
4241
4242        let server = MockServer::start();
4243        let mock = server.mock(|when, then| {
4244            when.method(GET).path("/api.json");
4245            then.status(200).json_body(sample_catalog_json());
4246        });
4247        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4248
4249        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
4250        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4251        assert_eq!(mock.hits(), 1);
4252    }
4253
4254    #[test]
4255    #[serial]
4256    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
4257        let mars = tempdir().unwrap();
4258        write_cache_state(
4259            mars.path(),
4260            vec![sample_cached_model("stale-model")],
4261            &stale_timestamp(),
4262        );
4263
4264        let path = Arc::new(mars.path().to_path_buf());
4265        let path_a = Arc::clone(&path);
4266        let path_b = Arc::clone(&path);
4267        let fetch_hits = Arc::new(AtomicUsize::new(0));
4268        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
4269        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
4270
4271        let fetch_hits_a = Arc::clone(&fetch_hits);
4272        let t1 = thread::spawn(move || {
4273            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
4274                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4275                fetch_started_tx.send(()).unwrap();
4276                release_fetch_rx.recv().unwrap();
4277                Ok(vec![sample_cached_model("fresh-model")])
4278            })
4279            .unwrap()
4280            .1
4281        });
4282
4283        fetch_started_rx.recv().unwrap();
4284
4285        let fetch_hits_b = Arc::clone(&fetch_hits);
4286        let t2 = thread::spawn(move || {
4287            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
4288                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4289                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4290            })
4291            .unwrap()
4292            .1
4293        });
4294
4295        release_fetch_tx.send(()).unwrap();
4296
4297        let outcome_a = t1.join().unwrap();
4298        let outcome_b = t2.join().unwrap();
4299
4300        let outcomes = [outcome_a, outcome_b];
4301        let refreshed = outcomes
4302            .iter()
4303            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
4304            .count();
4305        let already_fresh = outcomes
4306            .iter()
4307            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
4308            .count();
4309
4310        assert_eq!(refreshed, 1);
4311        assert_eq!(already_fresh, 1);
4312        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4313    }
4314
4315    #[test]
4316    #[serial]
4317    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
4318        let mars = tempdir().unwrap();
4319        write_cache_state(
4320            mars.path(),
4321            vec![sample_cached_model("stale-model")],
4322            &stale_timestamp(),
4323        );
4324
4325        let fetch_hits = Arc::new(AtomicUsize::new(0));
4326
4327        let fetch_hits_a = Arc::clone(&fetch_hits);
4328        let (_cache_a, outcome_a) =
4329            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4330                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4331                Err(MarsError::Http {
4332                    url: "https://example.test/api.json".to_string(),
4333                    status: 500,
4334                    message: "request failed with HTTP status 500".to_string(),
4335                })
4336            })
4337            .unwrap();
4338
4339        let fetch_hits_b = Arc::clone(&fetch_hits);
4340        let (_cache_b, outcome_b) =
4341            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4342                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4343                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4344            })
4345            .unwrap();
4346
4347        assert!(matches!(
4348            outcome_a,
4349            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
4350        ));
4351        assert_eq!(
4352            outcome_b,
4353            RefreshOutcome::StaleFallback {
4354                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4355            }
4356        );
4357        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4358    }
4359
4360    #[test]
4361    #[serial]
4362    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
4363        let mars = tempdir().unwrap();
4364        write_cache_state(
4365            mars.path(),
4366            vec![sample_cached_model("stale-model")],
4367            &stale_timestamp(),
4368        );
4369
4370        let fetch_hits = Arc::new(AtomicUsize::new(0));
4371
4372        let fetch_hits_a = Arc::clone(&fetch_hits);
4373        let (_cache_a, outcome_a) =
4374            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4375                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4376                Ok(Vec::new())
4377            })
4378            .unwrap();
4379
4380        let fetch_hits_b = Arc::clone(&fetch_hits);
4381        let (_cache_b, outcome_b) =
4382            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4383                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4384                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4385            })
4386            .unwrap();
4387
4388        assert!(matches!(
4389            outcome_a,
4390            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
4391        ));
4392        assert_eq!(
4393            outcome_b,
4394            RefreshOutcome::StaleFallback {
4395                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4396            }
4397        );
4398        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4399    }
4400
4401    #[test]
4402    fn merged_runtime_aliases_suppresses_builtins_when_cached_or_project_aliases_exist() {
4403        let mut dependency_aliases = IndexMap::new();
4404        dependency_aliases.insert("dep".to_string(), pinned_alias(Some("codex"), "dep-model"));
4405        dependency_aliases.insert(
4406            "override".to_string(),
4407            pinned_alias(Some("codex"), "dep-override"),
4408        );
4409
4410        let mut project_aliases = IndexMap::new();
4411        project_aliases.insert(
4412            "override".to_string(),
4413            pinned_alias(Some("claude"), "project-override"),
4414        );
4415        project_aliases.insert(
4416            "project".to_string(),
4417            pinned_alias(Some("pi"), "project-model"),
4418        );
4419
4420        let merged = merged_runtime_aliases(&dependency_aliases, Some(&project_aliases));
4421
4422        assert!(!merged.contains_key("opus"));
4423        assert_eq!(
4424            merged.get("dep").and_then(|alias| alias.harness.as_deref()),
4425            Some("codex")
4426        );
4427        assert_eq!(
4428            merged
4429                .get("override")
4430                .and_then(|alias| alias.harness.as_deref()),
4431            Some("claude")
4432        );
4433        assert_eq!(
4434            merged
4435                .get("project")
4436                .and_then(|alias| alias.harness.as_deref()),
4437            Some("pi")
4438        );
4439    }
4440
4441    #[test]
4442    fn merged_runtime_aliases_empty_project_uses_builtins() {
4443        let merged = merged_runtime_aliases(&IndexMap::new(), None);
4444
4445        assert!(merged.contains_key("opus"));
4446        assert!(merged.contains_key("sonnet"));
4447        assert!(merged.contains_key("codex"));
4448    }
4449}