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    resolve_with_alias_prefix_with_probe(input, aliases, cache, opencode_probe.as_ref(), None)
857}
858
859pub fn resolve_with_alias_prefix_with_probe(
860    input: &str,
861    aliases: &IndexMap<String, ModelAlias>,
862    cache: &ModelsCache,
863    opencode_probe: Option<&probes::OpenCodeProbeResult>,
864    pi_probe: Option<&probes::PiProbeResult>,
865) -> Option<ResolvedAlias> {
866    let pattern = if input.contains('*') {
867        input.to_string()
868    } else {
869        format!("*{}*", input)
870    };
871    let base_alias = alias_prefix_base(input, aliases);
872    let mut deduped: IndexMap<String, CachedModel> = IndexMap::new();
873
874    if let Some(alias) = base_alias
875        && let Some((model, provider)) = match &alias.spec {
876            ModelSpec::Pinned { model, provider } => Some((model, provider)),
877            ModelSpec::PinnedWithMatch {
878                model, provider, ..
879            } => Some((model, provider)),
880            ModelSpec::AutoResolve { .. } => None,
881        }
882    {
883        let provider_filter = provider
884            .as_deref()
885            .or_else(|| infer_provider_from_model_id(model));
886        for candidate in &cache.models {
887            if !glob_match(&pattern, &candidate.id) {
888                continue;
889            }
890            if let Some(provider_filter) = provider_filter
891                && !candidate.provider.eq_ignore_ascii_case(provider_filter)
892            {
893                continue;
894            }
895            deduped
896                .entry(candidate.id.clone())
897                .or_insert_with(|| candidate.clone());
898        }
899    }
900
901    for (_alias_name, alias) in aliases {
902        match &alias.spec {
903            ModelSpec::AutoResolve {
904                provider,
905                match_patterns,
906                exclude_patterns,
907            } => {
908                for candidate in auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
909                {
910                    if glob_match(&pattern, &candidate.id) {
911                        deduped
912                            .entry(candidate.id.clone())
913                            .or_insert_with(|| candidate.clone());
914                    }
915                }
916            }
917            ModelSpec::PinnedWithMatch {
918                model,
919                provider,
920                match_patterns,
921                exclude_patterns,
922            } => {
923                let Some(provider) = provider
924                    .as_deref()
925                    .or_else(|| infer_provider_from_model_id(model))
926                else {
927                    continue;
928                };
929                for candidate in auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
930                {
931                    if glob_match(&pattern, &candidate.id) {
932                        deduped
933                            .entry(candidate.id.clone())
934                            .or_insert_with(|| candidate.clone());
935                    }
936                }
937            }
938            ModelSpec::Pinned { .. } => {}
939        }
940    }
941
942    let mut candidates: Vec<CachedModel> = deduped.into_values().collect();
943    candidates.sort_by(|a, b| {
944        let date_cmp = b
945            .release_date
946            .as_deref()
947            .unwrap_or("")
948            .cmp(a.release_date.as_deref().unwrap_or(""));
949        date_cmp
950            .then_with(|| a.id.len().cmp(&b.id.len()))
951            .then_with(|| a.id.cmp(&b.id))
952    });
953
954    let winner = candidates.into_iter().next()?;
955    let provider = winner.provider.to_ascii_lowercase();
956    let (default_effort, autocompact, autocompact_pct) = match base_alias {
957        Some(ModelAlias {
958            default_effort,
959            autocompact,
960            autocompact_pct,
961            spec: ModelSpec::Pinned { .. } | ModelSpec::PinnedWithMatch { .. },
962            ..
963        }) => (default_effort.clone(), *autocompact, *autocompact_pct),
964        _ => (None, None, None),
965    };
966    let installed = harness::detect_installed_harnesses();
967    let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
968        model_id: &winner.id,
969        provider_for_order: Some(&provider),
970        provider_constraint: None,
971        settings_provider_order: None,
972        settings_harness_order: None,
973        config_default_harness: None,
974        installed_harnesses: &installed,
975        linked_harnesses: None,
976        opencode_probe_result: opencode_probe,
977        pi_probe_result: pi_probe,
978    });
979    let (harness, harness_source) = match crate::routing::acceptance::accept_route(
980        &trace,
981        &installed,
982        crate::routing::acceptance::MatchPolicy::InstalledOnly,
983    ) {
984        Ok(()) => (Some(trace.harness), HarnessSource::AutoDetected),
985        Err(_) => (None, HarnessSource::Unavailable),
986    };
987
988    Some(ResolvedAlias {
989        name: input.to_string(),
990        model_id: winner.id,
991        provider: provider.clone(),
992        harness,
993        harness_source,
994        harness_candidates: harness::harness_candidates_for_provider(&provider),
995        description: winner.description,
996        default_effort,
997        autocompact,
998        autocompact_pct,
999        availability: None,
1000    })
1001}
1002
1003fn alias_prefix_base<'a>(
1004    input: &str,
1005    aliases: &'a IndexMap<String, ModelAlias>,
1006) -> Option<&'a ModelAlias> {
1007    aliases
1008        .iter()
1009        .filter(|(name, _)| {
1010            !name.is_empty()
1011                && input.len() > name.len()
1012                && input.starts_with(name.as_str())
1013                && input.as_bytes().get(name.len()) == Some(&b'-')
1014        })
1015        .max_by_key(|(name, _)| name.len())
1016        .map(|(_, alias)| alias)
1017}
1018
1019/// Simple glob matching: `*` matches any sequence of characters.
1020/// Everything else is literal. Case-sensitive.
1021pub fn glob_match(pattern: &str, text: &str) -> bool {
1022    // Split pattern on '*' and match segments in order
1023    let segments: Vec<&str> = pattern.split('*').collect();
1024
1025    if segments.len() == 1 {
1026        // No wildcards — exact match
1027        return pattern == text;
1028    }
1029
1030    let mut pos = 0;
1031
1032    // First segment must be a prefix
1033    if let Some(first) = segments.first()
1034        && !first.is_empty()
1035    {
1036        if !text.starts_with(first) {
1037            return false;
1038        }
1039        pos = first.len();
1040    }
1041
1042    // Last segment must be a suffix
1043    if let Some(last) = segments.last()
1044        && !last.is_empty()
1045        && !text[pos..].ends_with(last)
1046    {
1047        return false;
1048    }
1049
1050    // Middle segments must appear in order
1051    let end = if let Some(last) = segments.last() {
1052        if !last.is_empty() {
1053            text.len() - last.len()
1054        } else {
1055            text.len()
1056        }
1057    } else {
1058        text.len()
1059    };
1060
1061    for segment in &segments[1..segments.len().saturating_sub(1)] {
1062        if segment.is_empty() {
1063            continue;
1064        }
1065        if let Some(idx) = text[pos..end].find(segment) {
1066            pos += idx + segment.len();
1067        } else {
1068            return false;
1069        }
1070    }
1071
1072    pos <= end
1073}
1074
1075/// Match a visibility pattern against a resolved model identity.
1076///
1077/// Pattern forms:
1078/// - 0 slashes: bare model ID, e.g. `gpt-5*`
1079/// - 1 slash: provider/model, e.g. `anthropic/*`
1080/// - 2 slashes: OpenCode runnable slug, e.g. `openrouter/anthropic/*`
1081pub fn matches_visibility_pattern(
1082    pattern: &str,
1083    model_id: &str,
1084    provider: &str,
1085    runnable_paths: &[availability::RunnablePath],
1086) -> bool {
1087    let pattern = pattern.to_ascii_lowercase();
1088    let slash_count = pattern.chars().filter(|c| *c == '/').count();
1089
1090    match slash_count {
1091        0 => glob_match_no_slash(&pattern, &model_id.to_ascii_lowercase()),
1092        1 => {
1093            let candidate = format!(
1094                "{}/{}",
1095                provider.to_ascii_lowercase(),
1096                model_id.to_ascii_lowercase()
1097            );
1098            glob_match_no_slash(&pattern, &candidate)
1099        }
1100        2 => runnable_paths
1101            .iter()
1102            .any(|path| glob_match_no_slash(&pattern, &path.harness_model_id.to_ascii_lowercase())),
1103        _ => false,
1104    }
1105}
1106
1107fn glob_match_no_slash(pattern: &str, text: &str) -> bool {
1108    let pattern_parts: Vec<&str> = pattern.split('*').collect();
1109    if pattern_parts.len() == 1 {
1110        return pattern == text;
1111    }
1112
1113    let mut pos = 0;
1114    for (i, part) in pattern_parts.iter().enumerate() {
1115        if part.is_empty() {
1116            continue;
1117        }
1118        let Some(found) = text[pos..].find(part) else {
1119            return false;
1120        };
1121        if i == 0 && found != 0 {
1122            return false;
1123        }
1124        if text[pos..pos + found].contains('/') {
1125            return false;
1126        }
1127        pos += found + part.len();
1128    }
1129
1130    if pattern.ends_with('*') {
1131        !text[pos..].contains('/')
1132    } else {
1133        pos == text.len()
1134    }
1135}
1136
1137// ---------------------------------------------------------------------------
1138// Builtin aliases — bare convenience mappings, no descriptions
1139// ---------------------------------------------------------------------------
1140
1141/// Minimal builtin aliases so common model names work out of the box.
1142/// No descriptions — packages layer those on top.
1143/// Precedence: consumer > deps > builtins.
1144pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
1145    let mut m = IndexMap::new();
1146    let add = |m: &mut IndexMap<String, ModelAlias>,
1147               name: &str,
1148               provider: &str,
1149               match_patterns: &[&str],
1150               exclude: &[&str]| {
1151        m.insert(
1152            name.to_string(),
1153            ModelAlias {
1154                harness: None,
1155                description: None,
1156                default_effort: None,
1157                autocompact: None,
1158                autocompact_pct: None,
1159                spec: ModelSpec::AutoResolve {
1160                    provider: provider.to_string(),
1161                    match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1162                    exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
1163                },
1164            },
1165        );
1166    };
1167    add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
1168    add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
1169    add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
1170    add(
1171        &mut m,
1172        "codex",
1173        "openai",
1174        &["*codex*"],
1175        &["*-mini", "*-spark", "*-max"],
1176    );
1177    add(
1178        &mut m,
1179        "gpt",
1180        "openai",
1181        &["gpt-5*"],
1182        &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
1183    );
1184    add(
1185        &mut m,
1186        "gemini",
1187        "google",
1188        &["gemini*", "*pro*"],
1189        &["*-customtools"],
1190    );
1191    m
1192}
1193
1194// ---------------------------------------------------------------------------
1195// Dependency-tree merge
1196// ---------------------------------------------------------------------------
1197
1198/// Info about a resolved dependency's model config.
1199pub struct ResolvedDepModels {
1200    pub source_name: String,
1201    pub models: IndexMap<String, ModelAlias>,
1202}
1203
1204/// Merge model aliases from dependency tree.
1205///
1206/// Precedence: consumer > deps (declaration order) > builtins.
1207/// When two deps define the same alias, first in declaration order wins
1208/// with a diagnostic warning.
1209pub fn merge_model_config(
1210    consumer: &IndexMap<String, ModelAlias>,
1211    deps: &[ResolvedDepModels],
1212    diag: &mut DiagnosticCollector,
1213    cache: Option<&ModelsCache>,
1214) -> IndexMap<String, ModelAlias> {
1215    #[derive(Clone)]
1216    struct DepWinner {
1217        source_name: String,
1218        alias: ModelAlias,
1219    }
1220
1221    let mut merged = IndexMap::new();
1222    let builtins = builtin_aliases();
1223
1224    // Layer 0 (lowest): builtins
1225    for (name, alias) in &builtins {
1226        merged.insert(name.clone(), alias.clone());
1227    }
1228
1229    // Track which dep won each alias (vs builtin)
1230    let mut dep_provided: std::collections::HashMap<String, DepWinner> =
1231        std::collections::HashMap::new();
1232
1233    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
1234    for dep in deps {
1235        for (name, alias) in &dep.models {
1236            if consumer.contains_key(name) {
1237                // Consumer will override — skip dep's version silently
1238                continue;
1239            }
1240            if let Some(winner) = dep_provided.get(name) {
1241                // Two deps define same alias — first dep wins, warn
1242                let message = if let Some(cache) = cache {
1243                    let (winner_formatted, winner_model_id) =
1244                        format_alias_resolution_for_diag(&winner.alias, &winner.source_name, cache);
1245                    let (loser_formatted, loser_model_id) =
1246                        format_alias_resolution_for_diag(alias, &dep.source_name, cache);
1247                    if winner_model_id.is_some() && winner_model_id == loser_model_id {
1248                        format!(
1249                            "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",
1250                            winner.source_name,
1251                            dep.source_name,
1252                            winner.source_name,
1253                            winner_model_id.unwrap_or_default(),
1254                        )
1255                    } else {
1256                        format!(
1257                            "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",
1258                            winner.source_name, dep.source_name, winner.source_name,
1259                        )
1260                    }
1261                } else {
1262                    format!(
1263                        "model alias `{name}` defined by both `{}` and `{}` — using {} (declared first)\n  → add [models.{name}] to your mars.toml to resolve explicitly",
1264                        winner.source_name, dep.source_name, winner.source_name,
1265                    )
1266                };
1267                diag.warn_with_context("model-alias-conflict", message, dep.source_name.clone());
1268            } else {
1269                // Override builtin or insert new
1270                merged.insert(name.clone(), alias.clone());
1271                dep_provided.insert(
1272                    name.clone(),
1273                    DepWinner {
1274                        source_name: dep.source_name.clone(),
1275                        alias: alias.clone(),
1276                    },
1277                );
1278            }
1279        }
1280    }
1281
1282    // Layer 2 (highest): consumer config
1283    for (name, alias) in consumer {
1284        merged.insert(name.clone(), alias.clone());
1285    }
1286
1287    merged
1288}
1289
1290/// Resolve all aliases to concrete model IDs + harnesses.
1291///
1292/// Harness detection is encapsulated — callers don't pass installed harnesses.
1293pub fn resolve_all(
1294    aliases: &IndexMap<String, ModelAlias>,
1295    cache: &ModelsCache,
1296    diag: &mut DiagnosticCollector,
1297) -> IndexMap<String, ResolvedAlias> {
1298    let opencode_probe = probes::opencode_cache::read_cached_probe_result_usable();
1299    resolve_all_with_probe(aliases, cache, diag, opencode_probe.as_ref(), None)
1300}
1301
1302pub fn resolve_all_with_probe(
1303    aliases: &IndexMap<String, ModelAlias>,
1304    cache: &ModelsCache,
1305    diag: &mut DiagnosticCollector,
1306    opencode_probe: Option<&probes::OpenCodeProbeResult>,
1307    pi_probe: Option<&probes::PiProbeResult>,
1308) -> IndexMap<String, ResolvedAlias> {
1309    let _ = diag;
1310    let installed = harness::detect_installed_harnesses();
1311    let mut resolved = IndexMap::new();
1312
1313    for (name, alias) in aliases {
1314        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
1315            continue; // unresolvable — omit
1316        };
1317
1318        let candidates = harness::harness_candidates_for_provider(&provider);
1319        let (h, source) = resolve_harness(
1320            alias,
1321            &provider,
1322            &model_id,
1323            &installed,
1324            opencode_probe,
1325            pi_probe,
1326        );
1327
1328        resolved.insert(
1329            name.clone(),
1330            ResolvedAlias {
1331                name: name.clone(),
1332                model_id,
1333                provider,
1334                harness: h,
1335                harness_source: source,
1336                harness_candidates: candidates,
1337                description: alias.description.clone(),
1338                default_effort: alias.default_effort.clone(),
1339                autocompact: alias.autocompact,
1340                autocompact_pct: alias.autocompact_pct,
1341                availability: None,
1342            },
1343        );
1344    }
1345
1346    resolved
1347}
1348
1349/// Resolve a single alias and emit diagnostics only for that alias.
1350pub fn resolve_one(
1351    name: &str,
1352    aliases: &IndexMap<String, ModelAlias>,
1353    cache: &ModelsCache,
1354    diag: &mut DiagnosticCollector,
1355) -> Option<ResolvedAlias> {
1356    let opencode_probe = probes::opencode_cache::read_cached_probe_result_usable();
1357    resolve_one_with_probe(name, aliases, cache, diag, opencode_probe.as_ref(), None)
1358}
1359
1360pub fn resolve_one_with_probe(
1361    name: &str,
1362    aliases: &IndexMap<String, ModelAlias>,
1363    cache: &ModelsCache,
1364    diag: &mut DiagnosticCollector,
1365    opencode_probe: Option<&probes::OpenCodeProbeResult>,
1366    pi_probe: Option<&probes::PiProbeResult>,
1367) -> Option<ResolvedAlias> {
1368    let alias = aliases.get(name)?;
1369    let installed = harness::detect_installed_harnesses();
1370    let (model_id, provider) = resolve_model_and_provider(alias, cache)?;
1371    let candidates = harness::harness_candidates_for_provider(&provider);
1372    let (harness, harness_source) = resolve_harness(
1373        alias,
1374        &provider,
1375        &model_id,
1376        &installed,
1377        opencode_probe,
1378        pi_probe,
1379    );
1380    let _ = diag;
1381    Some(ResolvedAlias {
1382        name: name.to_string(),
1383        model_id,
1384        provider,
1385        harness,
1386        harness_source,
1387        harness_candidates: candidates,
1388        description: alias.description.clone(),
1389        default_effort: alias.default_effort.clone(),
1390        autocompact: alias.autocompact,
1391        autocompact_pct: alias.autocompact_pct,
1392        availability: None,
1393    })
1394}
1395
1396/// Resolve a concrete model id for one alias.
1397///
1398/// Used by build-time launch routing so model resolution logic stays shared
1399/// with `mars models resolve`.
1400pub fn resolve_model_id_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1401    resolve_model_and_provider(alias, cache).map(|(model_id, _provider)| model_id)
1402}
1403
1404/// Resolve provider identity for one alias.
1405///
1406/// Returns `None` when provider cannot be inferred.
1407pub fn resolve_provider_for_alias(alias: &ModelAlias, cache: &ModelsCache) -> Option<String> {
1408    let provider = resolve_model_and_provider(alias, cache)
1409        .map(|(_model_id, provider)| provider)
1410        .or_else(|| provider_from_alias_spec(alias));
1411
1412    provider.filter(|value| !value.eq_ignore_ascii_case("unknown"))
1413}
1414
1415/// Filter resolved aliases by visibility config.
1416/// - `include` patterns: keep only aliases where at least one pattern matches
1417/// - `exclude` patterns: remove aliases where any pattern matches
1418/// - No config (both None): return all aliases unchanged
1419pub fn filter_by_visibility(
1420    mut aliases: IndexMap<String, ResolvedAlias>,
1421    visibility: &crate::config::ModelVisibility,
1422) -> IndexMap<String, ResolvedAlias> {
1423    let include = visibility
1424        .include
1425        .as_ref()
1426        .filter(|patterns| !patterns.is_empty());
1427    let exclude = visibility
1428        .exclude
1429        .as_ref()
1430        .filter(|patterns| !patterns.is_empty());
1431
1432    if include.is_none() && exclude.is_none() {
1433        return aliases;
1434    }
1435
1436    if let Some(includes) = include {
1437        aliases.retain(|_, alias| {
1438            let paths = alias
1439                .availability
1440                .as_ref()
1441                .map(|availability| availability.runnable_paths.as_slice())
1442                .unwrap_or(&[]);
1443            includes.iter().any(|pattern| {
1444                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1445            })
1446        });
1447    }
1448
1449    if let Some(excludes) = exclude {
1450        aliases.retain(|_, alias| {
1451            let paths = alias
1452                .availability
1453                .as_ref()
1454                .map(|availability| availability.runnable_paths.as_slice())
1455                .unwrap_or(&[]);
1456            !excludes.iter().any(|pattern| {
1457                matches_visibility_pattern(pattern, &alias.model_id, &alias.provider, paths)
1458            })
1459        });
1460    }
1461    aliases
1462}
1463
1464fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
1465    match &alias.spec {
1466        ModelSpec::Pinned {
1467            model, provider, ..
1468        } => {
1469            let p = provider
1470                .clone()
1471                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1472                .unwrap_or_else(|| "unknown".to_string());
1473            Some((model.clone(), p))
1474        }
1475        ModelSpec::PinnedWithMatch {
1476            model, provider, ..
1477        } => {
1478            let p = provider
1479                .clone()
1480                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
1481                .unwrap_or_else(|| "unknown".to_string());
1482            Some((model.clone(), p))
1483        }
1484        ModelSpec::AutoResolve {
1485            provider,
1486            match_patterns,
1487            exclude_patterns,
1488        } => {
1489            let model_id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
1490            Some((model_id, provider.clone()))
1491        }
1492    }
1493}
1494
1495fn provider_from_alias_spec(alias: &ModelAlias) -> Option<String> {
1496    match &alias.spec {
1497        ModelSpec::Pinned { model, provider }
1498        | ModelSpec::PinnedWithMatch {
1499            model, provider, ..
1500        } => provider
1501            .clone()
1502            .or_else(|| infer_provider_from_model_id(model).map(str::to_string)),
1503        ModelSpec::AutoResolve { provider, .. } => Some(provider.clone()),
1504    }
1505}
1506
1507fn provider_constraint_for_alias(alias: &ModelAlias) -> Option<String> {
1508    match &alias.spec {
1509        ModelSpec::Pinned { provider, .. } | ModelSpec::PinnedWithMatch { provider, .. } => {
1510            provider.clone()
1511        }
1512        ModelSpec::AutoResolve { provider, .. } => Some(provider.clone()),
1513    }
1514    .map(|provider| provider.trim().to_ascii_lowercase())
1515}
1516
1517fn format_alias_resolution_for_diag(
1518    alias: &ModelAlias,
1519    source_name: &str,
1520    cache: &ModelsCache,
1521) -> (String, Option<String>) {
1522    match &alias.spec {
1523        ModelSpec::Pinned { model, .. } => (
1524            format!("{source_name} → {model} (pinned)"),
1525            Some(model.clone()),
1526        ),
1527        ModelSpec::PinnedWithMatch { model, .. } => (
1528            format!("{source_name} → {model} (pinned+match)"),
1529            Some(model.clone()),
1530        ),
1531        ModelSpec::AutoResolve {
1532            provider,
1533            match_patterns,
1534            exclude_patterns,
1535        } => {
1536            let resolved = auto_resolve(provider, match_patterns, exclude_patterns, cache);
1537            match resolved {
1538                Some(model_id) => (format!("{source_name} → {model_id}"), Some(model_id)),
1539                None => (format!("{source_name} → <unresolvable>"), None),
1540            }
1541        }
1542    }
1543}
1544
1545fn resolve_harness(
1546    alias: &ModelAlias,
1547    provider: &str,
1548    model_id: &str,
1549    installed: &HashSet<String>,
1550    opencode_probe_result: Option<&probes::OpenCodeProbeResult>,
1551    pi_probe_result: Option<&probes::PiProbeResult>,
1552) -> (Option<String>, HarnessSource) {
1553    if let Some(h) = &alias.harness {
1554        if installed.contains(h) {
1555            (Some(h.clone()), HarnessSource::Explicit)
1556        } else {
1557            (Some(h.clone()), HarnessSource::Unavailable)
1558        }
1559    } else {
1560        let provider_constraint = provider_constraint_for_alias(alias);
1561        let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
1562            model_id,
1563            provider_for_order: Some(provider),
1564            provider_constraint: provider_constraint.as_deref(),
1565            settings_provider_order: None,
1566            settings_harness_order: None,
1567            config_default_harness: None,
1568            installed_harnesses: installed,
1569            linked_harnesses: None,
1570            opencode_probe_result,
1571            pi_probe_result,
1572        });
1573        match crate::routing::acceptance::accept_route(
1574            &trace,
1575            installed,
1576            crate::routing::acceptance::MatchPolicy::InstalledOnly,
1577        ) {
1578            Ok(()) => (Some(trace.harness), HarnessSource::AutoDetected),
1579            Err(_) => (None, HarnessSource::Unavailable),
1580        }
1581    }
1582}
1583
1584/// Best-effort provider inference from model ID prefixes.
1585/// Returns None for unrecognized patterns.
1586pub fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
1587    let id = model_id.to_lowercase();
1588    if id.starts_with("claude-") {
1589        return Some("anthropic");
1590    }
1591    if id.starts_with("gpt-")
1592        || id.starts_with("o1")
1593        || id.starts_with("o3")
1594        || id.starts_with("o4")
1595        || id.starts_with("codex-")
1596    {
1597        return Some("openai");
1598    }
1599    if id.starts_with("gemini") {
1600        return Some("google");
1601    }
1602    if id.starts_with("llama") {
1603        return Some("meta");
1604    }
1605    if id.starts_with("mistral") || id.starts_with("codestral") {
1606        return Some("mistral");
1607    }
1608    if id.starts_with("deepseek") {
1609        return Some("deepseek");
1610    }
1611    if id.starts_with("command") {
1612        return Some("cohere");
1613    }
1614    None
1615}
1616
1617/// Split a token shaped like `provider/model` into `(model, provider_constraint)`.
1618///
1619/// Returns `(trimmed_token, None)` when the token is not a valid constrained slug.
1620pub fn split_provider_constrained_model_token(token: &str) -> (String, Option<String>) {
1621    let trimmed = token.trim();
1622    let Some((provider, model_name)) = trimmed.split_once('/') else {
1623        return (trimmed.to_string(), None);
1624    };
1625    let provider = provider.trim();
1626    let model_name = model_name.trim();
1627    if provider.is_empty() || model_name.is_empty() {
1628        return (trimmed.to_string(), None);
1629    }
1630    (model_name.to_string(), Some(provider.to_ascii_lowercase()))
1631}
1632
1633// ---------------------------------------------------------------------------
1634// Tests
1635// ---------------------------------------------------------------------------
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640    use httpmock::prelude::*;
1641    use std::collections::HashSet;
1642    use std::sync::atomic::{AtomicUsize, Ordering};
1643    use std::sync::{Arc, mpsc};
1644    use std::thread;
1645    use tempfile::tempdir;
1646
1647    use serial_test::serial;
1648
1649    #[test]
1650    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
1651        let raw = serde_json::json!({
1652            "anthropic": {
1653                "models": {
1654                    "claude-opus-4-6": {
1655                        "id": "claude-opus-4-6",
1656                        "name": "Claude Opus 4.6",
1657                        "release_date": "2026-02-05",
1658                        "limit": {
1659                            "context": 1000000,
1660                            "output": 128000
1661                        },
1662                        "cost": {
1663                            "input": 5.0,
1664                            "output": 25.0,
1665                            "cache_read": 0.5,
1666                            "cache_write": 6.25,
1667                            "reasoning": 15.0
1668                        }
1669                    }
1670                }
1671            },
1672            "openai": {
1673                "models": {
1674                    "gpt-5": {
1675                        "id": "gpt-5",
1676                        "name": "GPT-5"
1677                    }
1678                }
1679            },
1680            "random-host": {
1681                "models": {
1682                    "foo": {
1683                        "id": "foo"
1684                    }
1685                }
1686            }
1687        });
1688
1689        let models = parse_models_dev_catalog(&raw).unwrap();
1690        assert_eq!(models.len(), 2);
1691
1692        let opus = models
1693            .iter()
1694            .find(|m| m.id == "claude-opus-4-6")
1695            .expect("missing claude-opus-4-6");
1696        assert_eq!(opus.provider, "Anthropic");
1697        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1698        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1699        assert_eq!(opus.context_window, Some(1_000_000));
1700        assert_eq!(opus.max_output, Some(128_000));
1701        assert_eq!(opus.cost_input, Some(5.0));
1702        assert_eq!(opus.cost_output, Some(25.0));
1703        assert_eq!(opus.cost_cache_read, Some(0.5));
1704        assert_eq!(opus.cost_cache_write, Some(6.25));
1705        assert_eq!(opus.cost_reasoning, Some(15.0));
1706
1707        let gpt = models
1708            .iter()
1709            .find(|m| m.id == "gpt-5")
1710            .expect("missing gpt-5");
1711        assert_eq!(gpt.provider, "OpenAI");
1712        assert_eq!(gpt.release_date, None);
1713        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1714        assert_eq!(gpt.context_window, None);
1715        assert_eq!(gpt.max_output, None);
1716        assert_eq!(gpt.cost_input, None);
1717        assert_eq!(gpt.cost_output, None);
1718        assert_eq!(gpt.cost_cache_read, None);
1719        assert_eq!(gpt.cost_cache_write, None);
1720        assert_eq!(gpt.cost_reasoning, None);
1721    }
1722
1723    #[test]
1724    fn parse_models_dev_catalog_requires_object_root() {
1725        let raw = serde_json::json!(["not", "an", "object"]);
1726        let err = parse_models_dev_catalog(&raw).unwrap_err();
1727        assert!(err.to_string().contains("keyed by provider"));
1728    }
1729
1730    // -- glob_match tests --
1731
1732    #[test]
1733    fn glob_exact_match() {
1734        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1735        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1736    }
1737
1738    #[test]
1739    fn glob_star_suffix() {
1740        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1741        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1742        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1743    }
1744
1745    #[test]
1746    fn glob_star_prefix() {
1747        assert!(glob_match("*-opus-4", "claude-opus-4"));
1748        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1749    }
1750
1751    #[test]
1752    fn glob_star_middle() {
1753        assert!(glob_match("claude-*-4", "claude-opus-4"));
1754        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1755        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1756    }
1757
1758    #[test]
1759    fn glob_multiple_stars() {
1760        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1761        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1762        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1763    }
1764
1765    #[test]
1766    fn glob_star_only() {
1767        assert!(glob_match("*", "anything"));
1768        assert!(glob_match("*", ""));
1769    }
1770
1771    #[test]
1772    fn glob_empty_pattern() {
1773        assert!(glob_match("", ""));
1774        assert!(!glob_match("", "something"));
1775    }
1776
1777    // -- auto_resolve tests --
1778
1779    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1780        ModelsCache {
1781            models: models
1782                .into_iter()
1783                .map(|(id, provider, date)| CachedModel {
1784                    id: id.to_string(),
1785                    provider: provider.to_string(),
1786                    release_date: date.map(String::from),
1787                    description: None,
1788                    context_window: None,
1789                    max_output: None,
1790                    cost_input: None,
1791                    cost_output: None,
1792                    cost_cache_read: None,
1793                    cost_cache_write: None,
1794                    cost_reasoning: None,
1795                })
1796                .collect(),
1797            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1798        }
1799    }
1800
1801    #[test]
1802    fn auto_resolve_basic() {
1803        let cache = make_cache(vec![
1804            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1805            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1806            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1807        ]);
1808
1809        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1810        // Newest date wins
1811        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1812    }
1813
1814    #[test]
1815    fn auto_resolve_exclude() {
1816        let cache = make_cache(vec![
1817            ("gpt-5", "OpenAI", Some("2025-06-01")),
1818            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1819            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1820        ]);
1821
1822        let result = auto_resolve(
1823            "OpenAI",
1824            &["gpt-*".to_string()],
1825            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1826            &cache,
1827        );
1828        assert_eq!(result, Some("gpt-5".to_string()));
1829    }
1830
1831    #[test]
1832    fn auto_resolve_skip_latest() {
1833        let cache = make_cache(vec![
1834            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1835            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1836        ]);
1837
1838        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1839        // Should skip -latest even though it has a newer date
1840        assert_eq!(result, Some("claude-opus-4".to_string()));
1841    }
1842
1843    #[test]
1844    fn auto_resolve_empty_cache() {
1845        let cache = ModelsCache {
1846            models: Vec::new(),
1847            fetched_at: None,
1848        };
1849
1850        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1851        assert_eq!(result, None);
1852    }
1853
1854    #[test]
1855    fn auto_resolve_no_match() {
1856        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1857
1858        let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1859        assert_eq!(result, None);
1860    }
1861
1862    #[test]
1863    fn auto_resolve_provider_case_insensitive() {
1864        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1865
1866        let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1867        assert_eq!(result, Some("claude-opus-4".to_string()));
1868    }
1869
1870    #[test]
1871    fn auto_resolve_shortest_id_tiebreaker() {
1872        let cache = make_cache(vec![
1873            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1874            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1875        ]);
1876
1877        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1878        // Same date — shorter ID wins
1879        assert_eq!(result, Some("claude-opus-4".to_string()));
1880    }
1881
1882    #[test]
1883    fn auto_resolve_lexical_id_tiebreaker_when_date_and_length_equal() {
1884        let cache = make_cache(vec![
1885            ("claude-opus-4-b", "Anthropic", Some("2025-03-01")),
1886            ("claude-opus-4-a", "Anthropic", Some("2025-03-01")),
1887        ]);
1888
1889        let result = auto_resolve("Anthropic", &["claude-opus-4-*".to_string()], &[], &cache);
1890        // Same date + same length — lexical ID wins for deterministic ordering.
1891        assert_eq!(result, Some("claude-opus-4-a".to_string()));
1892    }
1893
1894    #[test]
1895    fn auto_resolve_all_returns_all_candidates() {
1896        let cache = make_cache(vec![
1897            ("claude-opus-4-5", "Anthropic", Some("2025-12-01")),
1898            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1899            ("claude-opus-4-6-long", "Anthropic", Some("2026-02-05")),
1900            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1901            ("claude-opus-3", "Anthropic", Some("2024-02-05")),
1902        ]);
1903
1904        let result = auto_resolve_all(
1905            "Anthropic",
1906            &["claude-opus-*".to_string()],
1907            &["*opus-3".to_string()],
1908            &cache,
1909        );
1910        let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect();
1911        assert_eq!(
1912            ids,
1913            vec!["claude-opus-4-6", "claude-opus-4-6-long", "claude-opus-4-5"]
1914        );
1915    }
1916
1917    // -- merge_model_config tests --
1918
1919    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1920        ModelAlias {
1921            harness: harness.map(|h| h.to_string()),
1922            description: None,
1923            default_effort: None,
1924            autocompact: None,
1925            autocompact_pct: None,
1926            spec: ModelSpec::Pinned {
1927                model: model.to_string(),
1928                provider: None,
1929            },
1930        }
1931    }
1932
1933    fn auto_alias(
1934        provider: &str,
1935        match_patterns: &[&str],
1936        exclude_patterns: &[&str],
1937    ) -> ModelAlias {
1938        ModelAlias {
1939            harness: None,
1940            description: None,
1941            default_effort: None,
1942            autocompact: None,
1943            autocompact_pct: None,
1944            spec: ModelSpec::AutoResolve {
1945                provider: provider.to_string(),
1946                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1947                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1948            },
1949        }
1950    }
1951
1952    fn pinned_match_alias(
1953        model: &str,
1954        provider: &str,
1955        match_patterns: &[&str],
1956        exclude_patterns: &[&str],
1957    ) -> ModelAlias {
1958        ModelAlias {
1959            harness: None,
1960            description: None,
1961            default_effort: None,
1962            autocompact: None,
1963            autocompact_pct: None,
1964            spec: ModelSpec::PinnedWithMatch {
1965                model: model.to_string(),
1966                provider: Some(provider.to_string()),
1967                match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
1968                exclude_patterns: exclude_patterns.iter().map(|s| s.to_string()).collect(),
1969            },
1970        }
1971    }
1972
1973    #[test]
1974    fn resolve_with_alias_prefix_basic() {
1975        let aliases = builtin_aliases();
1976        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
1977
1978        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
1979        assert_eq!(resolved.name, "opus-4-6");
1980        assert_eq!(resolved.model_id, "claude-opus-4-6");
1981        assert_eq!(resolved.provider, "anthropic");
1982        assert_eq!(
1983            resolved.harness_candidates,
1984            vec!["claude", "pi", "opencode", "cursor"]
1985        );
1986
1987        let installed = harness::detect_installed_harnesses();
1988        let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
1989            model_id: "claude-opus-4-6",
1990            provider_for_order: Some("anthropic"),
1991            provider_constraint: None,
1992            settings_provider_order: None,
1993            settings_harness_order: None,
1994            config_default_harness: None,
1995            installed_harnesses: &installed,
1996            linked_harnesses: None,
1997            opencode_probe_result: None,
1998            pi_probe_result: None,
1999        });
2000        let (expected_harness, expected_source) = if installed.contains(&trace.harness) {
2001            (Some(trace.harness), HarnessSource::AutoDetected)
2002        } else {
2003            (None, HarnessSource::Unavailable)
2004        };
2005        assert_eq!(resolved.harness, expected_harness);
2006        assert_eq!(resolved.harness_source, expected_source);
2007    }
2008
2009    #[test]
2010    fn resolve_with_alias_prefix_no_candidates() {
2011        let aliases = builtin_aliases();
2012        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
2013
2014        let resolved = resolve_with_alias_prefix("opus-9-9", &aliases, &cache);
2015        assert!(resolved.is_none());
2016    }
2017
2018    #[test]
2019    fn resolve_with_alias_prefix_picks_newest() {
2020        let aliases = builtin_aliases();
2021        let cache = make_cache(vec![
2022            ("claude-opus-4-6-20250101", "Anthropic", Some("2025-01-01")),
2023            ("claude-opus-4-6-20260101", "Anthropic", Some("2026-01-01")),
2024        ]);
2025
2026        let resolved = resolve_with_alias_prefix("opus-4-6", &aliases, &cache).unwrap();
2027        assert_eq!(resolved.model_id, "claude-opus-4-6-20260101");
2028    }
2029
2030    #[test]
2031    fn resolve_with_alias_prefix_lexical_id_tiebreaker_when_date_and_length_equal() {
2032        let aliases = builtin_aliases();
2033        let cache = make_cache(vec![
2034            ("claude-opus-4-b", "Anthropic", Some("2026-02-05")),
2035            ("claude-opus-4-a", "Anthropic", Some("2026-02-05")),
2036        ]);
2037
2038        let resolved = resolve_with_alias_prefix("opus-4-", &aliases, &cache).unwrap();
2039        assert_eq!(resolved.model_id, "claude-opus-4-a");
2040    }
2041
2042    #[test]
2043    fn resolve_with_alias_prefix_pinned_base_inherits_defaults() {
2044        let mut aliases = IndexMap::new();
2045        let mut alias = pinned_alias(Some("claude"), "claude-opus-4-6");
2046        alias.default_effort = Some("high".to_string());
2047        alias.autocompact = Some(42);
2048        aliases.insert("opus".to_string(), alias);
2049        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
2050
2051        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
2052        assert_eq!(resolved.model_id, "claude-opus-4-7");
2053        assert_eq!(resolved.default_effort.as_deref(), Some("high"));
2054        assert_eq!(resolved.autocompact, Some(42));
2055    }
2056
2057    #[test]
2058    fn resolve_with_alias_prefix_auto_base_does_not_inherit_defaults() {
2059        let mut aliases = IndexMap::new();
2060        let mut alias = auto_alias("anthropic", &["claude-opus-*"], &[]);
2061        alias.default_effort = Some("high".to_string());
2062        alias.autocompact = Some(42);
2063        aliases.insert("opus".to_string(), alias);
2064        let cache = make_cache(vec![("claude-opus-4-7", "Anthropic", Some("2026-04-16"))]);
2065
2066        let resolved = resolve_with_alias_prefix("opus-4-7", &aliases, &cache).unwrap();
2067        assert_eq!(resolved.model_id, "claude-opus-4-7");
2068        assert_eq!(resolved.default_effort, None);
2069        assert_eq!(resolved.autocompact, None);
2070    }
2071
2072    #[test]
2073    fn resolve_with_alias_prefix_exact_name_matches() {
2074        // When the input equals an alias name, this function still finds matches
2075        // via glob *opus*. The caller (run_resolve) handles exact alias lookup
2076        // before calling this function, so this path is only reached for
2077        // non-alias inputs in practice.
2078        let aliases = builtin_aliases();
2079        let cache = make_cache(vec![("claude-opus-4-6", "Anthropic", Some("2026-02-05"))]);
2080
2081        let resolved = resolve_with_alias_prefix("opus", &aliases, &cache);
2082        assert!(resolved.is_some());
2083        assert_eq!(resolved.unwrap().model_id, "claude-opus-4-6");
2084    }
2085
2086    #[test]
2087    fn resolve_with_alias_prefix_multiple_aliases_union() {
2088        let mut aliases = IndexMap::new();
2089        aliases.insert(
2090            "g".to_string(),
2091            auto_alias("openai", &["gpt-2026-08*"], &[]),
2092        );
2093        aliases.insert(
2094            "gpt".to_string(),
2095            auto_alias("openai", &["gpt-2026-03*"], &[]),
2096        );
2097        let cache = make_cache(vec![
2098            ("gpt-2026-03-01", "OpenAI", Some("2026-03-01")),
2099            ("gpt-2026-08-07", "OpenAI", Some("2026-08-07")),
2100        ]);
2101
2102        let resolved = resolve_with_alias_prefix("gpt-2026", &aliases, &cache).unwrap();
2103        assert_eq!(resolved.model_id, "gpt-2026-08-07");
2104    }
2105
2106    #[test]
2107    fn merge_empty_returns_builtins() {
2108        let mut diag = DiagnosticCollector::new();
2109        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag, None);
2110        // Empty consumer + no deps = builtins only
2111        assert!(merged.contains_key("opus"));
2112        assert!(merged.contains_key("sonnet"));
2113        assert!(merged.contains_key("codex"));
2114    }
2115
2116    #[test]
2117    fn merge_consumer_overrides_dependency_alias() {
2118        let mut consumer = IndexMap::new();
2119        consumer.insert(
2120            "opus".to_string(),
2121            pinned_alias(Some("custom"), "my-opus-model"),
2122        );
2123
2124        let mut diag = DiagnosticCollector::new();
2125        let merged = merge_model_config(&consumer, &[], &mut diag, None);
2126        assert_eq!(
2127            merged.get("opus").unwrap().spec,
2128            ModelSpec::Pinned {
2129                model: "my-opus-model".to_string(),
2130                provider: None
2131            }
2132        );
2133    }
2134
2135    #[test]
2136    fn merge_dep_overrides_builtin() {
2137        let dep = ResolvedDepModels {
2138            source_name: "my-pkg".to_string(),
2139            models: {
2140                let mut m = IndexMap::new();
2141                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
2142                m
2143            },
2144        };
2145
2146        let mut diag = DiagnosticCollector::new();
2147        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag, None);
2148        // Dep overrides builtin
2149        assert_eq!(
2150            merged.get("opus").unwrap().spec,
2151            ModelSpec::Pinned {
2152                model: "pkg-opus".to_string(),
2153                provider: None
2154            }
2155        );
2156    }
2157
2158    #[test]
2159    fn merge_consumer_beats_dep() {
2160        let mut consumer = IndexMap::new();
2161        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
2162
2163        let dep = ResolvedDepModels {
2164            source_name: "pkg".to_string(),
2165            models: {
2166                let mut m = IndexMap::new();
2167                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
2168                m
2169            },
2170        };
2171
2172        let mut diag = DiagnosticCollector::new();
2173        let merged = merge_model_config(&consumer, &[dep], &mut diag, None);
2174        assert_eq!(
2175            merged.get("opus").unwrap().spec,
2176            ModelSpec::Pinned {
2177                model: "consumer-opus".to_string(),
2178                provider: None
2179            }
2180        );
2181    }
2182
2183    #[test]
2184    fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
2185        let dep1 = ResolvedDepModels {
2186            source_name: "pkg-a".to_string(),
2187            models: {
2188                let mut m = IndexMap::new();
2189                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2190                m
2191            },
2192        };
2193        let dep2 = ResolvedDepModels {
2194            source_name: "pkg-b".to_string(),
2195            models: {
2196                let mut m = IndexMap::new();
2197                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2198                m
2199            },
2200        };
2201
2202        let mut diag = DiagnosticCollector::new();
2203        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2204        // First dep wins
2205        assert_eq!(
2206            merged.get("custom").unwrap().spec,
2207            ModelSpec::Pinned {
2208                model: "model-a".to_string(),
2209                provider: None
2210            }
2211        );
2212        // Should have warned
2213        let warnings = diag.drain();
2214        assert_eq!(warnings.len(), 1);
2215        assert_eq!(warnings[0].code, "model-alias-conflict");
2216        assert_eq!(
2217            warnings[0].message,
2218            "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"
2219        );
2220    }
2221
2222    #[test]
2223    fn merge_dep_conflict_with_cache_shows_resolution_diff() {
2224        let cache = make_cache(vec![
2225            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2226            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2227        ]);
2228        let dep1 = ResolvedDepModels {
2229            source_name: "dep-a".to_string(),
2230            models: {
2231                let mut m = IndexMap::new();
2232                m.insert(
2233                    "opus".to_string(),
2234                    pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2235                );
2236                m
2237            },
2238        };
2239        let dep2 = ResolvedDepModels {
2240            source_name: "dep-b".to_string(),
2241            models: {
2242                let mut m = IndexMap::new();
2243                m.insert(
2244                    "opus".to_string(),
2245                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2246                );
2247                m
2248            },
2249        };
2250
2251        let mut diag = DiagnosticCollector::new();
2252        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2253        let warnings = diag.drain();
2254        assert_eq!(warnings.len(), 1);
2255        let message = &warnings[0].message;
2256        assert!(message.contains("dep-a → claude-opus-4-6 (pinned+match)"));
2257        assert!(message.contains("dep-b → claude-opus-4-7 (pinned+match)"));
2258    }
2259
2260    #[test]
2261    fn merge_dep_conflict_with_cache_same_resolution() {
2262        let cache = make_cache(vec![
2263            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2264            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2265        ]);
2266        let dep1 = ResolvedDepModels {
2267            source_name: "dep-a".to_string(),
2268            models: {
2269                let mut m = IndexMap::new();
2270                m.insert(
2271                    "opus".to_string(),
2272                    pinned_match_alias("claude-opus-4-7", "Anthropic", &["claude-opus-*"], &[]),
2273                );
2274                m
2275            },
2276        };
2277        let dep2 = ResolvedDepModels {
2278            source_name: "dep-b".to_string(),
2279            models: {
2280                let mut m = IndexMap::new();
2281                m.insert(
2282                    "opus".to_string(),
2283                    auto_alias("Anthropic", &["claude-opus-*"], &[]),
2284                );
2285                m
2286            },
2287        };
2288
2289        let mut diag = DiagnosticCollector::new();
2290        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, Some(&cache));
2291        let warnings = diag.drain();
2292        assert_eq!(warnings.len(), 1);
2293        assert!(
2294            warnings[0]
2295                .message
2296                .contains("both resolve to claude-opus-4-7")
2297        );
2298    }
2299
2300    #[test]
2301    fn merge_dep_conflict_without_cache_uses_old_format() {
2302        let dep1 = ResolvedDepModels {
2303            source_name: "dep-a".to_string(),
2304            models: {
2305                let mut m = IndexMap::new();
2306                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2307                m
2308            },
2309        };
2310        let dep2 = ResolvedDepModels {
2311            source_name: "dep-b".to_string(),
2312            models: {
2313                let mut m = IndexMap::new();
2314                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2315                m
2316            },
2317        };
2318
2319        let mut diag = DiagnosticCollector::new();
2320        let _merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2321        let warnings = diag.drain();
2322        assert_eq!(warnings.len(), 1);
2323        assert_eq!(
2324            warnings[0].message,
2325            "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"
2326        );
2327    }
2328
2329    #[test]
2330    fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
2331        let dep1 = ResolvedDepModels {
2332            source_name: "pkg-a".to_string(),
2333            models: {
2334                let mut m = IndexMap::new();
2335                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2336                m
2337            },
2338        };
2339        let dep2 = ResolvedDepModels {
2340            source_name: "pkg-b".to_string(),
2341            models: {
2342                let mut m = IndexMap::new();
2343                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2344                m
2345            },
2346        };
2347        let dep3 = ResolvedDepModels {
2348            source_name: "pkg-c".to_string(),
2349            models: {
2350                let mut m = IndexMap::new();
2351                m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
2352                m
2353            },
2354        };
2355
2356        let mut diag = DiagnosticCollector::new();
2357        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag, None);
2358
2359        assert_eq!(
2360            merged.get("custom").unwrap().spec,
2361            ModelSpec::Pinned {
2362                model: "model-a".to_string(),
2363                provider: None
2364            }
2365        );
2366
2367        let warnings = diag.drain();
2368        assert_eq!(warnings.len(), 2);
2369        assert_eq!(
2370            warnings[0].message,
2371            "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"
2372        );
2373        assert_eq!(
2374            warnings[1].message,
2375            "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"
2376        );
2377    }
2378
2379    #[test]
2380    fn merge_consumer_override_suppresses_dep_conflict_warning() {
2381        let mut consumer = IndexMap::new();
2382        consumer.insert(
2383            "custom".to_string(),
2384            pinned_alias(Some("consumer"), "consumer-model"),
2385        );
2386
2387        let dep1 = ResolvedDepModels {
2388            source_name: "pkg-a".to_string(),
2389            models: {
2390                let mut m = IndexMap::new();
2391                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2392                m
2393            },
2394        };
2395        let dep2 = ResolvedDepModels {
2396            source_name: "pkg-b".to_string(),
2397            models: {
2398                let mut m = IndexMap::new();
2399                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2400                m
2401            },
2402        };
2403
2404        let mut diag = DiagnosticCollector::new();
2405        let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag, None);
2406
2407        assert_eq!(
2408            merged.get("custom").unwrap().spec,
2409            ModelSpec::Pinned {
2410                model: "consumer-model".to_string(),
2411                provider: None
2412            }
2413        );
2414        assert!(diag.drain().is_empty());
2415    }
2416
2417    #[test]
2418    fn merge_dep_conflicts_are_non_blocking() {
2419        let dep1 = ResolvedDepModels {
2420            source_name: "pkg-a".to_string(),
2421            models: {
2422                let mut m = IndexMap::new();
2423                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
2424                m
2425            },
2426        };
2427        let dep2 = ResolvedDepModels {
2428            source_name: "pkg-b".to_string(),
2429            models: {
2430                let mut m = IndexMap::new();
2431                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
2432                m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
2433                m
2434            },
2435        };
2436
2437        let mut diag = DiagnosticCollector::new();
2438        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag, None);
2439
2440        assert!(merged.contains_key("opus"));
2441        assert_eq!(
2442            merged.get("custom").unwrap().spec,
2443            ModelSpec::Pinned {
2444                model: "model-a".to_string(),
2445                provider: None
2446            }
2447        );
2448        assert_eq!(
2449            merged.get("extra").unwrap().spec,
2450            ModelSpec::Pinned {
2451                model: "model-extra".to_string(),
2452                provider: None
2453            }
2454        );
2455        assert_eq!(diag.drain().len(), 1);
2456    }
2457
2458    // -- resolve_all tests --
2459
2460    #[test]
2461    fn resolve_all_pinned() {
2462        let mut aliases = IndexMap::new();
2463        aliases.insert(
2464            "fast".to_string(),
2465            pinned_alias(Some("claude"), "claude-haiku-4-5"),
2466        );
2467
2468        let cache = ModelsCache {
2469            models: Vec::new(),
2470            fetched_at: None,
2471        };
2472
2473        let mut diag = DiagnosticCollector::new();
2474        let resolved = resolve_all(&aliases, &cache, &mut diag);
2475        let entry = resolved.get("fast").unwrap();
2476        assert_eq!(entry.model_id, "claude-haiku-4-5");
2477        assert_eq!(entry.provider, "anthropic");
2478    }
2479
2480    #[test]
2481    fn resolve_all_copies_alias_defaults() {
2482        let mut aliases = IndexMap::new();
2483        let mut alias = pinned_alias(Some("claude"), "claude-haiku-4-5");
2484        alias.default_effort = Some("medium".to_string());
2485        alias.autocompact = Some(30);
2486        aliases.insert("fast".to_string(), alias);
2487
2488        let cache = ModelsCache {
2489            models: Vec::new(),
2490            fetched_at: None,
2491        };
2492
2493        let mut diag = DiagnosticCollector::new();
2494        let resolved = resolve_all(&aliases, &cache, &mut diag);
2495        let entry = resolved.get("fast").unwrap();
2496        assert_eq!(entry.default_effort.as_deref(), Some("medium"));
2497        assert_eq!(entry.autocompact, Some(30));
2498    }
2499
2500    #[test]
2501    fn resolve_all_pinned_with_provider() {
2502        let mut aliases = IndexMap::new();
2503        aliases.insert(
2504            "fast".to_string(),
2505            ModelAlias {
2506                harness: None,
2507                description: None,
2508                default_effort: None,
2509                autocompact: None,
2510                autocompact_pct: None,
2511                spec: ModelSpec::Pinned {
2512                    model: "gpt-5.3-codex".to_string(),
2513                    provider: Some("openai".to_string()),
2514                },
2515            },
2516        );
2517
2518        let cache = ModelsCache {
2519            models: Vec::new(),
2520            fetched_at: None,
2521        };
2522
2523        let mut diag = DiagnosticCollector::new();
2524        let resolved = resolve_all(&aliases, &cache, &mut diag);
2525        let entry = resolved.get("fast").unwrap();
2526        assert_eq!(entry.model_id, "gpt-5.3-codex");
2527        assert_eq!(entry.provider, "openai");
2528        assert_eq!(
2529            entry.harness_candidates,
2530            vec!["codex", "pi", "opencode", "cursor"]
2531        );
2532    }
2533
2534    #[test]
2535    fn resolve_all_pinned_auto_detect_harness() {
2536        let mut aliases = IndexMap::new();
2537        aliases.insert(
2538            "opus".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: "claude-opus-4-6".to_string(),
2547                    provider: Some("anthropic".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("opus").unwrap();
2560        assert_eq!(entry.model_id, "claude-opus-4-6");
2561        assert_eq!(entry.provider, "anthropic");
2562
2563        let installed = harness::detect_installed_harnesses();
2564        let trace = crate::routing::evaluate_candidates(&crate::routing::RoutingInput {
2565            model_id: "claude-opus-4-6",
2566            provider_for_order: Some("anthropic"),
2567            provider_constraint: None,
2568            settings_provider_order: None,
2569            settings_harness_order: None,
2570            config_default_harness: None,
2571            installed_harnesses: &installed,
2572            linked_harnesses: None,
2573            opencode_probe_result: None,
2574            pi_probe_result: None,
2575        });
2576        let (expected_harness, expected_source) = if installed.contains(&trace.harness) {
2577            (Some(trace.harness), HarnessSource::AutoDetected)
2578        } else {
2579            (None, HarnessSource::Unavailable)
2580        };
2581
2582        assert_eq!(entry.harness, expected_harness);
2583        assert_eq!(entry.harness_source, expected_source);
2584    }
2585
2586    #[test]
2587    fn resolve_all_auto_detect_harness() {
2588        let mut aliases = IndexMap::new();
2589        aliases.insert(
2590            "gpt".to_string(),
2591            ModelAlias {
2592                harness: None,
2593                description: None,
2594                default_effort: None,
2595                autocompact: None,
2596                autocompact_pct: None,
2597                spec: ModelSpec::AutoResolve {
2598                    provider: "openai".to_string(),
2599                    match_patterns: vec!["gpt-5*".to_string()],
2600                    exclude_patterns: vec![],
2601                },
2602            },
2603        );
2604        let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
2605
2606        let mut diag = DiagnosticCollector::new();
2607        let resolved = resolve_all(&aliases, &cache, &mut diag);
2608        let entry = resolved.get("gpt").unwrap();
2609        assert_eq!(entry.model_id, "gpt-5");
2610        assert_eq!(entry.provider, "openai");
2611        assert_eq!(
2612            entry.harness_candidates,
2613            vec!["codex", "pi", "opencode", "cursor"]
2614        );
2615        match entry.harness_source {
2616            HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
2617            HarnessSource::Unavailable => assert!(entry.harness.is_none()),
2618            HarnessSource::Explicit => panic!("unexpected explicit harness source"),
2619        }
2620    }
2621
2622    #[test]
2623    fn resolve_all_unavailable_harness_still_included() {
2624        let mut aliases = IndexMap::new();
2625        aliases.insert(
2626            "opus".to_string(),
2627            ModelAlias {
2628                harness: Some("missing-harness-xyz".to_string()),
2629                description: None,
2630                default_effort: None,
2631                autocompact: None,
2632                autocompact_pct: None,
2633                spec: ModelSpec::Pinned {
2634                    model: "claude-opus-4-6".to_string(),
2635                    provider: None,
2636                },
2637            },
2638        );
2639
2640        let cache = ModelsCache {
2641            models: Vec::new(),
2642            fetched_at: None,
2643        };
2644
2645        let mut diag = DiagnosticCollector::new();
2646        let resolved = resolve_all(&aliases, &cache, &mut diag);
2647        let entry = resolved.get("opus").unwrap();
2648        assert_eq!(entry.model_id, "claude-opus-4-6");
2649        assert_eq!(entry.provider, "anthropic");
2650        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
2651        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
2652    }
2653
2654    #[test]
2655    fn resolve_all_empty_cache_omits_unresolvable() {
2656        let mut aliases = IndexMap::new();
2657        aliases.insert(
2658            "opus".to_string(),
2659            ModelAlias {
2660                harness: Some("claude".to_string()),
2661                description: None,
2662                default_effort: None,
2663                autocompact: None,
2664                autocompact_pct: None,
2665                spec: ModelSpec::AutoResolve {
2666                    provider: "Anthropic".to_string(),
2667                    match_patterns: vec!["claude-opus-*".to_string()],
2668                    exclude_patterns: vec![],
2669                },
2670            },
2671        );
2672        let cache = ModelsCache {
2673            models: Vec::new(),
2674            fetched_at: None,
2675        };
2676
2677        let mut diag = DiagnosticCollector::new();
2678        let resolved = resolve_all(&aliases, &cache, &mut diag);
2679        // No cache → auto-resolve can't match → alias omitted from results
2680        assert!(!resolved.contains_key("opus"));
2681    }
2682
2683    #[test]
2684    fn resolve_all_pinned_with_match_uses_model_field() {
2685        let mut aliases = IndexMap::new();
2686        aliases.insert(
2687            "opus".to_string(),
2688            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2689        );
2690        let cache = make_cache(vec![
2691            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2692            ("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
2693        ]);
2694
2695        let mut diag = DiagnosticCollector::new();
2696        let resolved = resolve_all(&aliases, &cache, &mut diag);
2697        assert_eq!(resolved.get("opus").unwrap().model_id, "claude-opus-4-6");
2698        assert!(diag.drain().is_empty());
2699    }
2700
2701    #[test]
2702    fn resolve_one_scopes_diagnostics_to_requested_alias() {
2703        let mut aliases = IndexMap::new();
2704        aliases.insert(
2705            "opus".to_string(),
2706            pinned_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
2707        );
2708        aliases.insert(
2709            "sonnet".to_string(),
2710            pinned_match_alias("claude-sonnet-4-5", "Anthropic", &["claude-sonnet-*"], &[]),
2711        );
2712        let cache = make_cache(vec![
2713            ("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
2714            ("claude-sonnet-4-7", "Anthropic", Some("2026-04-16")),
2715        ]);
2716
2717        let mut diag = DiagnosticCollector::new();
2718        let resolved = resolve_one("opus", &aliases, &cache, &mut diag).unwrap();
2719        assert_eq!(resolved.name, "opus");
2720        assert!(diag.drain().is_empty());
2721    }
2722
2723    fn make_resolved_alias(name: &str) -> ResolvedAlias {
2724        ResolvedAlias {
2725            name: name.to_string(),
2726            model_id: format!("model-{name}"),
2727            provider: "openai".to_string(),
2728            harness: Some("codex".to_string()),
2729            harness_source: HarnessSource::Explicit,
2730            harness_candidates: vec!["codex".to_string()],
2731            description: None,
2732            default_effort: None,
2733            autocompact: None,
2734            autocompact_pct: None,
2735            availability: None,
2736        }
2737    }
2738
2739    #[test]
2740    fn filter_by_visibility_include_mode_keeps_matches_only() {
2741        let mut aliases = IndexMap::new();
2742        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2743        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2744        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2745
2746        let filtered = filter_by_visibility(
2747            aliases,
2748            &crate::config::ModelVisibility {
2749                include: Some(vec!["model-opus*".to_string(), "model-gpt-*".to_string()]),
2750                exclude: None,
2751            },
2752        );
2753
2754        assert_eq!(filtered.len(), 2);
2755        assert!(filtered.contains_key("opus"));
2756        assert!(filtered.contains_key("gpt-5"));
2757        assert!(!filtered.contains_key("sonnet"));
2758    }
2759
2760    #[test]
2761    fn filter_by_visibility_exclude_mode_removes_matches() {
2762        let mut aliases = IndexMap::new();
2763        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2764        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
2765        aliases.insert(
2766            "deprecated-gpt".to_string(),
2767            make_resolved_alias("deprecated-gpt"),
2768        );
2769
2770        let filtered = filter_by_visibility(
2771            aliases,
2772            &crate::config::ModelVisibility {
2773                include: None,
2774                exclude: Some(vec![
2775                    "model-test-*".to_string(),
2776                    "model-deprecated-*".to_string(),
2777                ]),
2778            },
2779        );
2780
2781        assert_eq!(filtered.len(), 1);
2782        assert!(filtered.contains_key("opus"));
2783        assert!(!filtered.contains_key("test-opus"));
2784        assert!(!filtered.contains_key("deprecated-gpt"));
2785    }
2786
2787    #[test]
2788    fn filter_by_visibility_empty_config_returns_all() {
2789        let mut aliases = IndexMap::new();
2790        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2791        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2792        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
2793        assert_eq!(filtered.len(), 2);
2794        assert!(filtered.contains_key("opus"));
2795        assert!(filtered.contains_key("sonnet"));
2796    }
2797
2798    #[test]
2799    fn filter_by_visibility_empty_lists_return_all() {
2800        let mut aliases = IndexMap::new();
2801        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2802        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
2803        let filtered = filter_by_visibility(
2804            aliases,
2805            &crate::config::ModelVisibility {
2806                include: Some(Vec::new()),
2807                exclude: Some(Vec::new()),
2808            },
2809        );
2810        assert_eq!(filtered.len(), 2);
2811        assert!(filtered.contains_key("opus"));
2812        assert!(filtered.contains_key("sonnet"));
2813    }
2814
2815    #[test]
2816    fn visibility_pattern_matches_bare_provider_and_opencode_slug_forms() {
2817        let paths = vec![availability::RunnablePath {
2818            harness: "opencode".to_string(),
2819            mars_provider: "Anthropic".to_string(),
2820            harness_model_id: "openrouter/anthropic/claude-opus-4.7".to_string(),
2821        }];
2822
2823        assert!(matches_visibility_pattern(
2824            "claude-opus-*",
2825            "claude-opus-4-7",
2826            "Anthropic",
2827            &paths
2828        ));
2829        assert!(matches_visibility_pattern(
2830            "anthropic/claude-opus-*",
2831            "claude-opus-4-7",
2832            "Anthropic",
2833            &paths
2834        ));
2835        assert!(matches_visibility_pattern(
2836            "openrouter/anthropic/*",
2837            "claude-opus-4-7",
2838            "Anthropic",
2839            &paths
2840        ));
2841        assert!(!matches_visibility_pattern(
2842            "anthropic/*/opus",
2843            "claude-opus-4-7",
2844            "Anthropic",
2845            &paths
2846        ));
2847    }
2848
2849    #[test]
2850    fn filter_by_visibility_applies_include_then_exclude() {
2851        let mut aliases = IndexMap::new();
2852        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
2853        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
2854        aliases.insert("gpt-4".to_string(), make_resolved_alias("gpt-4"));
2855
2856        let filtered = filter_by_visibility(
2857            aliases,
2858            &crate::config::ModelVisibility {
2859                include: Some(vec!["openai/model-*".to_string()]),
2860                exclude: Some(vec!["model-gpt-4".to_string()]),
2861            },
2862        );
2863
2864        assert_eq!(filtered.len(), 2);
2865        assert!(filtered.contains_key("opus"));
2866        assert!(filtered.contains_key("gpt-5"));
2867        assert!(!filtered.contains_key("gpt-4"));
2868    }
2869
2870    #[test]
2871    fn resolve_model_and_provider_pinned_explicit_provider() {
2872        let alias = ModelAlias {
2873            harness: None,
2874            description: None,
2875            default_effort: None,
2876            autocompact: None,
2877            autocompact_pct: None,
2878            spec: ModelSpec::Pinned {
2879                model: "claude-opus-4-6".to_string(),
2880                provider: Some("anthropic".to_string()),
2881            },
2882        };
2883        let cache = ModelsCache {
2884            models: Vec::new(),
2885            fetched_at: None,
2886        };
2887
2888        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2889        assert_eq!(
2890            resolved,
2891            ("claude-opus-4-6".to_string(), "anthropic".to_string())
2892        );
2893    }
2894
2895    #[test]
2896    fn resolve_model_and_provider_pinned_inferred() {
2897        let alias = ModelAlias {
2898            harness: None,
2899            description: None,
2900            default_effort: None,
2901            autocompact: None,
2902            autocompact_pct: None,
2903            spec: ModelSpec::Pinned {
2904                model: "claude-opus-4-6".to_string(),
2905                provider: None,
2906            },
2907        };
2908        let cache = ModelsCache {
2909            models: Vec::new(),
2910            fetched_at: None,
2911        };
2912
2913        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2914        assert_eq!(
2915            resolved,
2916            ("claude-opus-4-6".to_string(), "anthropic".to_string())
2917        );
2918    }
2919
2920    #[test]
2921    fn resolve_model_and_provider_pinned_unknown() {
2922        let alias = ModelAlias {
2923            harness: None,
2924            description: None,
2925            default_effort: None,
2926            autocompact: None,
2927            autocompact_pct: None,
2928            spec: ModelSpec::Pinned {
2929                model: "my-custom-model".to_string(),
2930                provider: None,
2931            },
2932        };
2933        let cache = ModelsCache {
2934            models: Vec::new(),
2935            fetched_at: None,
2936        };
2937
2938        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2939        assert_eq!(
2940            resolved,
2941            ("my-custom-model".to_string(), "unknown".to_string())
2942        );
2943    }
2944
2945    #[test]
2946    fn resolve_model_and_provider_auto_resolve() {
2947        let alias = ModelAlias {
2948            harness: None,
2949            description: None,
2950            default_effort: None,
2951            autocompact: None,
2952            autocompact_pct: None,
2953            spec: ModelSpec::AutoResolve {
2954                provider: "openai".to_string(),
2955                match_patterns: vec!["gpt-5*".to_string()],
2956                exclude_patterns: vec![],
2957            },
2958        };
2959        let cache = make_cache(vec![
2960            ("gpt-4o", "OpenAI", Some("2024-06-01")),
2961            ("gpt-5", "OpenAI", Some("2025-06-01")),
2962        ]);
2963
2964        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
2965        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
2966    }
2967
2968    #[test]
2969    fn resolve_harness_explicit_installed() {
2970        let alias = ModelAlias {
2971            harness: Some("claude".to_string()),
2972            description: None,
2973            default_effort: None,
2974            autocompact: None,
2975            autocompact_pct: None,
2976            spec: ModelSpec::Pinned {
2977                model: "claude-opus-4-6".to_string(),
2978                provider: None,
2979            },
2980        };
2981        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
2982
2983        let resolved = resolve_harness(
2984            &alias,
2985            "anthropic",
2986            "claude-opus-4-6",
2987            &installed,
2988            None,
2989            None,
2990        );
2991        assert_eq!(
2992            resolved,
2993            (Some("claude".to_string()), HarnessSource::Explicit)
2994        );
2995    }
2996
2997    #[test]
2998    fn resolve_harness_explicit_not_installed() {
2999        let alias = ModelAlias {
3000            harness: Some("claude".to_string()),
3001            description: None,
3002            default_effort: None,
3003            autocompact: None,
3004            autocompact_pct: None,
3005            spec: ModelSpec::Pinned {
3006                model: "claude-opus-4-6".to_string(),
3007                provider: None,
3008            },
3009        };
3010        let installed = HashSet::new();
3011
3012        let resolved = resolve_harness(
3013            &alias,
3014            "anthropic",
3015            "claude-opus-4-6",
3016            &installed,
3017            None,
3018            None,
3019        );
3020        assert_eq!(
3021            resolved,
3022            (Some("claude".to_string()), HarnessSource::Unavailable)
3023        );
3024    }
3025
3026    #[test]
3027    fn resolve_harness_native_installed_result_depends_on_auth_probe() {
3028        let alias = ModelAlias {
3029            harness: None,
3030            description: None,
3031            default_effort: None,
3032            autocompact: None,
3033            autocompact_pct: None,
3034            spec: ModelSpec::Pinned {
3035                model: "claude-opus-4-6".to_string(),
3036                provider: Some("anthropic".to_string()),
3037            },
3038        };
3039        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
3040
3041        let resolved = resolve_harness(
3042            &alias,
3043            "anthropic",
3044            "claude-opus-4-6",
3045            &installed,
3046            None,
3047            None,
3048        );
3049        assert!(
3050            matches!(
3051                resolved,
3052                (Some(_), HarnessSource::AutoDetected) | (None, HarnessSource::Unavailable)
3053            ),
3054            "native harness routing must be gated by the host auth probe; got {resolved:?}"
3055        );
3056    }
3057
3058    #[test]
3059    fn resolve_harness_unavailable() {
3060        let alias = ModelAlias {
3061            harness: None,
3062            description: None,
3063            default_effort: None,
3064            autocompact: None,
3065            autocompact_pct: None,
3066            spec: ModelSpec::Pinned {
3067                model: "claude-opus-4-6".to_string(),
3068                provider: Some("anthropic".to_string()),
3069            },
3070        };
3071        let installed = HashSet::new();
3072
3073        let resolved = resolve_harness(
3074            &alias,
3075            "anthropic",
3076            "claude-opus-4-6",
3077            &installed,
3078            None,
3079            None,
3080        );
3081        assert_eq!(resolved, (None, HarnessSource::Unavailable));
3082    }
3083
3084    #[test]
3085    fn resolve_harness_unknown_provider_uses_pi_first_fallback_ladder() {
3086        let alias = ModelAlias {
3087            harness: None,
3088            description: None,
3089            default_effort: None,
3090            autocompact: None,
3091            autocompact_pct: None,
3092            spec: ModelSpec::Pinned {
3093                model: "my-custom-model".to_string(),
3094                provider: Some("unknown".to_string()),
3095            },
3096        };
3097        let installed: HashSet<String> = ["claude", "pi"].iter().map(|s| s.to_string()).collect();
3098
3099        let resolved =
3100            resolve_harness(&alias, "unknown", "my-custom-model", &installed, None, None);
3101        assert_eq!(
3102            resolved,
3103            (Some("pi".to_string()), HarnessSource::AutoDetected)
3104        );
3105    }
3106
3107    // -- serde roundtrip tests --
3108
3109    #[test]
3110    fn harness_source_serializes_snake_case() {
3111        assert_eq!(
3112            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
3113            "\"explicit\""
3114        );
3115        assert_eq!(
3116            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
3117            "\"auto_detected\""
3118        );
3119        assert_eq!(
3120            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
3121            "\"unavailable\""
3122        );
3123    }
3124
3125    #[test]
3126    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
3127        let toml_str = r#"
3128[models.fast]
3129harness = "claude"
3130model = "claude-haiku-4-5"
3131description = "Fast and cheap"
3132"#;
3133
3134        #[derive(Debug, Deserialize)]
3135        struct Wrapper {
3136            #[allow(dead_code)]
3137            models: IndexMap<String, ModelAlias>,
3138        }
3139
3140        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3141        let alias = parsed.models.get("fast").unwrap();
3142        assert_eq!(
3143            alias.spec,
3144            ModelSpec::Pinned {
3145                model: "claude-haiku-4-5".to_string(),
3146                provider: None
3147            }
3148        );
3149        assert_eq!(alias.harness.as_deref(), Some("claude"));
3150        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
3151
3152        let json = serde_json::to_string(alias).unwrap();
3153        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3154        assert_eq!(roundtripped, *alias);
3155    }
3156
3157    #[test]
3158    fn model_alias_pinned_toml_roundtrip_without_harness() {
3159        let toml_str = r#"
3160[models.fast]
3161model = "claude-haiku-4-5"
3162"#;
3163
3164        #[derive(Debug, Deserialize)]
3165        struct Wrapper {
3166            #[allow(dead_code)]
3167            models: IndexMap<String, ModelAlias>,
3168        }
3169
3170        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3171        let alias = parsed.models.get("fast").unwrap();
3172        assert_eq!(alias.harness, None);
3173        assert_eq!(
3174            alias.spec,
3175            ModelSpec::Pinned {
3176                model: "claude-haiku-4-5".to_string(),
3177                provider: None
3178            }
3179        );
3180
3181        let json = serde_json::to_string(alias).unwrap();
3182        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3183        assert!(value.get("harness").is_none());
3184        assert!(value.get("provider").is_none());
3185        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3186        assert_eq!(roundtripped, *alias);
3187    }
3188
3189    #[test]
3190    fn model_alias_pinned_toml_roundtrip_with_provider() {
3191        let toml_str = r#"
3192[models.fast]
3193model = "claude-haiku-4-5"
3194provider = "anthropic"
3195"#;
3196
3197        #[derive(Debug, Deserialize)]
3198        struct Wrapper {
3199            #[allow(dead_code)]
3200            models: IndexMap<String, ModelAlias>,
3201        }
3202
3203        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3204        let alias = parsed.models.get("fast").unwrap();
3205        assert_eq!(alias.harness, None);
3206        assert_eq!(
3207            alias.spec,
3208            ModelSpec::Pinned {
3209                model: "claude-haiku-4-5".to_string(),
3210                provider: Some("anthropic".to_string())
3211            }
3212        );
3213
3214        let json = serde_json::to_string(alias).unwrap();
3215        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
3216        assert_eq!(
3217            value.get("provider").and_then(serde_json::Value::as_str),
3218            Some("anthropic")
3219        );
3220        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3221        assert_eq!(roundtripped, *alias);
3222    }
3223
3224    #[test]
3225    fn model_alias_pinned_json_roundtrip_with_provider() {
3226        let json = r#"{
3227            "model": "gpt-5.3-codex",
3228            "provider": "openai"
3229        }"#;
3230
3231        let alias: ModelAlias = serde_json::from_str(json).unwrap();
3232        assert_eq!(alias.harness, None);
3233        assert_eq!(alias.description, None);
3234        assert_eq!(
3235            alias.spec,
3236            ModelSpec::Pinned {
3237                model: "gpt-5.3-codex".to_string(),
3238                provider: Some("openai".to_string())
3239            }
3240        );
3241
3242        let encoded = serde_json::to_string(&alias).unwrap();
3243        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
3244        assert_eq!(roundtripped, alias);
3245    }
3246
3247    #[test]
3248    fn model_alias_auto_resolve_toml_roundtrip() {
3249        let toml_str = r#"
3250[models.opus]
3251harness = "claude"
3252provider = "Anthropic"
3253match = ["claude-opus-*"]
3254exclude = ["claude-opus-3*"]
3255description = "Best reasoning"
3256"#;
3257
3258        #[derive(Debug, Deserialize)]
3259        struct Wrapper {
3260            #[allow(dead_code)]
3261            models: IndexMap<String, ModelAlias>,
3262        }
3263
3264        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3265        let alias = parsed.models.get("opus").unwrap();
3266        assert_eq!(alias.harness.as_deref(), Some("claude"));
3267        match &alias.spec {
3268            ModelSpec::AutoResolve {
3269                provider,
3270                match_patterns,
3271                exclude_patterns,
3272            } => {
3273                assert_eq!(provider, "Anthropic");
3274                assert_eq!(match_patterns, &["claude-opus-*"]);
3275                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3276            }
3277            _ => panic!("expected AutoResolve"),
3278        }
3279    }
3280
3281    #[test]
3282    fn model_alias_model_and_match_toml_roundtrip() {
3283        let toml_str = r#"
3284[models.opus]
3285model = "claude-opus-4-6"
3286provider = "anthropic"
3287match = ["claude-opus-*"]
3288exclude = ["claude-opus-3*"]
3289"#;
3290
3291        #[derive(Debug, Deserialize)]
3292        struct Wrapper {
3293            #[allow(dead_code)]
3294            models: IndexMap<String, ModelAlias>,
3295        }
3296
3297        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3298        let alias = parsed.models.get("opus").unwrap();
3299        match &alias.spec {
3300            ModelSpec::PinnedWithMatch {
3301                model,
3302                provider,
3303                match_patterns,
3304                exclude_patterns,
3305            } => {
3306                assert_eq!(model, "claude-opus-4-6");
3307                assert_eq!(provider.as_deref(), Some("anthropic"));
3308                assert_eq!(match_patterns, &["claude-opus-*"]);
3309                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
3310            }
3311            _ => panic!("expected PinnedWithMatch"),
3312        }
3313
3314        let json = serde_json::to_string(alias).unwrap();
3315        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3316        assert_eq!(roundtripped, *alias);
3317    }
3318
3319    #[test]
3320    fn model_alias_model_with_exclude_without_match_errors() {
3321        let toml_str = r#"
3322[models.opus]
3323model = "claude-opus-4-7"
3324exclude = ["claude-opus-3*"]
3325"#;
3326
3327        #[derive(Debug, Deserialize)]
3328        struct Wrapper {
3329            #[allow(dead_code)]
3330            models: IndexMap<String, ModelAlias>,
3331        }
3332
3333        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3334        assert!(err.contains("must also include 'match'"));
3335    }
3336
3337    #[test]
3338    fn model_alias_defaults_toml_roundtrip() {
3339        let toml_str = r#"
3340[models.opus]
3341provider = "Anthropic"
3342match = ["claude-opus-*"]
3343default_effort = "high"
3344autocompact = 25
3345"#;
3346
3347        #[derive(Debug, Deserialize)]
3348        struct Wrapper {
3349            models: IndexMap<String, ModelAlias>,
3350        }
3351
3352        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3353        let alias = parsed.models.get("opus").unwrap();
3354        assert_eq!(alias.default_effort.as_deref(), Some("high"));
3355        assert_eq!(alias.autocompact, Some(25));
3356
3357        let json = serde_json::to_string(alias).unwrap();
3358        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
3359        assert_eq!(roundtripped, *alias);
3360    }
3361
3362    #[test]
3363    fn model_alias_empty_default_effort_treated_as_none() {
3364        let toml_str = r#"
3365[models.opus]
3366provider = "Anthropic"
3367match = ["claude-opus-*"]
3368default_effort = ""
3369"#;
3370
3371        #[derive(Debug, Deserialize)]
3372        struct Wrapper {
3373            models: IndexMap<String, ModelAlias>,
3374        }
3375
3376        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3377        let alias = parsed.models.get("opus").unwrap();
3378        assert_eq!(alias.default_effort, None);
3379    }
3380
3381    #[test]
3382    fn model_alias_invalid_default_effort_errors() {
3383        let toml_str = r#"
3384[models.opus]
3385provider = "Anthropic"
3386match = ["claude-opus-*"]
3387default_effort = "maximum"
3388"#;
3389
3390        #[derive(Debug, Deserialize)]
3391        struct Wrapper {
3392            #[allow(dead_code)]
3393            models: IndexMap<String, ModelAlias>,
3394        }
3395
3396        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3397        assert!(err.contains("invalid default_effort"));
3398        assert!(err.contains("accepted values"));
3399    }
3400
3401    #[test]
3402    fn model_alias_invalid_harness_errors() {
3403        let toml_str = r#"
3404[models.opus]
3405harness = "gemini"
3406provider = "Anthropic"
3407match = ["claude-opus-*"]
3408"#;
3409
3410        #[derive(Debug, Deserialize)]
3411        struct Wrapper {
3412            #[allow(dead_code)]
3413            models: IndexMap<String, ModelAlias>,
3414        }
3415
3416        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3417        assert!(err.contains("invalid harness 'gemini'"));
3418        assert!(err.contains("valid harnesses: claude, codex, pi, opencode, cursor"));
3419    }
3420
3421    #[test]
3422    fn model_alias_harness_normalizes_mixed_case() {
3423        let toml_str = r#"
3424[models.opus]
3425harness = "OpenCode"
3426model = "gpt-5"
3427"#;
3428
3429        #[derive(Debug, Deserialize)]
3430        struct Wrapper {
3431            models: IndexMap<String, ModelAlias>,
3432        }
3433
3434        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3435        let alias = parsed.models.get("opus").unwrap();
3436        assert_eq!(alias.harness.as_deref(), Some("opencode"));
3437    }
3438
3439    #[test]
3440    fn model_alias_autocompact_out_of_range_errors() {
3441        // autocompact_pct out of range (>100) is a hard error
3442        let toml_str = r#"
3443[models.opus]
3444provider = "Anthropic"
3445match = ["claude-opus-*"]
3446autocompact_pct = 101
3447"#;
3448
3449        #[derive(Debug, Deserialize)]
3450        struct Wrapper {
3451            #[allow(dead_code)]
3452            models: IndexMap<String, ModelAlias>,
3453        }
3454
3455        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3456        assert!(err.contains("out of range 1-100"));
3457    }
3458
3459    #[test]
3460    fn model_alias_autocompact_boolean_errors() {
3461        let toml_str = r#"
3462[models.opus]
3463provider = "Anthropic"
3464match = ["claude-opus-*"]
3465autocompact = true
3466"#;
3467
3468        #[derive(Debug, Deserialize)]
3469        struct Wrapper {
3470            #[allow(dead_code)]
3471            models: IndexMap<String, ModelAlias>,
3472        }
3473
3474        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3475        assert!(err.contains("autocompact must be an integer (token count)"));
3476    }
3477
3478    #[test]
3479    fn parses_autocompact_pct() {
3480        let toml_str = r#"
3481[models.opus]
3482provider = "Anthropic"
3483match = ["claude-opus-*"]
3484autocompact_pct = 75
3485"#;
3486
3487        #[derive(Debug, Deserialize)]
3488        struct Wrapper {
3489            models: IndexMap<String, ModelAlias>,
3490        }
3491
3492        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3493        let alias = parsed.models.get("opus").unwrap();
3494        assert_eq!(alias.autocompact_pct, Some(75));
3495        assert_eq!(alias.autocompact, None);
3496    }
3497
3498    #[test]
3499    fn autocompact_pct_out_of_range_errors() {
3500        let toml_str = r#"
3501[models.opus]
3502provider = "Anthropic"
3503match = ["claude-opus-*"]
3504autocompact_pct = 150
3505"#;
3506
3507        #[derive(Debug, Deserialize)]
3508        struct Wrapper {
3509            #[allow(dead_code)]
3510            models: IndexMap<String, ModelAlias>,
3511        }
3512
3513        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3514        assert!(err.contains("autocompact_pct"));
3515        assert!(err.contains("out of range 1-100"));
3516    }
3517
3518    #[test]
3519    fn autocompact_pct_zero_errors() {
3520        let toml_str = r#"
3521[models.opus]
3522provider = "Anthropic"
3523match = ["claude-opus-*"]
3524autocompact_pct = 0
3525"#;
3526
3527        #[derive(Debug, Deserialize)]
3528        struct Wrapper {
3529            #[allow(dead_code)]
3530            models: IndexMap<String, ModelAlias>,
3531        }
3532
3533        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3534        assert!(err.contains("autocompact_pct"));
3535        assert!(err.contains("out of range 1-100"));
3536    }
3537
3538    #[test]
3539    fn model_alias_autocompact_zero_accepted() {
3540        let toml_str = r#"
3541[models.opus]
3542model = "claude-opus-4-6"
3543autocompact = 0
3544"#;
3545
3546        #[derive(Debug, Deserialize)]
3547        struct Wrapper {
3548            models: IndexMap<String, ModelAlias>,
3549        }
3550
3551        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3552        let alias = parsed.models.get("opus").unwrap();
3553        assert_eq!(alias.autocompact, Some(0u32));
3554    }
3555
3556    #[test]
3557    fn model_alias_autocompact_max_u32_accepted() {
3558        let toml_str = r#"
3559[models.opus]
3560model = "claude-opus-4-6"
3561autocompact = 4294967295
3562"#;
3563
3564        #[derive(Debug, Deserialize)]
3565        struct Wrapper {
3566            models: IndexMap<String, ModelAlias>,
3567        }
3568
3569        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3570        let alias = parsed.models.get("opus").unwrap();
3571        assert_eq!(alias.autocompact, Some(4294967295u32));
3572    }
3573
3574    #[test]
3575    fn model_alias_autocompact_overflow_errors() {
3576        // 4294967296 == u32::MAX + 1 — should be rejected
3577        let toml_str = r#"
3578[models.opus]
3579model = "claude-opus-4-6"
3580autocompact = 4294967296
3581"#;
3582
3583        #[derive(Debug, Deserialize)]
3584        struct Wrapper {
3585            #[allow(dead_code)]
3586            models: IndexMap<String, ModelAlias>,
3587        }
3588
3589        let err = toml::from_str::<Wrapper>(toml_str).unwrap_err().to_string();
3590        assert!(err.contains("out of u32 range"));
3591    }
3592
3593    #[test]
3594    fn both_autocompact_fields_round_trip() {
3595        let toml_str = r#"
3596[models.opus]
3597model = "claude-opus-4-6"
3598autocompact = 50000
3599autocompact_pct = 80
3600"#;
3601
3602        #[derive(Debug, Deserialize)]
3603        struct Wrapper {
3604            models: IndexMap<String, ModelAlias>,
3605        }
3606
3607        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
3608        let alias = parsed.models.get("opus").unwrap();
3609        assert_eq!(alias.autocompact, Some(50000u32));
3610        assert_eq!(alias.autocompact_pct, Some(80u8));
3611
3612        // Verify both propagate through resolve_all
3613        let mut aliases = IndexMap::new();
3614        aliases.insert("opus".to_string(), alias.clone());
3615        let cache = ModelsCache {
3616            models: Vec::new(),
3617            fetched_at: None,
3618        };
3619        let mut diag = DiagnosticCollector::new();
3620        let resolved = resolve_all(&aliases, &cache, &mut diag);
3621        let entry = resolved.get("opus").unwrap();
3622        assert_eq!(entry.autocompact, Some(50000u32));
3623        assert_eq!(entry.autocompact_pct, Some(80u8));
3624    }
3625
3626    #[test]
3627    fn model_alias_both_model_and_match_is_hybrid_pinned() {
3628        let toml_str = r#"
3629[models.bad]
3630harness = "claude"
3631model = "some-model"
3632match = ["pattern-*"]
3633"#;
3634
3635        #[derive(Debug, Deserialize)]
3636        struct Wrapper {
3637            #[allow(dead_code)]
3638            models: IndexMap<String, ModelAlias>,
3639        }
3640
3641        let result = toml::from_str::<Wrapper>(toml_str).unwrap();
3642        let alias = result.models.get("bad").unwrap();
3643        match &alias.spec {
3644            ModelSpec::PinnedWithMatch {
3645                model,
3646                match_patterns,
3647                ..
3648            } => {
3649                assert_eq!(model, "some-model");
3650                assert_eq!(match_patterns, &["pattern-*"]);
3651            }
3652            _ => panic!("expected pinned-with-match alias"),
3653        }
3654    }
3655
3656    #[test]
3657    fn model_alias_neither_model_nor_match_errors() {
3658        let toml_str = r#"
3659[models.bad]
3660harness = "claude"
3661"#;
3662
3663        #[derive(Debug, Deserialize)]
3664        struct Wrapper {
3665            #[allow(dead_code)]
3666            models: IndexMap<String, ModelAlias>,
3667        }
3668
3669        let result = toml::from_str::<Wrapper>(toml_str);
3670        assert!(result.is_err());
3671    }
3672
3673    #[test]
3674    fn infer_provider_from_model_id_detects_known_prefixes() {
3675        assert_eq!(
3676            infer_provider_from_model_id("claude-opus-4-6"),
3677            Some("anthropic")
3678        );
3679        assert_eq!(
3680            infer_provider_from_model_id("gpt-5.3-codex"),
3681            Some("openai")
3682        );
3683        assert_eq!(
3684            infer_provider_from_model_id("gemini-2.5-pro"),
3685            Some("google")
3686        );
3687        assert_eq!(
3688            infer_provider_from_model_id("llama-4-maverick"),
3689            Some("meta")
3690        );
3691        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
3692        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
3693        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
3694        assert_eq!(
3695            infer_provider_from_model_id("codex-mini-latest"),
3696            Some("openai")
3697        );
3698        assert_eq!(
3699            infer_provider_from_model_id("mistral-large"),
3700            Some("mistral")
3701        );
3702        assert_eq!(
3703            infer_provider_from_model_id("codestral-latest"),
3704            Some("mistral")
3705        );
3706        assert_eq!(
3707            infer_provider_from_model_id("deepseek-chat"),
3708            Some("deepseek")
3709        );
3710        assert_eq!(
3711            infer_provider_from_model_id("command-r-plus"),
3712            Some("cohere")
3713        );
3714    }
3715
3716    #[test]
3717    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
3718        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
3719    }
3720
3721    #[test]
3722    fn infer_provider_from_model_id_returns_none_for_empty_string() {
3723        assert_eq!(infer_provider_from_model_id(""), None);
3724    }
3725
3726    #[test]
3727    fn infer_provider_from_model_id_is_case_insensitive() {
3728        assert_eq!(
3729            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
3730            Some("anthropic")
3731        );
3732        assert_eq!(
3733            infer_provider_from_model_id("GPT-5.3-codex"),
3734            Some("openai")
3735        );
3736        assert_eq!(
3737            infer_provider_from_model_id("CoDeStRaL-latest"),
3738            Some("mistral")
3739        );
3740    }
3741
3742    #[allow(unused_unsafe)]
3743    fn env_set(key: &str, value: &str) {
3744        unsafe {
3745            std::env::set_var(key, value);
3746        }
3747    }
3748
3749    #[allow(unused_unsafe)]
3750    fn env_remove(key: &str) {
3751        unsafe {
3752            std::env::remove_var(key);
3753        }
3754    }
3755
3756    struct EnvVarGuard {
3757        key: String,
3758        prev: Option<String>,
3759    }
3760
3761    impl EnvVarGuard {
3762        fn set(key: &str, value: &str) -> Self {
3763            let prev = std::env::var(key).ok();
3764            env_set(key, value);
3765            Self {
3766                key: key.to_string(),
3767                prev,
3768            }
3769        }
3770    }
3771
3772    impl Drop for EnvVarGuard {
3773        fn drop(&mut self) {
3774            if let Some(prev) = &self.prev {
3775                env_set(&self.key, prev);
3776            } else {
3777                env_remove(&self.key);
3778            }
3779        }
3780    }
3781
3782    fn sample_catalog_json() -> serde_json::Value {
3783        serde_json::json!({
3784            "openai": {
3785                "models": {
3786                    "gpt-5": {
3787                        "id": "gpt-5",
3788                        "name": "GPT-5",
3789                        "release_date": "2025-06-01",
3790                        "limit": {
3791                            "context": 400000,
3792                            "output": 128000
3793                        }
3794                    }
3795                }
3796            },
3797            "anthropic": {
3798                "models": {
3799                    "claude-sonnet-4-5": {
3800                        "id": "claude-sonnet-4-5",
3801                        "name": "Claude Sonnet 4.5",
3802                        "release_date": "2025-03-01"
3803                    }
3804                }
3805            }
3806        })
3807    }
3808
3809    fn sample_cached_model(id: &str) -> CachedModel {
3810        CachedModel {
3811            id: id.to_string(),
3812            provider: "OpenAI".to_string(),
3813            release_date: None,
3814            description: None,
3815            context_window: None,
3816            max_output: None,
3817            cost_input: None,
3818            cost_output: None,
3819            cost_cache_read: None,
3820            cost_cache_write: None,
3821            cost_reasoning: None,
3822        }
3823    }
3824
3825    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
3826        write_cache(
3827            mars_dir,
3828            &ModelsCache {
3829                models,
3830                fetched_at: Some(fetched_at.to_string()),
3831            },
3832        )
3833        .expect("failed to write cache fixture");
3834    }
3835
3836    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
3837        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
3838        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
3839    }
3840
3841    fn stale_timestamp() -> String {
3842        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
3843    }
3844
3845    fn fresh_timestamp() -> String {
3846        now_unix_secs_value().saturating_sub(60).to_string()
3847    }
3848
3849    fn assert_model_cache_unavailable(
3850        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
3851        reason_contains: &str,
3852    ) {
3853        match result {
3854            Err(MarsError::ModelCacheUnavailable { reason }) => {
3855                assert!(
3856                    reason.contains(reason_contains),
3857                    "unexpected reason: {reason}"
3858                );
3859            }
3860            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
3861        }
3862    }
3863
3864    #[test]
3865    #[serial]
3866    fn ensure_fresh_1_missing_cache_offline_errors() {
3867        let mars = tempdir().unwrap();
3868        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
3869
3870        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3871        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
3872    }
3873
3874    #[test]
3875    #[serial]
3876    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
3877        let mars = tempdir().unwrap();
3878        let server = MockServer::start();
3879        let mock = server.mock(|when, then| {
3880            when.method(GET).path("/api.json");
3881            then.status(500).body("server error");
3882        });
3883        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3884
3885        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
3886        assert_model_cache_unavailable(result, "automatic refresh failed");
3887        assert_eq!(mock.hits(), 1);
3888    }
3889
3890    #[test]
3891    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
3892        let mars = tempdir().unwrap();
3893        write_cache_state(
3894            mars.path(),
3895            vec![sample_cached_model("stale-model")],
3896            &stale_timestamp(),
3897        );
3898
3899        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
3900        assert_eq!(cache.models.len(), 1);
3901        assert_eq!(cache.models[0].id, "stale-model");
3902        assert_eq!(outcome, RefreshOutcome::Offline);
3903    }
3904
3905    #[test]
3906    #[serial]
3907    fn ensure_fresh_4_fresh_auto_skips_http() {
3908        let mars = tempdir().unwrap();
3909        write_cache_state(
3910            mars.path(),
3911            vec![sample_cached_model("fresh-model")],
3912            &fresh_timestamp(),
3913        );
3914
3915        let server = MockServer::start();
3916        let mock = server.mock(|when, then| {
3917            when.method(GET).path("/api.json");
3918            then.status(200).json_body(sample_catalog_json());
3919        });
3920        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3921
3922        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3923        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
3924        assert_eq!(mock.hits(), 0);
3925    }
3926
3927    #[test]
3928    #[serial]
3929    fn ensure_fresh_5_stale_auto_success_refreshes() {
3930        let mars = tempdir().unwrap();
3931        write_cache_state(
3932            mars.path(),
3933            vec![sample_cached_model("old-model")],
3934            &stale_timestamp(),
3935        );
3936
3937        let server = MockServer::start();
3938        let mock = server.mock(|when, then| {
3939            when.method(GET).path("/api.json");
3940            then.status(200).json_body(sample_catalog_json());
3941        });
3942        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3943
3944        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3945        assert!(matches!(
3946            outcome,
3947            RefreshOutcome::Refreshed { models_count } if models_count == 2
3948        ));
3949        assert_eq!(cache.models.len(), 2);
3950        assert!(!cache.models.is_empty());
3951        assert!(cache.fetched_at.is_some());
3952        assert_eq!(mock.hits(), 1);
3953    }
3954
3955    #[test]
3956    #[serial]
3957    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
3958        let mars = tempdir().unwrap();
3959        write_cache_state(
3960            mars.path(),
3961            vec![sample_cached_model("stale-model")],
3962            &stale_timestamp(),
3963        );
3964
3965        let server = MockServer::start();
3966        let mock = server.mock(|when, then| {
3967            when.method(GET).path("/api.json");
3968            then.status(500).body("server error");
3969        });
3970        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3971
3972        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3973        assert_eq!(cache.models[0].id, "stale-model");
3974        assert!(matches!(
3975            outcome,
3976            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
3977        ));
3978        assert_eq!(mock.hits(), 1);
3979    }
3980
3981    #[test]
3982    #[serial]
3983    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
3984        let mars = tempdir().unwrap();
3985        write_cache_state(
3986            mars.path(),
3987            vec![sample_cached_model("stale-model")],
3988            &stale_timestamp(),
3989        );
3990
3991        let server = MockServer::start();
3992        let mock = server.mock(|when, then| {
3993            when.method(GET).path("/api.json");
3994            then.status(200).json_body(serde_json::json!({}));
3995        });
3996        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
3997
3998        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
3999        assert_eq!(cache.models[0].id, "stale-model");
4000        assert!(matches!(
4001            outcome,
4002            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
4003        ));
4004        assert_eq!(mock.hits(), 1);
4005    }
4006
4007    #[test]
4008    #[serial]
4009    fn ensure_fresh_8_empty_cache_auto_refetches() {
4010        let mars = tempdir().unwrap();
4011        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
4012
4013        let server = MockServer::start();
4014        let mock = server.mock(|when, then| {
4015            when.method(GET).path("/api.json");
4016            then.status(200).json_body(sample_catalog_json());
4017        });
4018        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4019
4020        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4021        assert!(!cache.models.is_empty());
4022        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4023        assert_eq!(mock.hits(), 1);
4024    }
4025
4026    #[test]
4027    fn ensure_fresh_9_empty_cache_offline_errors() {
4028        let mars = tempdir().unwrap();
4029        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
4030
4031        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
4032        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
4033    }
4034
4035    #[test]
4036    #[serial]
4037    fn ensure_fresh_10_corrupt_json_auto_refetches() {
4038        let mars = tempdir().unwrap();
4039        write_raw_cache_file(mars.path(), "{ not-json ");
4040
4041        let server = MockServer::start();
4042        let mock = server.mock(|when, then| {
4043            when.method(GET).path("/api.json");
4044            then.status(200).json_body(sample_catalog_json());
4045        });
4046        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4047
4048        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4049        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4050        assert!(!cache.models.is_empty());
4051        assert_eq!(mock.hits(), 1);
4052    }
4053
4054    #[test]
4055    fn ensure_fresh_11_corrupt_json_offline_errors() {
4056        let mars = tempdir().unwrap();
4057        write_raw_cache_file(mars.path(), "{ not-json ");
4058
4059        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
4060        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
4061    }
4062
4063    #[test]
4064    fn read_cache_io_error_includes_operation_and_path() {
4065        let mars = tempdir().unwrap();
4066        let cache_path = mars.path().join(CACHE_FILE);
4067        std::fs::create_dir(&cache_path).unwrap();
4068
4069        let err = read_cache(mars.path()).unwrap_err();
4070        let msg = err.to_string();
4071
4072        assert!(
4073            msg.contains("read models cache"),
4074            "error should include operation context: {msg}"
4075        );
4076        assert!(
4077            msg.contains(CACHE_FILE),
4078            "error should include cache path: {msg}"
4079        );
4080    }
4081
4082    #[test]
4083    #[serial]
4084    fn ensure_fresh_12_ttl_zero_always_refetches() {
4085        let mars = tempdir().unwrap();
4086        write_cache_state(
4087            mars.path(),
4088            vec![sample_cached_model("fresh-model")],
4089            &fresh_timestamp(),
4090        );
4091
4092        let server = MockServer::start();
4093        let mock = server.mock(|when, then| {
4094            when.method(GET).path("/api.json");
4095            then.status(200).json_body(sample_catalog_json());
4096        });
4097        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4098
4099        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
4100        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4101        assert_eq!(mock.hits(), 1);
4102    }
4103
4104    #[test]
4105    #[serial]
4106    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
4107        let mars = tempdir().unwrap();
4108        write_cache_state(
4109            mars.path(),
4110            vec![sample_cached_model("stale-model")],
4111            "not-a-timestamp",
4112        );
4113
4114        let server = MockServer::start();
4115        let mock = server.mock(|when, then| {
4116            when.method(GET).path("/api.json");
4117            then.status(200).json_body(sample_catalog_json());
4118        });
4119        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4120
4121        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4122        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4123        assert_eq!(mock.hits(), 1);
4124    }
4125
4126    #[test]
4127    #[serial]
4128    fn ensure_fresh_14_future_fetched_at_is_stale() {
4129        let mars = tempdir().unwrap();
4130        let future = now_unix_secs_value() + 3600;
4131        write_cache_state(
4132            mars.path(),
4133            vec![sample_cached_model("future-model")],
4134            &future.to_string(),
4135        );
4136
4137        let server = MockServer::start();
4138        let mock = server.mock(|when, then| {
4139            when.method(GET).path("/api.json");
4140            then.status(200).json_body(sample_catalog_json());
4141        });
4142        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4143
4144        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4145        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4146        assert_eq!(mock.hits(), 1);
4147    }
4148
4149    #[test]
4150    #[serial]
4151    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
4152        let mars = tempdir().unwrap();
4153        write_cache_state(
4154            mars.path(),
4155            vec![sample_cached_model("fresh-model")],
4156            &fresh_timestamp(),
4157        );
4158
4159        let server = MockServer::start();
4160        let mock = server.mock(|when, then| {
4161            when.method(GET).path("/api.json");
4162            then.status(200).json_body(sample_catalog_json());
4163        });
4164        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4165        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
4166
4167        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
4168        assert_eq!(outcome, RefreshOutcome::Offline);
4169        assert_eq!(mock.hits(), 0);
4170    }
4171
4172    #[test]
4173    #[serial]
4174    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
4175        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
4176        assert!(!is_mars_offline());
4177        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
4178    }
4179
4180    #[test]
4181    #[serial]
4182    fn ensure_fresh_17_offline_env_truthy_is_offline() {
4183        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
4184        assert!(is_mars_offline());
4185        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
4186    }
4187
4188    #[test]
4189    #[serial]
4190    fn ensure_fresh_18_force_ignores_offline_env() {
4191        let mars = tempdir().unwrap();
4192        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
4193
4194        let server = MockServer::start();
4195        let mock = server.mock(|when, then| {
4196            when.method(GET).path("/api.json");
4197            then.status(200).json_body(sample_catalog_json());
4198        });
4199        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
4200
4201        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
4202        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
4203        assert_eq!(mock.hits(), 1);
4204    }
4205
4206    #[test]
4207    #[serial]
4208    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
4209        let mars = tempdir().unwrap();
4210        write_cache_state(
4211            mars.path(),
4212            vec![sample_cached_model("stale-model")],
4213            &stale_timestamp(),
4214        );
4215
4216        let path = Arc::new(mars.path().to_path_buf());
4217        let path_a = Arc::clone(&path);
4218        let path_b = Arc::clone(&path);
4219        let fetch_hits = Arc::new(AtomicUsize::new(0));
4220        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
4221        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
4222
4223        let fetch_hits_a = Arc::clone(&fetch_hits);
4224        let t1 = thread::spawn(move || {
4225            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
4226                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4227                fetch_started_tx.send(()).unwrap();
4228                release_fetch_rx.recv().unwrap();
4229                Ok(vec![sample_cached_model("fresh-model")])
4230            })
4231            .unwrap()
4232            .1
4233        });
4234
4235        fetch_started_rx.recv().unwrap();
4236
4237        let fetch_hits_b = Arc::clone(&fetch_hits);
4238        let t2 = thread::spawn(move || {
4239            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
4240                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4241                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4242            })
4243            .unwrap()
4244            .1
4245        });
4246
4247        release_fetch_tx.send(()).unwrap();
4248
4249        let outcome_a = t1.join().unwrap();
4250        let outcome_b = t2.join().unwrap();
4251
4252        let outcomes = [outcome_a, outcome_b];
4253        let refreshed = outcomes
4254            .iter()
4255            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
4256            .count();
4257        let already_fresh = outcomes
4258            .iter()
4259            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
4260            .count();
4261
4262        assert_eq!(refreshed, 1);
4263        assert_eq!(already_fresh, 1);
4264        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4265    }
4266
4267    #[test]
4268    #[serial]
4269    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
4270        let mars = tempdir().unwrap();
4271        write_cache_state(
4272            mars.path(),
4273            vec![sample_cached_model("stale-model")],
4274            &stale_timestamp(),
4275        );
4276
4277        let fetch_hits = Arc::new(AtomicUsize::new(0));
4278
4279        let fetch_hits_a = Arc::clone(&fetch_hits);
4280        let (_cache_a, outcome_a) =
4281            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4282                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4283                Err(MarsError::Http {
4284                    url: "https://example.test/api.json".to_string(),
4285                    status: 500,
4286                    message: "request failed with HTTP status 500".to_string(),
4287                })
4288            })
4289            .unwrap();
4290
4291        let fetch_hits_b = Arc::clone(&fetch_hits);
4292        let (_cache_b, outcome_b) =
4293            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4294                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4295                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4296            })
4297            .unwrap();
4298
4299        assert!(matches!(
4300            outcome_a,
4301            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
4302        ));
4303        assert_eq!(
4304            outcome_b,
4305            RefreshOutcome::StaleFallback {
4306                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4307            }
4308        );
4309        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4310    }
4311
4312    #[test]
4313    #[serial]
4314    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
4315        let mars = tempdir().unwrap();
4316        write_cache_state(
4317            mars.path(),
4318            vec![sample_cached_model("stale-model")],
4319            &stale_timestamp(),
4320        );
4321
4322        let fetch_hits = Arc::new(AtomicUsize::new(0));
4323
4324        let fetch_hits_a = Arc::clone(&fetch_hits);
4325        let (_cache_a, outcome_a) =
4326            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4327                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
4328                Ok(Vec::new())
4329            })
4330            .unwrap();
4331
4332        let fetch_hits_b = Arc::clone(&fetch_hits);
4333        let (_cache_b, outcome_b) =
4334            ensure_fresh_with_fetcher(mars.path(), 24, RefreshMode::Auto, move || {
4335                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
4336                Ok(vec![sample_cached_model("unexpected-second-refresh")])
4337            })
4338            .unwrap();
4339
4340        assert!(matches!(
4341            outcome_a,
4342            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
4343        ));
4344        assert_eq!(
4345            outcome_b,
4346            RefreshOutcome::StaleFallback {
4347                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
4348            }
4349        );
4350        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
4351    }
4352
4353    #[test]
4354    fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
4355        let project = tempdir().unwrap();
4356        let ctx = crate::types::MarsContext::for_test(
4357            project.path().to_path_buf(),
4358            project.path().join(".agents"),
4359        );
4360        assert_eq!(load_models_cache_ttl(&ctx), 24);
4361    }
4362
4363    #[test]
4364    fn load_models_cache_ttl_reads_config_value() {
4365        let project = tempdir().unwrap();
4366        std::fs::write(
4367            project.path().join("mars.toml"),
4368            "[settings]\nmodels_cache_ttl_hours = 48\n",
4369        )
4370        .unwrap();
4371        let ctx = crate::types::MarsContext::for_test(
4372            project.path().to_path_buf(),
4373            project.path().join(".agents"),
4374        );
4375        assert_eq!(load_models_cache_ttl(&ctx), 48);
4376    }
4377}