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