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