Skip to main content

mars_agents/models/
mod.rs

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