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