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