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