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