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