Skip to main content

mars_agents/models/
mod.rs

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