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