Skip to main content

mars_agents/models/
mod.rs

1//! Model catalog — two-mode aliases (pinned + auto-resolve),
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, no resolution needed.
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 harness;
23
24mod tracing {
25    macro_rules! debug {
26        ($($arg:tt)*) => {
27            if cfg!(debug_assertions) {
28                eprintln!($($arg)*);
29            }
30        };
31    }
32
33    pub(super) use debug;
34}
35
36// ---------------------------------------------------------------------------
37// Core types
38// ---------------------------------------------------------------------------
39
40/// A model alias — either pinned to a specific model ID or auto-resolved
41/// against the models cache at resolution time.
42#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct ModelAlias {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub harness: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub description: Option<String>,
48    #[serde(flatten)]
49    pub spec: ModelSpec,
50}
51
52/// How a model alias resolves to a concrete model ID.
53#[derive(Debug, Clone, PartialEq)]
54pub enum ModelSpec {
55    /// Explicit model ID — no resolution needed.
56    Pinned {
57        model: String,
58        provider: Option<String>,
59    },
60    /// Pattern-based resolution against models cache.
61    AutoResolve {
62        provider: String,
63        match_patterns: Vec<String>,
64        exclude_patterns: Vec<String>,
65    },
66}
67
68/// How the harness was determined.
69#[derive(Debug, Clone, PartialEq, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HarnessSource {
72    Explicit,
73    AutoDetected,
74    Unavailable,
75}
76
77/// Fully resolved model alias — everything a consumer needs to launch.
78#[derive(Debug, Clone, Serialize)]
79pub struct ResolvedAlias {
80    pub name: String,
81    pub model_id: String,
82    pub provider: String,
83    pub harness: Option<String>,
84    pub harness_source: HarnessSource,
85    pub harness_candidates: Vec<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub description: Option<String>,
88}
89
90// Custom Serialize for ModelSpec to flatten into parent
91impl Serialize for ModelSpec {
92    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93        use serde::ser::SerializeMap;
94        match self {
95            ModelSpec::Pinned { model, provider } => {
96                let mut count = 1;
97                if provider.is_some() {
98                    count += 1;
99                }
100                let mut map = serializer.serialize_map(Some(count))?;
101                map.serialize_entry("model", model)?;
102                if let Some(provider) = provider {
103                    map.serialize_entry("provider", provider)?;
104                }
105                map.end()
106            }
107            ModelSpec::AutoResolve {
108                provider,
109                match_patterns,
110                exclude_patterns,
111            } => {
112                let mut count = 2; // provider + match
113                if !exclude_patterns.is_empty() {
114                    count += 1;
115                }
116                let mut map = serializer.serialize_map(Some(count))?;
117                map.serialize_entry("provider", provider)?;
118                map.serialize_entry("match", match_patterns)?;
119                if !exclude_patterns.is_empty() {
120                    map.serialize_entry("exclude", exclude_patterns)?;
121                }
122                map.end()
123            }
124        }
125    }
126}
127
128/// Raw deserialization helper — distinguished by field presence.
129#[derive(Debug, Deserialize)]
130struct RawModelAlias {
131    harness: Option<String>,
132    #[serde(default)]
133    description: Option<String>,
134    // Pinned mode
135    #[serde(default)]
136    model: Option<String>,
137    // AutoResolve mode
138    #[serde(default)]
139    provider: Option<String>,
140    #[serde(default, rename = "match")]
141    match_patterns: Option<Vec<String>>,
142    #[serde(default)]
143    exclude: Option<Vec<String>>,
144}
145
146impl<'de> Deserialize<'de> for ModelAlias {
147    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
148        let raw = RawModelAlias::deserialize(deserializer)?;
149
150        let has_model = raw.model.is_some();
151        let has_match = raw.match_patterns.is_some();
152
153        if has_model && has_match {
154            return Err(serde::de::Error::custom(
155                "model alias cannot have both 'model' and 'match' — use one or the other",
156            ));
157        }
158
159        let spec = if let Some(model) = raw.model {
160            ModelSpec::Pinned {
161                model,
162                provider: raw.provider,
163            }
164        } else if let Some(match_patterns) = raw.match_patterns {
165            let provider = raw.provider.ok_or_else(|| {
166                serde::de::Error::custom(
167                    "auto-resolve model alias requires 'provider' when 'match' is specified",
168                )
169            })?;
170            ModelSpec::AutoResolve {
171                provider,
172                match_patterns,
173                exclude_patterns: raw.exclude.unwrap_or_default(),
174            }
175        } else {
176            return Err(serde::de::Error::custom(
177                "model alias must have either 'model' (pinned) or 'match' (auto-resolve)",
178            ));
179        };
180
181        Ok(ModelAlias {
182            harness: raw.harness,
183            description: raw.description,
184            spec,
185        })
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Models cache
191// ---------------------------------------------------------------------------
192
193/// Cached model catalog from external API.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ModelsCache {
196    pub models: Vec<CachedModel>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub fetched_at: Option<String>,
199}
200
201/// A single model entry in the cache.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct CachedModel {
204    pub id: String,
205    pub provider: String,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub release_date: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub description: Option<String>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub context_window: Option<u64>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub max_output: Option<u64>,
214}
215
216const CACHE_FILE: &str = "models-cache.json";
217const FETCH_FAIL_MARKER_FILE: &str = ".models-cache.last-fail";
218const DEFAULT_MODELS_CACHE_TTL_HOURS: u32 = 24;
219pub(crate) const FETCH_FAIL_COOLDOWN_SECS: u64 = 300;
220const FETCH_FAIL_COOLDOWN_REASON: &str = "recent fetch attempt failed; backing off (cooldown)";
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum RefreshMode {
224    Auto,
225    Force,
226    Offline,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum RefreshOutcome {
231    AlreadyFresh,
232    Refreshed { models_count: usize },
233    StaleFallback { reason: String },
234    Offline,
235}
236
237pub fn now_unix_secs_value() -> u64 {
238    SystemTime::now()
239        .duration_since(UNIX_EPOCH)
240        .unwrap_or_default()
241        .as_secs()
242}
243
244pub fn now_unix_secs() -> String {
245    now_unix_secs_value().to_string()
246}
247
248pub fn is_mars_offline() -> bool {
249    match std::env::var("MARS_OFFLINE") {
250        Ok(value) => matches!(
251            value.trim().to_ascii_lowercase().as_str(),
252            "1" | "true" | "yes"
253        ),
254        Err(_) => false,
255    }
256}
257
258pub fn resolve_refresh_mode(no_refresh_flag: bool) -> RefreshMode {
259    if no_refresh_flag {
260        RefreshMode::Offline
261    } else {
262        RefreshMode::Auto
263    }
264}
265
266pub fn load_models_cache_ttl(ctx: &MarsContext) -> u32 {
267    crate::config::load(&ctx.project_root)
268        .map(|config| config.settings.models_cache_ttl_hours)
269        .unwrap_or(DEFAULT_MODELS_CACHE_TTL_HOURS)
270}
271
272fn read_cache_tolerant(mars_dir: &Path) -> ModelsCache {
273    match read_cache(mars_dir) {
274        Ok(cache) => cache,
275        Err(err) => {
276            tracing::debug!("models cache read failed, treating as empty: {err}");
277            ModelsCache {
278                models: Vec::new(),
279                fetched_at: None,
280            }
281        }
282    }
283}
284
285fn is_fresh(cache: &ModelsCache, ttl_hours: u32) -> bool {
286    if ttl_hours == 0 {
287        return false;
288    }
289    if cache.models.is_empty() {
290        return false;
291    }
292
293    let Some(fetched_str) = &cache.fetched_at else {
294        return false;
295    };
296    let Ok(fetched) = fetched_str.parse::<u64>() else {
297        return false;
298    };
299
300    let now = now_unix_secs_value();
301    if fetched > now {
302        return false;
303    }
304
305    (now - fetched) < (ttl_hours as u64) * 3600
306}
307
308fn is_usable(cache: &ModelsCache) -> bool {
309    !cache.models.is_empty()
310}
311
312fn read_fetch_fail_marker(mars_dir: &Path) -> Option<u64> {
313    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
314    let raw = std::fs::read_to_string(marker).ok()?;
315    raw.trim().parse::<u64>().ok()
316}
317
318fn write_fetch_fail_marker(mars_dir: &Path, timestamp: u64) {
319    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
320    if let Err(err) = crate::fs::atomic_write(&marker, timestamp.to_string().as_bytes()) {
321        tracing::debug!("failed to write models fetch failure marker: {err}");
322    }
323}
324
325fn clear_fetch_fail_marker(mars_dir: &Path) {
326    let marker = mars_dir.join(FETCH_FAIL_MARKER_FILE);
327    if let Err(err) = std::fs::remove_file(marker)
328        && err.kind() != std::io::ErrorKind::NotFound
329    {
330        tracing::debug!("failed to clear models fetch failure marker: {err}");
331    }
332}
333
334pub fn ensure_fresh(
335    mars_dir: &Path,
336    ttl_hours: u32,
337    mode: RefreshMode,
338) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
339    ensure_fresh_with_fetcher(mars_dir, ttl_hours, mode, fetch_models)
340}
341
342fn ensure_fresh_with_fetcher<F>(
343    mars_dir: &Path,
344    ttl_hours: u32,
345    mode: RefreshMode,
346    fetcher: F,
347) -> Result<(ModelsCache, RefreshOutcome), MarsError>
348where
349    F: FnOnce() -> Result<Vec<CachedModel>, MarsError>,
350{
351    std::fs::create_dir_all(mars_dir)?;
352
353    // D1: apply MARS_OFFLINE coercion exactly once here.
354    let effective_mode = match mode {
355        RefreshMode::Auto if is_mars_offline() => RefreshMode::Offline,
356        m => m,
357    };
358
359    let prior = read_cache_tolerant(mars_dir);
360
361    if effective_mode == RefreshMode::Auto && is_fresh(&prior, ttl_hours) {
362        return Ok((prior, RefreshOutcome::AlreadyFresh));
363    }
364
365    if effective_mode == RefreshMode::Offline {
366        if is_usable(&prior) {
367            return Ok((prior, RefreshOutcome::Offline));
368        }
369        return Err(MarsError::ModelCacheUnavailable {
370            reason: offline_unavailable_reason(mode),
371        });
372    }
373
374    let lock_path = mars_dir.join(".models-cache.lock");
375    let _guard = crate::fs::FileLock::acquire(&lock_path)?;
376
377    let under_lock = read_cache_tolerant(mars_dir);
378    if effective_mode == RefreshMode::Auto && is_fresh(&under_lock, ttl_hours) {
379        return Ok((under_lock, RefreshOutcome::AlreadyFresh));
380    }
381
382    if mode != RefreshMode::Force && is_usable(&under_lock) {
383        let now = now_unix_secs_value();
384        if let Some(last_fail) = read_fetch_fail_marker(mars_dir)
385            && now.saturating_sub(last_fail) < FETCH_FAIL_COOLDOWN_SECS
386        {
387            return Ok((
388                under_lock,
389                RefreshOutcome::StaleFallback {
390                    reason: FETCH_FAIL_COOLDOWN_REASON.to_string(),
391                },
392            ));
393        }
394    }
395
396    match fetcher() {
397        Ok(models) if !models.is_empty() => {
398            let models_count = models.len();
399            let cache = ModelsCache {
400                models,
401                fetched_at: Some(now_unix_secs()),
402            };
403            write_cache(mars_dir, &cache)?;
404            clear_fetch_fail_marker(mars_dir);
405            Ok((cache, RefreshOutcome::Refreshed { models_count }))
406        }
407        Ok(_) => fallback_to_stale_or_error(
408            mars_dir,
409            under_lock,
410            "API returned empty catalog".to_string(),
411            "API returned an empty catalog and no prior cache exists".to_string(),
412            true,
413        ),
414        Err(err) => fallback_to_stale_or_error(
415            mars_dir,
416            under_lock,
417            format!("fetch failed: {err}"),
418            format!("automatic refresh failed: {err}"),
419            true,
420        ),
421    }
422}
423
424fn fallback_to_stale_or_error(
425    mars_dir: &Path,
426    under_lock: ModelsCache,
427    stale_reason: String,
428    unavailable_reason: String,
429    mark_fetch_failure: bool,
430) -> Result<(ModelsCache, RefreshOutcome), MarsError> {
431    if is_usable(&under_lock) {
432        if mark_fetch_failure {
433            write_fetch_fail_marker(mars_dir, now_unix_secs_value());
434        }
435        Ok((
436            under_lock,
437            RefreshOutcome::StaleFallback {
438                reason: stale_reason,
439            },
440        ))
441    } else {
442        Err(MarsError::ModelCacheUnavailable {
443            reason: unavailable_reason,
444        })
445    }
446}
447
448fn offline_unavailable_reason(requested_mode: RefreshMode) -> String {
449    match requested_mode {
450        RefreshMode::Offline => {
451            "--no-refresh-models was passed and no cached catalog is available".to_string()
452        }
453        RefreshMode::Auto => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
454        RefreshMode::Force => "MARS_OFFLINE is set and no cached catalog is available".to_string(),
455    }
456}
457
458/// Read models cache from `.mars/models-cache.json`.
459pub fn read_cache(mars_dir: &Path) -> Result<ModelsCache, MarsError> {
460    let path = mars_dir.join(CACHE_FILE);
461    match std::fs::read_to_string(&path) {
462        Ok(content) => {
463            let cache: ModelsCache =
464                serde_json::from_str(&content).map_err(|e| crate::error::ConfigError::Invalid {
465                    message: format!("failed to parse models cache: {e}"),
466                })?;
467            Ok(cache)
468        }
469        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ModelsCache {
470            models: Vec::new(),
471            fetched_at: None,
472        }),
473        Err(source) => Err(MarsError::Io {
474            operation: "read models cache".to_string(),
475            path,
476            source,
477        }),
478    }
479}
480
481/// Write models cache to `.mars/models-cache.json` (atomic via tmp+rename).
482pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
483    std::fs::create_dir_all(mars_dir)?;
484    let path = mars_dir.join(CACHE_FILE);
485    let tmp_path = mars_dir.join(".models-cache.json.tmp");
486    let content =
487        serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
488            message: format!("failed to serialize models cache: {e}"),
489        })?;
490    std::fs::write(&tmp_path, content)?;
491    std::fs::rename(&tmp_path, &path)?;
492    Ok(())
493}
494
495/// Fetch models from the models.dev API.
496///
497/// Returns a list of cached model entries. On network failure, returns an error
498/// (callers should fall back to existing cache or explicit pinned IDs).
499pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
500    let url = models_api_url();
501    let agent: ureq::Agent = ureq::Agent::config_builder()
502        .timeout_connect(Some(Duration::from_secs(15)))
503        .timeout_recv_response(Some(Duration::from_secs(15)))
504        .timeout_recv_body(Some(Duration::from_secs(15)))
505        .build()
506        .into();
507
508    let response = agent.get(&url).call().map_err(|e| match e {
509        ureq::Error::StatusCode(status) => MarsError::Http {
510            url: url.clone(),
511            status,
512            message: format!("request failed with HTTP status {status}"),
513        },
514        _ => MarsError::Http {
515            url: url.clone(),
516            status: 0,
517            message: format!("failed to fetch models catalog: {e}"),
518        },
519    })?;
520    let body = response
521        .into_body()
522        .read_to_string()
523        .map_err(|e| MarsError::Http {
524            url: url.clone(),
525            status: 0,
526            message: format!("failed to read response body: {e}"),
527        })?;
528    let raw: serde_json::Value =
529        serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
530            message: format!("failed to parse models API response: {e}"),
531        })?;
532
533    parse_models_dev_catalog(&raw)
534}
535
536fn models_api_url() -> String {
537    std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
538}
539
540fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
541    let providers = raw
542        .as_object()
543        .ok_or_else(|| crate::error::ConfigError::Invalid {
544            message: "models API response must be an object keyed by provider".to_string(),
545        })?;
546
547    let mut models = Vec::new();
548
549    for (provider_key, provider_obj) in providers {
550        if !is_major_provider(provider_key) {
551            continue;
552        }
553
554        let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
555            continue;
556        };
557
558        for model_obj in provider_models.values() {
559            let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
560                continue;
561            };
562            let release_date = model_obj
563                .get("release_date")
564                .and_then(|v| v.as_str())
565                .map(str::to_string);
566            let description = model_obj
567                .get("name")
568                .and_then(|v| v.as_str())
569                .map(str::to_string);
570            let context_window = model_obj
571                .get("limit")
572                .and_then(|v| v.get("context"))
573                .and_then(|v| v.as_u64());
574            let max_output = model_obj
575                .get("limit")
576                .and_then(|v| v.get("output"))
577                .and_then(|v| v.as_u64());
578
579            models.push(CachedModel {
580                id: model_id.to_string(),
581                provider: normalize_provider(provider_key),
582                release_date,
583                description,
584                context_window,
585                max_output,
586            });
587        }
588    }
589
590    Ok(models)
591}
592
593fn is_major_provider(provider_key: &str) -> bool {
594    matches!(
595        provider_key,
596        "anthropic"
597            | "openai"
598            | "google"
599            | "meta-llama"
600            | "meta"
601            | "mistralai"
602            | "mistral"
603            | "deepseek"
604            | "cohere"
605    )
606}
607
608/// Normalize models.dev provider keys to canonical names.
609fn normalize_provider(slug: &str) -> String {
610    match slug {
611        "anthropic" => "Anthropic".to_string(),
612        "openai" => "OpenAI".to_string(),
613        "google" => "Google".to_string(),
614        "meta-llama" | "meta" => "Meta".to_string(),
615        "mistralai" | "mistral" => "Mistral".to_string(),
616        "deepseek" => "DeepSeek".to_string(),
617        "cohere" => "Cohere".to_string(),
618        _ => slug.to_string(),
619    }
620}
621
622// ---------------------------------------------------------------------------
623// Auto-resolve algorithm
624// ---------------------------------------------------------------------------
625
626/// Resolve an auto-resolve spec against the models cache.
627///
628/// Algorithm:
629/// 1. Filter by provider (case-insensitive)
630/// 2. All match patterns must hit (AND)
631/// 3. No exclude patterns may hit (OR)
632/// 4. Skip entries ending with `-latest` (synthetic aliases)
633/// 5. Sort by newest release_date, then shortest ID
634/// 6. Pick first
635pub fn auto_resolve(
636    provider: &str,
637    match_patterns: &[String],
638    exclude_patterns: &[String],
639    cache: &ModelsCache,
640) -> Option<String> {
641    let mut candidates: Vec<&CachedModel> = cache
642        .models
643        .iter()
644        .filter(|m| {
645            // Provider match (case-insensitive)
646            m.provider.eq_ignore_ascii_case(provider)
647        })
648        .filter(|m| {
649            // Skip -latest suffix (synthetic aliases)
650            !m.id.ends_with("-latest")
651        })
652        .filter(|m| {
653            // All match patterns must hit (AND)
654            match_patterns.iter().all(|p| glob_match(p, &m.id))
655        })
656        .filter(|m| {
657            // No exclude patterns may hit (OR)
658            !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
659        })
660        .collect();
661
662    // Sort: newest release_date first, then shortest ID (tiebreaker)
663    candidates.sort_by(|a, b| {
664        let date_cmp = b
665            .release_date
666            .as_deref()
667            .unwrap_or("")
668            .cmp(a.release_date.as_deref().unwrap_or(""));
669        date_cmp.then_with(|| a.id.len().cmp(&b.id.len()))
670    });
671
672    candidates.first().map(|m| m.id.clone())
673}
674
675/// Simple glob matching: `*` matches any sequence of characters.
676/// Everything else is literal. Case-sensitive.
677pub fn glob_match(pattern: &str, text: &str) -> bool {
678    // Split pattern on '*' and match segments in order
679    let segments: Vec<&str> = pattern.split('*').collect();
680
681    if segments.len() == 1 {
682        // No wildcards — exact match
683        return pattern == text;
684    }
685
686    let mut pos = 0;
687
688    // First segment must be a prefix
689    if let Some(first) = segments.first()
690        && !first.is_empty()
691    {
692        if !text.starts_with(first) {
693            return false;
694        }
695        pos = first.len();
696    }
697
698    // Last segment must be a suffix
699    if let Some(last) = segments.last()
700        && !last.is_empty()
701        && !text[pos..].ends_with(last)
702    {
703        return false;
704    }
705
706    // Middle segments must appear in order
707    let end = if let Some(last) = segments.last() {
708        if !last.is_empty() {
709            text.len() - last.len()
710        } else {
711            text.len()
712        }
713    } else {
714        text.len()
715    };
716
717    for segment in &segments[1..segments.len().saturating_sub(1)] {
718        if segment.is_empty() {
719            continue;
720        }
721        if let Some(idx) = text[pos..end].find(segment) {
722            pos += idx + segment.len();
723        } else {
724            return false;
725        }
726    }
727
728    pos <= end
729}
730
731// ---------------------------------------------------------------------------
732// Builtin aliases — bare convenience mappings, no descriptions
733// ---------------------------------------------------------------------------
734
735/// Minimal builtin aliases so common model names work out of the box.
736/// No descriptions — packages layer those on top.
737/// Precedence: consumer > deps > builtins.
738pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
739    let mut m = IndexMap::new();
740    let add = |m: &mut IndexMap<String, ModelAlias>,
741               name: &str,
742               provider: &str,
743               match_patterns: &[&str],
744               exclude: &[&str]| {
745        m.insert(
746            name.to_string(),
747            ModelAlias {
748                harness: None,
749                description: None,
750                spec: ModelSpec::AutoResolve {
751                    provider: provider.to_string(),
752                    match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
753                    exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
754                },
755            },
756        );
757    };
758    add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
759    add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
760    add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
761    add(
762        &mut m,
763        "codex",
764        "openai",
765        &["*codex*"],
766        &["*-mini", "*-spark", "*-max"],
767    );
768    add(
769        &mut m,
770        "gpt",
771        "openai",
772        &["gpt-5*"],
773        &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
774    );
775    add(
776        &mut m,
777        "gemini",
778        "google",
779        &["gemini*", "*pro*"],
780        &["*-customtools"],
781    );
782    m
783}
784
785// ---------------------------------------------------------------------------
786// Dependency-tree merge
787// ---------------------------------------------------------------------------
788
789/// Info about a resolved dependency's model config.
790pub struct ResolvedDepModels {
791    pub source_name: String,
792    pub models: IndexMap<String, ModelAlias>,
793}
794
795/// Merge model aliases from dependency tree.
796///
797/// Precedence: consumer > deps (declaration order) > builtins.
798/// When two deps define the same alias, first in declaration order wins
799/// with a diagnostic warning.
800pub fn merge_model_config(
801    consumer: &IndexMap<String, ModelAlias>,
802    deps: &[ResolvedDepModels],
803    diag: &mut DiagnosticCollector,
804) -> IndexMap<String, ModelAlias> {
805    let mut merged = IndexMap::new();
806    let builtins = builtin_aliases();
807
808    // Layer 0 (lowest): builtins
809    for (name, alias) in &builtins {
810        merged.insert(name.clone(), alias.clone());
811    }
812
813    // Track which dep won each alias (vs builtin)
814    let mut dep_provided: std::collections::HashMap<String, String> =
815        std::collections::HashMap::new();
816
817    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
818    for dep in deps {
819        for (name, alias) in &dep.models {
820            if consumer.contains_key(name) {
821                // Consumer will override — skip dep's version silently
822                continue;
823            }
824            if let Some(winner) = dep_provided.get(name) {
825                // Two deps define same alias — first dep wins, warn
826                diag.warn_with_context(
827                    "model-alias-conflict",
828                    format!(
829                        "model alias `{name}` defined by both `{winner}` and `{}` — using {winner} (declared first)\n  → add [models.{name}] to your mars.toml to resolve explicitly",
830                        dep.source_name
831                    ),
832                    dep.source_name.clone(),
833                );
834            } else {
835                // Override builtin or insert new
836                merged.insert(name.clone(), alias.clone());
837                dep_provided.insert(name.clone(), dep.source_name.clone());
838            }
839        }
840    }
841
842    // Layer 2 (highest): consumer config
843    for (name, alias) in consumer {
844        merged.insert(name.clone(), alias.clone());
845    }
846
847    merged
848}
849
850/// Resolve all aliases to concrete model IDs + harnesses.
851///
852/// Harness detection is encapsulated — callers don't pass installed harnesses.
853pub fn resolve_all(
854    aliases: &IndexMap<String, ModelAlias>,
855    cache: &ModelsCache,
856) -> IndexMap<String, ResolvedAlias> {
857    let installed = harness::detect_installed_harnesses();
858    let mut resolved = IndexMap::new();
859
860    for (name, alias) in aliases {
861        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
862            continue; // unresolvable — omit
863        };
864
865        let candidates = harness::harness_candidates_for_provider(&provider);
866        let (h, source) = resolve_harness(alias, &provider, &installed);
867
868        resolved.insert(
869            name.clone(),
870            ResolvedAlias {
871                name: name.clone(),
872                model_id,
873                provider,
874                harness: h,
875                harness_source: source,
876                harness_candidates: candidates,
877                description: alias.description.clone(),
878            },
879        );
880    }
881
882    resolved
883}
884
885/// Filter resolved aliases by visibility config.
886/// - `include` patterns: keep only aliases where at least one pattern matches
887/// - `exclude` patterns: remove aliases where any pattern matches
888/// - No config (both None): return all aliases unchanged
889pub fn filter_by_visibility(
890    mut aliases: IndexMap<String, ResolvedAlias>,
891    visibility: &crate::config::ModelVisibility,
892) -> IndexMap<String, ResolvedAlias> {
893    if let Some(includes) = &visibility.include {
894        aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
895    } else if let Some(excludes) = &visibility.exclude {
896        aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
897    }
898    aliases
899}
900
901fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
902    match &alias.spec {
903        ModelSpec::Pinned { model, provider } => {
904            let p = provider
905                .clone()
906                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
907                .unwrap_or_else(|| "unknown".to_string());
908            Some((model.clone(), p))
909        }
910        ModelSpec::AutoResolve {
911            provider,
912            match_patterns,
913            exclude_patterns,
914        } => {
915            let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
916            Some((id, provider.clone()))
917        }
918    }
919}
920
921fn resolve_harness(
922    alias: &ModelAlias,
923    provider: &str,
924    installed: &HashSet<String>,
925) -> (Option<String>, HarnessSource) {
926    if let Some(h) = &alias.harness {
927        if installed.contains(h) {
928            (Some(h.clone()), HarnessSource::Explicit)
929        } else {
930            (Some(h.clone()), HarnessSource::Unavailable)
931        }
932    } else {
933        match harness::resolve_harness_for_provider(provider, installed) {
934            Some(h) => (Some(h), HarnessSource::AutoDetected),
935            None => (None, HarnessSource::Unavailable),
936        }
937    }
938}
939
940/// Best-effort provider inference from model ID prefixes.
941/// Returns None for unrecognized patterns.
942#[allow(dead_code)]
943fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
944    let id = model_id.to_lowercase();
945    if id.starts_with("claude-") {
946        return Some("anthropic");
947    }
948    if id.starts_with("gpt-")
949        || id.starts_with("o1")
950        || id.starts_with("o3")
951        || id.starts_with("o4")
952        || id.starts_with("codex-")
953    {
954        return Some("openai");
955    }
956    if id.starts_with("gemini") {
957        return Some("google");
958    }
959    if id.starts_with("llama") {
960        return Some("meta");
961    }
962    if id.starts_with("mistral") || id.starts_with("codestral") {
963        return Some("mistral");
964    }
965    if id.starts_with("deepseek") {
966        return Some("deepseek");
967    }
968    if id.starts_with("command") {
969        return Some("cohere");
970    }
971    None
972}
973
974// ---------------------------------------------------------------------------
975// Tests
976// ---------------------------------------------------------------------------
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use httpmock::prelude::*;
982    use std::collections::HashSet;
983    use std::sync::atomic::{AtomicUsize, Ordering};
984    use std::sync::{Arc, mpsc};
985    use std::thread;
986    use tempfile::tempdir;
987
988    use serial_test::serial;
989
990    #[test]
991    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
992        let raw = serde_json::json!({
993            "anthropic": {
994                "models": {
995                    "claude-opus-4-6": {
996                        "id": "claude-opus-4-6",
997                        "name": "Claude Opus 4.6",
998                        "release_date": "2026-02-05",
999                        "limit": {
1000                            "context": 1000000,
1001                            "output": 128000
1002                        }
1003                    }
1004                }
1005            },
1006            "openai": {
1007                "models": {
1008                    "gpt-5": {
1009                        "id": "gpt-5",
1010                        "name": "GPT-5"
1011                    }
1012                }
1013            },
1014            "random-host": {
1015                "models": {
1016                    "foo": {
1017                        "id": "foo"
1018                    }
1019                }
1020            }
1021        });
1022
1023        let models = parse_models_dev_catalog(&raw).unwrap();
1024        assert_eq!(models.len(), 2);
1025
1026        let opus = models
1027            .iter()
1028            .find(|m| m.id == "claude-opus-4-6")
1029            .expect("missing claude-opus-4-6");
1030        assert_eq!(opus.provider, "Anthropic");
1031        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1032        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1033        assert_eq!(opus.context_window, Some(1_000_000));
1034        assert_eq!(opus.max_output, Some(128_000));
1035
1036        let gpt = models
1037            .iter()
1038            .find(|m| m.id == "gpt-5")
1039            .expect("missing gpt-5");
1040        assert_eq!(gpt.provider, "OpenAI");
1041        assert_eq!(gpt.release_date, None);
1042        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1043        assert_eq!(gpt.context_window, None);
1044        assert_eq!(gpt.max_output, None);
1045    }
1046
1047    #[test]
1048    fn parse_models_dev_catalog_requires_object_root() {
1049        let raw = serde_json::json!(["not", "an", "object"]);
1050        let err = parse_models_dev_catalog(&raw).unwrap_err();
1051        assert!(err.to_string().contains("keyed by provider"));
1052    }
1053
1054    // -- glob_match tests --
1055
1056    #[test]
1057    fn glob_exact_match() {
1058        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1059        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1060    }
1061
1062    #[test]
1063    fn glob_star_suffix() {
1064        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1065        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1066        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1067    }
1068
1069    #[test]
1070    fn glob_star_prefix() {
1071        assert!(glob_match("*-opus-4", "claude-opus-4"));
1072        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1073    }
1074
1075    #[test]
1076    fn glob_star_middle() {
1077        assert!(glob_match("claude-*-4", "claude-opus-4"));
1078        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1079        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1080    }
1081
1082    #[test]
1083    fn glob_multiple_stars() {
1084        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1085        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1086        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1087    }
1088
1089    #[test]
1090    fn glob_star_only() {
1091        assert!(glob_match("*", "anything"));
1092        assert!(glob_match("*", ""));
1093    }
1094
1095    #[test]
1096    fn glob_empty_pattern() {
1097        assert!(glob_match("", ""));
1098        assert!(!glob_match("", "something"));
1099    }
1100
1101    // -- auto_resolve tests --
1102
1103    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1104        ModelsCache {
1105            models: models
1106                .into_iter()
1107                .map(|(id, provider, date)| CachedModel {
1108                    id: id.to_string(),
1109                    provider: provider.to_string(),
1110                    release_date: date.map(String::from),
1111                    description: None,
1112                    context_window: None,
1113                    max_output: None,
1114                })
1115                .collect(),
1116            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1117        }
1118    }
1119
1120    #[test]
1121    fn auto_resolve_basic() {
1122        let cache = make_cache(vec![
1123            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1124            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1125            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1126        ]);
1127
1128        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1129        // Newest date wins
1130        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1131    }
1132
1133    #[test]
1134    fn auto_resolve_exclude() {
1135        let cache = make_cache(vec![
1136            ("gpt-5", "OpenAI", Some("2025-06-01")),
1137            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1138            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1139        ]);
1140
1141        let result = auto_resolve(
1142            "OpenAI",
1143            &["gpt-*".to_string()],
1144            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1145            &cache,
1146        );
1147        assert_eq!(result, Some("gpt-5".to_string()));
1148    }
1149
1150    #[test]
1151    fn auto_resolve_skip_latest() {
1152        let cache = make_cache(vec![
1153            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1154            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1155        ]);
1156
1157        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1158        // Should skip -latest even though it has a newer date
1159        assert_eq!(result, Some("claude-opus-4".to_string()));
1160    }
1161
1162    #[test]
1163    fn auto_resolve_empty_cache() {
1164        let cache = ModelsCache {
1165            models: Vec::new(),
1166            fetched_at: None,
1167        };
1168
1169        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1170        assert_eq!(result, None);
1171    }
1172
1173    #[test]
1174    fn auto_resolve_no_match() {
1175        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1176
1177        let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1178        assert_eq!(result, None);
1179    }
1180
1181    #[test]
1182    fn auto_resolve_provider_case_insensitive() {
1183        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1184
1185        let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1186        assert_eq!(result, Some("claude-opus-4".to_string()));
1187    }
1188
1189    #[test]
1190    fn auto_resolve_shortest_id_tiebreaker() {
1191        let cache = make_cache(vec![
1192            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1193            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1194        ]);
1195
1196        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1197        // Same date — shorter ID wins
1198        assert_eq!(result, Some("claude-opus-4".to_string()));
1199    }
1200
1201    // -- merge_model_config tests --
1202
1203    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1204        ModelAlias {
1205            harness: harness.map(|h| h.to_string()),
1206            description: None,
1207            spec: ModelSpec::Pinned {
1208                model: model.to_string(),
1209                provider: None,
1210            },
1211        }
1212    }
1213
1214    #[test]
1215    fn merge_empty_returns_builtins() {
1216        let mut diag = DiagnosticCollector::new();
1217        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
1218        // Empty consumer + no deps = builtins only
1219        assert!(merged.contains_key("opus"));
1220        assert!(merged.contains_key("sonnet"));
1221        assert!(merged.contains_key("codex"));
1222    }
1223
1224    #[test]
1225    fn merge_consumer_overrides_dependency_alias() {
1226        let mut consumer = IndexMap::new();
1227        consumer.insert(
1228            "opus".to_string(),
1229            pinned_alias(Some("custom"), "my-opus-model"),
1230        );
1231
1232        let mut diag = DiagnosticCollector::new();
1233        let merged = merge_model_config(&consumer, &[], &mut diag);
1234        assert_eq!(
1235            merged.get("opus").unwrap().spec,
1236            ModelSpec::Pinned {
1237                model: "my-opus-model".to_string(),
1238                provider: None
1239            }
1240        );
1241    }
1242
1243    #[test]
1244    fn merge_dep_overrides_builtin() {
1245        let dep = ResolvedDepModels {
1246            source_name: "my-pkg".to_string(),
1247            models: {
1248                let mut m = IndexMap::new();
1249                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
1250                m
1251            },
1252        };
1253
1254        let mut diag = DiagnosticCollector::new();
1255        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
1256        // Dep overrides builtin
1257        assert_eq!(
1258            merged.get("opus").unwrap().spec,
1259            ModelSpec::Pinned {
1260                model: "pkg-opus".to_string(),
1261                provider: None
1262            }
1263        );
1264    }
1265
1266    #[test]
1267    fn merge_consumer_beats_dep() {
1268        let mut consumer = IndexMap::new();
1269        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
1270
1271        let dep = ResolvedDepModels {
1272            source_name: "pkg".to_string(),
1273            models: {
1274                let mut m = IndexMap::new();
1275                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
1276                m
1277            },
1278        };
1279
1280        let mut diag = DiagnosticCollector::new();
1281        let merged = merge_model_config(&consumer, &[dep], &mut diag);
1282        assert_eq!(
1283            merged.get("opus").unwrap().spec,
1284            ModelSpec::Pinned {
1285                model: "consumer-opus".to_string(),
1286                provider: None
1287            }
1288        );
1289    }
1290
1291    #[test]
1292    fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
1293        let dep1 = ResolvedDepModels {
1294            source_name: "pkg-a".to_string(),
1295            models: {
1296                let mut m = IndexMap::new();
1297                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1298                m
1299            },
1300        };
1301        let dep2 = ResolvedDepModels {
1302            source_name: "pkg-b".to_string(),
1303            models: {
1304                let mut m = IndexMap::new();
1305                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1306                m
1307            },
1308        };
1309
1310        let mut diag = DiagnosticCollector::new();
1311        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1312        // First dep wins
1313        assert_eq!(
1314            merged.get("custom").unwrap().spec,
1315            ModelSpec::Pinned {
1316                model: "model-a".to_string(),
1317                provider: None
1318            }
1319        );
1320        // Should have warned
1321        let warnings = diag.drain();
1322        assert_eq!(warnings.len(), 1);
1323        assert_eq!(warnings[0].code, "model-alias-conflict");
1324        assert_eq!(
1325            warnings[0].message,
1326            "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"
1327        );
1328    }
1329
1330    #[test]
1331    fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
1332        let dep1 = ResolvedDepModels {
1333            source_name: "pkg-a".to_string(),
1334            models: {
1335                let mut m = IndexMap::new();
1336                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1337                m
1338            },
1339        };
1340        let dep2 = ResolvedDepModels {
1341            source_name: "pkg-b".to_string(),
1342            models: {
1343                let mut m = IndexMap::new();
1344                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1345                m
1346            },
1347        };
1348        let dep3 = ResolvedDepModels {
1349            source_name: "pkg-c".to_string(),
1350            models: {
1351                let mut m = IndexMap::new();
1352                m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
1353                m
1354            },
1355        };
1356
1357        let mut diag = DiagnosticCollector::new();
1358        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag);
1359
1360        assert_eq!(
1361            merged.get("custom").unwrap().spec,
1362            ModelSpec::Pinned {
1363                model: "model-a".to_string(),
1364                provider: None
1365            }
1366        );
1367
1368        let warnings = diag.drain();
1369        assert_eq!(warnings.len(), 2);
1370        assert_eq!(
1371            warnings[0].message,
1372            "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"
1373        );
1374        assert_eq!(
1375            warnings[1].message,
1376            "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"
1377        );
1378    }
1379
1380    #[test]
1381    fn merge_consumer_override_suppresses_dep_conflict_warning() {
1382        let mut consumer = IndexMap::new();
1383        consumer.insert(
1384            "custom".to_string(),
1385            pinned_alias(Some("consumer"), "consumer-model"),
1386        );
1387
1388        let dep1 = ResolvedDepModels {
1389            source_name: "pkg-a".to_string(),
1390            models: {
1391                let mut m = IndexMap::new();
1392                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1393                m
1394            },
1395        };
1396        let dep2 = ResolvedDepModels {
1397            source_name: "pkg-b".to_string(),
1398            models: {
1399                let mut m = IndexMap::new();
1400                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1401                m
1402            },
1403        };
1404
1405        let mut diag = DiagnosticCollector::new();
1406        let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag);
1407
1408        assert_eq!(
1409            merged.get("custom").unwrap().spec,
1410            ModelSpec::Pinned {
1411                model: "consumer-model".to_string(),
1412                provider: None
1413            }
1414        );
1415        assert!(diag.drain().is_empty());
1416    }
1417
1418    #[test]
1419    fn merge_dep_conflicts_are_non_blocking() {
1420        let dep1 = ResolvedDepModels {
1421            source_name: "pkg-a".to_string(),
1422            models: {
1423                let mut m = IndexMap::new();
1424                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1425                m
1426            },
1427        };
1428        let dep2 = ResolvedDepModels {
1429            source_name: "pkg-b".to_string(),
1430            models: {
1431                let mut m = IndexMap::new();
1432                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1433                m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
1434                m
1435            },
1436        };
1437
1438        let mut diag = DiagnosticCollector::new();
1439        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1440
1441        assert!(merged.contains_key("opus"));
1442        assert_eq!(
1443            merged.get("custom").unwrap().spec,
1444            ModelSpec::Pinned {
1445                model: "model-a".to_string(),
1446                provider: None
1447            }
1448        );
1449        assert_eq!(
1450            merged.get("extra").unwrap().spec,
1451            ModelSpec::Pinned {
1452                model: "model-extra".to_string(),
1453                provider: None
1454            }
1455        );
1456        assert_eq!(diag.drain().len(), 1);
1457    }
1458
1459    // -- resolve_all tests --
1460
1461    #[test]
1462    fn resolve_all_pinned() {
1463        let mut aliases = IndexMap::new();
1464        aliases.insert(
1465            "fast".to_string(),
1466            pinned_alias(Some("claude"), "claude-haiku-4-5"),
1467        );
1468
1469        let cache = ModelsCache {
1470            models: Vec::new(),
1471            fetched_at: None,
1472        };
1473
1474        let resolved = resolve_all(&aliases, &cache);
1475        let entry = resolved.get("fast").unwrap();
1476        assert_eq!(entry.model_id, "claude-haiku-4-5");
1477        assert_eq!(entry.provider, "anthropic");
1478    }
1479
1480    #[test]
1481    fn resolve_all_pinned_with_provider() {
1482        let mut aliases = IndexMap::new();
1483        aliases.insert(
1484            "fast".to_string(),
1485            ModelAlias {
1486                harness: None,
1487                description: None,
1488                spec: ModelSpec::Pinned {
1489                    model: "gpt-5.3-codex".to_string(),
1490                    provider: Some("openai".to_string()),
1491                },
1492            },
1493        );
1494
1495        let cache = ModelsCache {
1496            models: Vec::new(),
1497            fetched_at: None,
1498        };
1499
1500        let resolved = resolve_all(&aliases, &cache);
1501        let entry = resolved.get("fast").unwrap();
1502        assert_eq!(entry.model_id, "gpt-5.3-codex");
1503        assert_eq!(entry.provider, "openai");
1504        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1505    }
1506
1507    #[test]
1508    fn resolve_all_pinned_auto_detect_harness() {
1509        let mut aliases = IndexMap::new();
1510        aliases.insert(
1511            "opus".to_string(),
1512            ModelAlias {
1513                harness: None,
1514                description: None,
1515                spec: ModelSpec::Pinned {
1516                    model: "claude-opus-4-6".to_string(),
1517                    provider: Some("anthropic".to_string()),
1518                },
1519            },
1520        );
1521
1522        let cache = ModelsCache {
1523            models: Vec::new(),
1524            fetched_at: None,
1525        };
1526
1527        let resolved = resolve_all(&aliases, &cache);
1528        let entry = resolved.get("opus").unwrap();
1529        assert_eq!(entry.model_id, "claude-opus-4-6");
1530        assert_eq!(entry.provider, "anthropic");
1531
1532        let installed = harness::detect_installed_harnesses();
1533        let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1534        let expected_source = if expected_harness.is_some() {
1535            HarnessSource::AutoDetected
1536        } else {
1537            HarnessSource::Unavailable
1538        };
1539
1540        assert_eq!(entry.harness, expected_harness);
1541        assert_eq!(entry.harness_source, expected_source);
1542    }
1543
1544    #[test]
1545    fn resolve_all_auto_detect_harness() {
1546        let mut aliases = IndexMap::new();
1547        aliases.insert(
1548            "gpt".to_string(),
1549            ModelAlias {
1550                harness: None,
1551                description: None,
1552                spec: ModelSpec::AutoResolve {
1553                    provider: "openai".to_string(),
1554                    match_patterns: vec!["gpt-5*".to_string()],
1555                    exclude_patterns: vec![],
1556                },
1557            },
1558        );
1559        let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1560
1561        let resolved = resolve_all(&aliases, &cache);
1562        let entry = resolved.get("gpt").unwrap();
1563        assert_eq!(entry.model_id, "gpt-5");
1564        assert_eq!(entry.provider, "openai");
1565        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1566        match entry.harness_source {
1567            HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1568            HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1569            HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1570        }
1571    }
1572
1573    #[test]
1574    fn resolve_all_unavailable_harness_still_included() {
1575        let mut aliases = IndexMap::new();
1576        aliases.insert(
1577            "opus".to_string(),
1578            ModelAlias {
1579                harness: Some("missing-harness-xyz".to_string()),
1580                description: None,
1581                spec: ModelSpec::Pinned {
1582                    model: "claude-opus-4-6".to_string(),
1583                    provider: None,
1584                },
1585            },
1586        );
1587
1588        let cache = ModelsCache {
1589            models: Vec::new(),
1590            fetched_at: None,
1591        };
1592
1593        let resolved = resolve_all(&aliases, &cache);
1594        let entry = resolved.get("opus").unwrap();
1595        assert_eq!(entry.model_id, "claude-opus-4-6");
1596        assert_eq!(entry.provider, "anthropic");
1597        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1598        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1599    }
1600
1601    #[test]
1602    fn resolve_all_empty_cache_omits_unresolvable() {
1603        let mut aliases = IndexMap::new();
1604        aliases.insert(
1605            "opus".to_string(),
1606            ModelAlias {
1607                harness: Some("claude".to_string()),
1608                description: None,
1609                spec: ModelSpec::AutoResolve {
1610                    provider: "Anthropic".to_string(),
1611                    match_patterns: vec!["claude-opus-*".to_string()],
1612                    exclude_patterns: vec![],
1613                },
1614            },
1615        );
1616        let cache = ModelsCache {
1617            models: Vec::new(),
1618            fetched_at: None,
1619        };
1620
1621        let resolved = resolve_all(&aliases, &cache);
1622        // No cache → auto-resolve can't match → alias omitted from results
1623        assert!(!resolved.contains_key("opus"));
1624    }
1625
1626    fn make_resolved_alias(name: &str) -> ResolvedAlias {
1627        ResolvedAlias {
1628            name: name.to_string(),
1629            model_id: format!("model-{name}"),
1630            provider: "openai".to_string(),
1631            harness: Some("codex".to_string()),
1632            harness_source: HarnessSource::Explicit,
1633            harness_candidates: vec!["codex".to_string()],
1634            description: None,
1635        }
1636    }
1637
1638    #[test]
1639    fn filter_by_visibility_include_mode_keeps_matches_only() {
1640        let mut aliases = IndexMap::new();
1641        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1642        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1643        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
1644
1645        let filtered = filter_by_visibility(
1646            aliases,
1647            &crate::config::ModelVisibility {
1648                include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
1649                exclude: None,
1650            },
1651        );
1652
1653        assert_eq!(filtered.len(), 2);
1654        assert!(filtered.contains_key("opus"));
1655        assert!(filtered.contains_key("gpt-5"));
1656        assert!(!filtered.contains_key("sonnet"));
1657    }
1658
1659    #[test]
1660    fn filter_by_visibility_exclude_mode_removes_matches() {
1661        let mut aliases = IndexMap::new();
1662        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1663        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
1664        aliases.insert(
1665            "deprecated-gpt".to_string(),
1666            make_resolved_alias("deprecated-gpt"),
1667        );
1668
1669        let filtered = filter_by_visibility(
1670            aliases,
1671            &crate::config::ModelVisibility {
1672                include: None,
1673                exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
1674            },
1675        );
1676
1677        assert_eq!(filtered.len(), 1);
1678        assert!(filtered.contains_key("opus"));
1679        assert!(!filtered.contains_key("test-opus"));
1680        assert!(!filtered.contains_key("deprecated-gpt"));
1681    }
1682
1683    #[test]
1684    fn filter_by_visibility_empty_config_returns_all() {
1685        let mut aliases = IndexMap::new();
1686        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1687        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1688        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
1689        assert_eq!(filtered.len(), 2);
1690        assert!(filtered.contains_key("opus"));
1691        assert!(filtered.contains_key("sonnet"));
1692    }
1693
1694    #[test]
1695    fn resolve_model_and_provider_pinned_explicit_provider() {
1696        let alias = ModelAlias {
1697            harness: None,
1698            description: None,
1699            spec: ModelSpec::Pinned {
1700                model: "claude-opus-4-6".to_string(),
1701                provider: Some("anthropic".to_string()),
1702            },
1703        };
1704        let cache = ModelsCache {
1705            models: Vec::new(),
1706            fetched_at: None,
1707        };
1708
1709        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1710        assert_eq!(
1711            resolved,
1712            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1713        );
1714    }
1715
1716    #[test]
1717    fn resolve_model_and_provider_pinned_inferred() {
1718        let alias = ModelAlias {
1719            harness: None,
1720            description: None,
1721            spec: ModelSpec::Pinned {
1722                model: "claude-opus-4-6".to_string(),
1723                provider: None,
1724            },
1725        };
1726        let cache = ModelsCache {
1727            models: Vec::new(),
1728            fetched_at: None,
1729        };
1730
1731        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1732        assert_eq!(
1733            resolved,
1734            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1735        );
1736    }
1737
1738    #[test]
1739    fn resolve_model_and_provider_pinned_unknown() {
1740        let alias = ModelAlias {
1741            harness: None,
1742            description: None,
1743            spec: ModelSpec::Pinned {
1744                model: "my-custom-model".to_string(),
1745                provider: None,
1746            },
1747        };
1748        let cache = ModelsCache {
1749            models: Vec::new(),
1750            fetched_at: None,
1751        };
1752
1753        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1754        assert_eq!(
1755            resolved,
1756            ("my-custom-model".to_string(), "unknown".to_string())
1757        );
1758    }
1759
1760    #[test]
1761    fn resolve_model_and_provider_auto_resolve() {
1762        let alias = ModelAlias {
1763            harness: None,
1764            description: None,
1765            spec: ModelSpec::AutoResolve {
1766                provider: "openai".to_string(),
1767                match_patterns: vec!["gpt-5*".to_string()],
1768                exclude_patterns: vec![],
1769            },
1770        };
1771        let cache = make_cache(vec![
1772            ("gpt-4o", "OpenAI", Some("2024-06-01")),
1773            ("gpt-5", "OpenAI", Some("2025-06-01")),
1774        ]);
1775
1776        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1777        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1778    }
1779
1780    #[test]
1781    fn resolve_harness_explicit_installed() {
1782        let alias = ModelAlias {
1783            harness: Some("claude".to_string()),
1784            description: None,
1785            spec: ModelSpec::Pinned {
1786                model: "claude-opus-4-6".to_string(),
1787                provider: None,
1788            },
1789        };
1790        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1791
1792        let resolved = resolve_harness(&alias, "anthropic", &installed);
1793        assert_eq!(
1794            resolved,
1795            (Some("claude".to_string()), HarnessSource::Explicit)
1796        );
1797    }
1798
1799    #[test]
1800    fn resolve_harness_explicit_not_installed() {
1801        let alias = ModelAlias {
1802            harness: Some("claude".to_string()),
1803            description: None,
1804            spec: ModelSpec::Pinned {
1805                model: "claude-opus-4-6".to_string(),
1806                provider: None,
1807            },
1808        };
1809        let installed = HashSet::new();
1810
1811        let resolved = resolve_harness(&alias, "anthropic", &installed);
1812        assert_eq!(
1813            resolved,
1814            (Some("claude".to_string()), HarnessSource::Unavailable)
1815        );
1816    }
1817
1818    #[test]
1819    fn resolve_harness_auto_detected() {
1820        let alias = ModelAlias {
1821            harness: None,
1822            description: None,
1823            spec: ModelSpec::Pinned {
1824                model: "claude-opus-4-6".to_string(),
1825                provider: Some("anthropic".to_string()),
1826            },
1827        };
1828        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1829
1830        let resolved = resolve_harness(&alias, "anthropic", &installed);
1831        assert_eq!(
1832            resolved,
1833            (Some("claude".to_string()), HarnessSource::AutoDetected)
1834        );
1835    }
1836
1837    #[test]
1838    fn resolve_harness_unavailable() {
1839        let alias = ModelAlias {
1840            harness: None,
1841            description: None,
1842            spec: ModelSpec::Pinned {
1843                model: "claude-opus-4-6".to_string(),
1844                provider: Some("anthropic".to_string()),
1845            },
1846        };
1847        let installed = HashSet::new();
1848
1849        let resolved = resolve_harness(&alias, "anthropic", &installed);
1850        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1851    }
1852
1853    #[test]
1854    fn resolve_harness_unavailable_no_provider_match() {
1855        let alias = ModelAlias {
1856            harness: None,
1857            description: None,
1858            spec: ModelSpec::Pinned {
1859                model: "my-custom-model".to_string(),
1860                provider: Some("unknown".to_string()),
1861            },
1862        };
1863        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1864
1865        let resolved = resolve_harness(&alias, "unknown", &installed);
1866        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1867    }
1868
1869    // -- serde roundtrip tests --
1870
1871    #[test]
1872    fn harness_source_serializes_snake_case() {
1873        assert_eq!(
1874            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1875            "\"explicit\""
1876        );
1877        assert_eq!(
1878            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1879            "\"auto_detected\""
1880        );
1881        assert_eq!(
1882            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1883            "\"unavailable\""
1884        );
1885    }
1886
1887    #[test]
1888    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1889        let toml_str = r#"
1890[models.fast]
1891harness = "claude"
1892model = "claude-haiku-4-5"
1893description = "Fast and cheap"
1894"#;
1895
1896        #[derive(Debug, Deserialize)]
1897        struct Wrapper {
1898            models: IndexMap<String, ModelAlias>,
1899        }
1900
1901        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1902        let alias = parsed.models.get("fast").unwrap();
1903        assert_eq!(
1904            alias.spec,
1905            ModelSpec::Pinned {
1906                model: "claude-haiku-4-5".to_string(),
1907                provider: None
1908            }
1909        );
1910        assert_eq!(alias.harness.as_deref(), Some("claude"));
1911        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1912
1913        let json = serde_json::to_string(alias).unwrap();
1914        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1915        assert_eq!(roundtripped, *alias);
1916    }
1917
1918    #[test]
1919    fn model_alias_pinned_toml_roundtrip_without_harness() {
1920        let toml_str = r#"
1921[models.fast]
1922model = "claude-haiku-4-5"
1923"#;
1924
1925        #[derive(Debug, Deserialize)]
1926        struct Wrapper {
1927            models: IndexMap<String, ModelAlias>,
1928        }
1929
1930        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1931        let alias = parsed.models.get("fast").unwrap();
1932        assert_eq!(alias.harness, None);
1933        assert_eq!(
1934            alias.spec,
1935            ModelSpec::Pinned {
1936                model: "claude-haiku-4-5".to_string(),
1937                provider: None
1938            }
1939        );
1940
1941        let json = serde_json::to_string(alias).unwrap();
1942        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1943        assert!(value.get("harness").is_none());
1944        assert!(value.get("provider").is_none());
1945        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1946        assert_eq!(roundtripped, *alias);
1947    }
1948
1949    #[test]
1950    fn model_alias_pinned_toml_roundtrip_with_provider() {
1951        let toml_str = r#"
1952[models.fast]
1953model = "claude-haiku-4-5"
1954provider = "anthropic"
1955"#;
1956
1957        #[derive(Debug, Deserialize)]
1958        struct Wrapper {
1959            models: IndexMap<String, ModelAlias>,
1960        }
1961
1962        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1963        let alias = parsed.models.get("fast").unwrap();
1964        assert_eq!(alias.harness, None);
1965        assert_eq!(
1966            alias.spec,
1967            ModelSpec::Pinned {
1968                model: "claude-haiku-4-5".to_string(),
1969                provider: Some("anthropic".to_string())
1970            }
1971        );
1972
1973        let json = serde_json::to_string(alias).unwrap();
1974        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1975        assert_eq!(
1976            value.get("provider").and_then(serde_json::Value::as_str),
1977            Some("anthropic")
1978        );
1979        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1980        assert_eq!(roundtripped, *alias);
1981    }
1982
1983    #[test]
1984    fn model_alias_pinned_json_roundtrip_with_provider() {
1985        let json = r#"{
1986            "model": "gpt-5.3-codex",
1987            "provider": "openai"
1988        }"#;
1989
1990        let alias: ModelAlias = serde_json::from_str(json).unwrap();
1991        assert_eq!(alias.harness, None);
1992        assert_eq!(alias.description, None);
1993        assert_eq!(
1994            alias.spec,
1995            ModelSpec::Pinned {
1996                model: "gpt-5.3-codex".to_string(),
1997                provider: Some("openai".to_string())
1998            }
1999        );
2000
2001        let encoded = serde_json::to_string(&alias).unwrap();
2002        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
2003        assert_eq!(roundtripped, alias);
2004    }
2005
2006    #[test]
2007    fn model_alias_auto_resolve_toml_roundtrip() {
2008        let toml_str = r#"
2009[models.opus]
2010harness = "claude"
2011provider = "Anthropic"
2012match = ["claude-opus-*"]
2013exclude = ["claude-opus-3*"]
2014description = "Best reasoning"
2015"#;
2016
2017        #[derive(Debug, Deserialize)]
2018        struct Wrapper {
2019            models: IndexMap<String, ModelAlias>,
2020        }
2021
2022        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2023        let alias = parsed.models.get("opus").unwrap();
2024        assert_eq!(alias.harness.as_deref(), Some("claude"));
2025        match &alias.spec {
2026            ModelSpec::AutoResolve {
2027                provider,
2028                match_patterns,
2029                exclude_patterns,
2030            } => {
2031                assert_eq!(provider, "Anthropic");
2032                assert_eq!(match_patterns, &["claude-opus-*"]);
2033                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
2034            }
2035            _ => panic!("expected AutoResolve"),
2036        }
2037    }
2038
2039    #[test]
2040    fn model_alias_both_model_and_match_errors() {
2041        let toml_str = r#"
2042[models.bad]
2043harness = "claude"
2044model = "some-model"
2045match = ["pattern-*"]
2046"#;
2047
2048        #[derive(Debug, Deserialize)]
2049        struct Wrapper {
2050            #[expect(dead_code)]
2051            models: IndexMap<String, ModelAlias>,
2052        }
2053
2054        let result = toml::from_str::<Wrapper>(toml_str);
2055        assert!(result.is_err());
2056        let err_msg = result.unwrap_err().to_string();
2057        assert!(err_msg.contains("both"));
2058    }
2059
2060    #[test]
2061    fn model_alias_neither_model_nor_match_errors() {
2062        let toml_str = r#"
2063[models.bad]
2064harness = "claude"
2065"#;
2066
2067        #[derive(Debug, Deserialize)]
2068        struct Wrapper {
2069            #[expect(dead_code)]
2070            models: IndexMap<String, ModelAlias>,
2071        }
2072
2073        let result = toml::from_str::<Wrapper>(toml_str);
2074        assert!(result.is_err());
2075    }
2076
2077    #[test]
2078    fn infer_provider_from_model_id_detects_known_prefixes() {
2079        assert_eq!(
2080            infer_provider_from_model_id("claude-opus-4-6"),
2081            Some("anthropic")
2082        );
2083        assert_eq!(
2084            infer_provider_from_model_id("gpt-5.3-codex"),
2085            Some("openai")
2086        );
2087        assert_eq!(
2088            infer_provider_from_model_id("gemini-2.5-pro"),
2089            Some("google")
2090        );
2091        assert_eq!(
2092            infer_provider_from_model_id("llama-4-maverick"),
2093            Some("meta")
2094        );
2095        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
2096        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
2097        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
2098        assert_eq!(
2099            infer_provider_from_model_id("codex-mini-latest"),
2100            Some("openai")
2101        );
2102        assert_eq!(
2103            infer_provider_from_model_id("mistral-large"),
2104            Some("mistral")
2105        );
2106        assert_eq!(
2107            infer_provider_from_model_id("codestral-latest"),
2108            Some("mistral")
2109        );
2110        assert_eq!(
2111            infer_provider_from_model_id("deepseek-chat"),
2112            Some("deepseek")
2113        );
2114        assert_eq!(
2115            infer_provider_from_model_id("command-r-plus"),
2116            Some("cohere")
2117        );
2118    }
2119
2120    #[test]
2121    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
2122        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
2123    }
2124
2125    #[test]
2126    fn infer_provider_from_model_id_returns_none_for_empty_string() {
2127        assert_eq!(infer_provider_from_model_id(""), None);
2128    }
2129
2130    #[test]
2131    fn infer_provider_from_model_id_is_case_insensitive() {
2132        assert_eq!(
2133            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
2134            Some("anthropic")
2135        );
2136        assert_eq!(
2137            infer_provider_from_model_id("GPT-5.3-codex"),
2138            Some("openai")
2139        );
2140        assert_eq!(
2141            infer_provider_from_model_id("CoDeStRaL-latest"),
2142            Some("mistral")
2143        );
2144    }
2145
2146    #[allow(unused_unsafe)]
2147    fn env_set(key: &str, value: &str) {
2148        unsafe {
2149            std::env::set_var(key, value);
2150        }
2151    }
2152
2153    #[allow(unused_unsafe)]
2154    fn env_remove(key: &str) {
2155        unsafe {
2156            std::env::remove_var(key);
2157        }
2158    }
2159
2160    struct EnvVarGuard {
2161        key: String,
2162        prev: Option<String>,
2163    }
2164
2165    impl EnvVarGuard {
2166        fn set(key: &str, value: &str) -> Self {
2167            let prev = std::env::var(key).ok();
2168            env_set(key, value);
2169            Self {
2170                key: key.to_string(),
2171                prev,
2172            }
2173        }
2174    }
2175
2176    impl Drop for EnvVarGuard {
2177        fn drop(&mut self) {
2178            if let Some(prev) = &self.prev {
2179                env_set(&self.key, prev);
2180            } else {
2181                env_remove(&self.key);
2182            }
2183        }
2184    }
2185
2186    fn sample_catalog_json() -> serde_json::Value {
2187        serde_json::json!({
2188            "openai": {
2189                "models": {
2190                    "gpt-5": {
2191                        "id": "gpt-5",
2192                        "name": "GPT-5",
2193                        "release_date": "2025-06-01",
2194                        "limit": {
2195                            "context": 400000,
2196                            "output": 128000
2197                        }
2198                    }
2199                }
2200            },
2201            "anthropic": {
2202                "models": {
2203                    "claude-sonnet-4-5": {
2204                        "id": "claude-sonnet-4-5",
2205                        "name": "Claude Sonnet 4.5",
2206                        "release_date": "2025-03-01"
2207                    }
2208                }
2209            }
2210        })
2211    }
2212
2213    fn sample_cached_model(id: &str) -> CachedModel {
2214        CachedModel {
2215            id: id.to_string(),
2216            provider: "OpenAI".to_string(),
2217            release_date: None,
2218            description: None,
2219            context_window: None,
2220            max_output: None,
2221        }
2222    }
2223
2224    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
2225        write_cache(
2226            mars_dir,
2227            &ModelsCache {
2228                models,
2229                fetched_at: Some(fetched_at.to_string()),
2230            },
2231        )
2232        .expect("failed to write cache fixture");
2233    }
2234
2235    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
2236        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
2237        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
2238    }
2239
2240    fn stale_timestamp() -> String {
2241        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
2242    }
2243
2244    fn fresh_timestamp() -> String {
2245        now_unix_secs_value().saturating_sub(60).to_string()
2246    }
2247
2248    fn assert_model_cache_unavailable(
2249        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
2250        reason_contains: &str,
2251    ) {
2252        match result {
2253            Err(MarsError::ModelCacheUnavailable { reason }) => {
2254                assert!(
2255                    reason.contains(reason_contains),
2256                    "unexpected reason: {reason}"
2257                );
2258            }
2259            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
2260        }
2261    }
2262
2263    #[test]
2264    #[serial]
2265    fn ensure_fresh_1_missing_cache_offline_errors() {
2266        let mars = tempdir().unwrap();
2267        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2268
2269        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2270        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
2271    }
2272
2273    #[test]
2274    #[serial]
2275    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
2276        let mars = tempdir().unwrap();
2277        let server = MockServer::start();
2278        let mock = server.mock(|when, then| {
2279            when.method(GET).path("/api.json");
2280            then.status(500).body("server error");
2281        });
2282        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2283
2284        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2285        assert_model_cache_unavailable(result, "automatic refresh failed");
2286        assert_eq!(mock.hits(), 1);
2287    }
2288
2289    #[test]
2290    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
2291        let mars = tempdir().unwrap();
2292        write_cache_state(
2293            mars.path(),
2294            vec![sample_cached_model("stale-model")],
2295            &stale_timestamp(),
2296        );
2297
2298        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
2299        assert_eq!(cache.models.len(), 1);
2300        assert_eq!(cache.models[0].id, "stale-model");
2301        assert_eq!(outcome, RefreshOutcome::Offline);
2302    }
2303
2304    #[test]
2305    #[serial]
2306    fn ensure_fresh_4_fresh_auto_skips_http() {
2307        let mars = tempdir().unwrap();
2308        write_cache_state(
2309            mars.path(),
2310            vec![sample_cached_model("fresh-model")],
2311            &fresh_timestamp(),
2312        );
2313
2314        let server = MockServer::start();
2315        let mock = server.mock(|when, then| {
2316            when.method(GET).path("/api.json");
2317            then.status(200).json_body(sample_catalog_json());
2318        });
2319        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2320
2321        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2322        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
2323        assert_eq!(mock.hits(), 0);
2324    }
2325
2326    #[test]
2327    #[serial]
2328    fn ensure_fresh_5_stale_auto_success_refreshes() {
2329        let mars = tempdir().unwrap();
2330        write_cache_state(
2331            mars.path(),
2332            vec![sample_cached_model("old-model")],
2333            &stale_timestamp(),
2334        );
2335
2336        let server = MockServer::start();
2337        let mock = server.mock(|when, then| {
2338            when.method(GET).path("/api.json");
2339            then.status(200).json_body(sample_catalog_json());
2340        });
2341        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2342
2343        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2344        assert!(matches!(
2345            outcome,
2346            RefreshOutcome::Refreshed { models_count } if models_count == 2
2347        ));
2348        assert_eq!(cache.models.len(), 2);
2349        assert!(!cache.models.is_empty());
2350        assert!(cache.fetched_at.is_some());
2351        assert_eq!(mock.hits(), 1);
2352    }
2353
2354    #[test]
2355    #[serial]
2356    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
2357        let mars = tempdir().unwrap();
2358        write_cache_state(
2359            mars.path(),
2360            vec![sample_cached_model("stale-model")],
2361            &stale_timestamp(),
2362        );
2363
2364        let server = MockServer::start();
2365        let mock = server.mock(|when, then| {
2366            when.method(GET).path("/api.json");
2367            then.status(500).body("server error");
2368        });
2369        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2370
2371        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2372        assert_eq!(cache.models[0].id, "stale-model");
2373        assert!(matches!(
2374            outcome,
2375            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2376        ));
2377        assert_eq!(mock.hits(), 1);
2378    }
2379
2380    #[test]
2381    #[serial]
2382    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
2383        let mars = tempdir().unwrap();
2384        write_cache_state(
2385            mars.path(),
2386            vec![sample_cached_model("stale-model")],
2387            &stale_timestamp(),
2388        );
2389
2390        let server = MockServer::start();
2391        let mock = server.mock(|when, then| {
2392            when.method(GET).path("/api.json");
2393            then.status(200).json_body(serde_json::json!({}));
2394        });
2395        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2396
2397        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2398        assert_eq!(cache.models[0].id, "stale-model");
2399        assert!(matches!(
2400            outcome,
2401            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
2402        ));
2403        assert_eq!(mock.hits(), 1);
2404    }
2405
2406    #[test]
2407    #[serial]
2408    fn ensure_fresh_8_empty_cache_auto_refetches() {
2409        let mars = tempdir().unwrap();
2410        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2411
2412        let server = MockServer::start();
2413        let mock = server.mock(|when, then| {
2414            when.method(GET).path("/api.json");
2415            then.status(200).json_body(sample_catalog_json());
2416        });
2417        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2418
2419        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2420        assert!(!cache.models.is_empty());
2421        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2422        assert_eq!(mock.hits(), 1);
2423    }
2424
2425    #[test]
2426    fn ensure_fresh_9_empty_cache_offline_errors() {
2427        let mars = tempdir().unwrap();
2428        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2429
2430        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2431        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2432    }
2433
2434    #[test]
2435    #[serial]
2436    fn ensure_fresh_10_corrupt_json_auto_refetches() {
2437        let mars = tempdir().unwrap();
2438        write_raw_cache_file(mars.path(), "{ not-json ");
2439
2440        let server = MockServer::start();
2441        let mock = server.mock(|when, then| {
2442            when.method(GET).path("/api.json");
2443            then.status(200).json_body(sample_catalog_json());
2444        });
2445        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2446
2447        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2448        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2449        assert!(!cache.models.is_empty());
2450        assert_eq!(mock.hits(), 1);
2451    }
2452
2453    #[test]
2454    fn ensure_fresh_11_corrupt_json_offline_errors() {
2455        let mars = tempdir().unwrap();
2456        write_raw_cache_file(mars.path(), "{ not-json ");
2457
2458        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2459        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2460    }
2461
2462    #[test]
2463    fn read_cache_io_error_includes_operation_and_path() {
2464        let mars = tempdir().unwrap();
2465        let cache_path = mars.path().join(CACHE_FILE);
2466        std::fs::create_dir(&cache_path).unwrap();
2467
2468        let err = read_cache(mars.path()).unwrap_err();
2469        let msg = err.to_string();
2470
2471        assert!(
2472            msg.contains("read models cache"),
2473            "error should include operation context: {msg}"
2474        );
2475        assert!(
2476            msg.contains(CACHE_FILE),
2477            "error should include cache path: {msg}"
2478        );
2479    }
2480
2481    #[test]
2482    #[serial]
2483    fn ensure_fresh_12_ttl_zero_always_refetches() {
2484        let mars = tempdir().unwrap();
2485        write_cache_state(
2486            mars.path(),
2487            vec![sample_cached_model("fresh-model")],
2488            &fresh_timestamp(),
2489        );
2490
2491        let server = MockServer::start();
2492        let mock = server.mock(|when, then| {
2493            when.method(GET).path("/api.json");
2494            then.status(200).json_body(sample_catalog_json());
2495        });
2496        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2497
2498        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
2499        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2500        assert_eq!(mock.hits(), 1);
2501    }
2502
2503    #[test]
2504    #[serial]
2505    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
2506        let mars = tempdir().unwrap();
2507        write_cache_state(
2508            mars.path(),
2509            vec![sample_cached_model("stale-model")],
2510            "not-a-timestamp",
2511        );
2512
2513        let server = MockServer::start();
2514        let mock = server.mock(|when, then| {
2515            when.method(GET).path("/api.json");
2516            then.status(200).json_body(sample_catalog_json());
2517        });
2518        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2519
2520        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2521        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2522        assert_eq!(mock.hits(), 1);
2523    }
2524
2525    #[test]
2526    #[serial]
2527    fn ensure_fresh_14_future_fetched_at_is_stale() {
2528        let mars = tempdir().unwrap();
2529        let future = now_unix_secs_value() + 3600;
2530        write_cache_state(
2531            mars.path(),
2532            vec![sample_cached_model("future-model")],
2533            &future.to_string(),
2534        );
2535
2536        let server = MockServer::start();
2537        let mock = server.mock(|when, then| {
2538            when.method(GET).path("/api.json");
2539            then.status(200).json_body(sample_catalog_json());
2540        });
2541        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2542
2543        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2544        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2545        assert_eq!(mock.hits(), 1);
2546    }
2547
2548    #[test]
2549    #[serial]
2550    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
2551        let mars = tempdir().unwrap();
2552        write_cache_state(
2553            mars.path(),
2554            vec![sample_cached_model("fresh-model")],
2555            &fresh_timestamp(),
2556        );
2557
2558        let server = MockServer::start();
2559        let mock = server.mock(|when, then| {
2560            when.method(GET).path("/api.json");
2561            then.status(200).json_body(sample_catalog_json());
2562        });
2563        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2564        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2565
2566        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2567        assert_eq!(outcome, RefreshOutcome::Offline);
2568        assert_eq!(mock.hits(), 0);
2569    }
2570
2571    #[test]
2572    #[serial]
2573    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
2574        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
2575        assert!(!is_mars_offline());
2576        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2577    }
2578
2579    #[test]
2580    #[serial]
2581    fn ensure_fresh_17_offline_env_truthy_is_offline() {
2582        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
2583        assert!(is_mars_offline());
2584        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2585    }
2586
2587    #[test]
2588    #[serial]
2589    fn ensure_fresh_18_force_ignores_offline_env() {
2590        let mars = tempdir().unwrap();
2591        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2592
2593        let server = MockServer::start();
2594        let mock = server.mock(|when, then| {
2595            when.method(GET).path("/api.json");
2596            then.status(200).json_body(sample_catalog_json());
2597        });
2598        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2599
2600        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
2601        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2602        assert_eq!(mock.hits(), 1);
2603    }
2604
2605    #[test]
2606    #[serial]
2607    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
2608        let mars = tempdir().unwrap();
2609        write_cache_state(
2610            mars.path(),
2611            vec![sample_cached_model("stale-model")],
2612            &stale_timestamp(),
2613        );
2614
2615        let path = Arc::new(mars.path().to_path_buf());
2616        let path_a = Arc::clone(&path);
2617        let path_b = Arc::clone(&path);
2618        let fetch_hits = Arc::new(AtomicUsize::new(0));
2619        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
2620        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
2621
2622        let fetch_hits_a = Arc::clone(&fetch_hits);
2623        let t1 = thread::spawn(move || {
2624            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
2625                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
2626                fetch_started_tx.send(()).unwrap();
2627                release_fetch_rx.recv().unwrap();
2628                Ok(vec![sample_cached_model("fresh-model")])
2629            })
2630            .unwrap()
2631            .1
2632        });
2633
2634        fetch_started_rx.recv().unwrap();
2635
2636        let fetch_hits_b = Arc::clone(&fetch_hits);
2637        let t2 = thread::spawn(move || {
2638            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
2639                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
2640                Ok(vec![sample_cached_model("unexpected-second-refresh")])
2641            })
2642            .unwrap()
2643            .1
2644        });
2645
2646        release_fetch_tx.send(()).unwrap();
2647
2648        let outcome_a = t1.join().unwrap();
2649        let outcome_b = t2.join().unwrap();
2650
2651        let outcomes = [outcome_a, outcome_b];
2652        let refreshed = outcomes
2653            .iter()
2654            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
2655            .count();
2656        let already_fresh = outcomes
2657            .iter()
2658            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
2659            .count();
2660
2661        assert_eq!(refreshed, 1);
2662        assert_eq!(already_fresh, 1);
2663        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
2664    }
2665
2666    #[test]
2667    #[serial]
2668    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
2669        let mars = tempdir().unwrap();
2670        write_cache_state(
2671            mars.path(),
2672            vec![sample_cached_model("stale-model")],
2673            &stale_timestamp(),
2674        );
2675
2676        let server = MockServer::start();
2677        let mock = server.mock(|when, then| {
2678            when.method(GET).path("/api.json");
2679            then.status(500).body("server error");
2680        });
2681        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2682
2683        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2684        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2685
2686        assert!(matches!(
2687            outcome_a,
2688            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2689        ));
2690        assert_eq!(
2691            outcome_b,
2692            RefreshOutcome::StaleFallback {
2693                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2694            }
2695        );
2696        assert_eq!(mock.hits(), 1);
2697    }
2698
2699    #[test]
2700    #[serial]
2701    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
2702        let mars = tempdir().unwrap();
2703        write_cache_state(
2704            mars.path(),
2705            vec![sample_cached_model("stale-model")],
2706            &stale_timestamp(),
2707        );
2708
2709        let server = MockServer::start();
2710        let mock = server.mock(|when, then| {
2711            when.method(GET).path("/api.json");
2712            then.status(200).json_body(serde_json::json!({
2713                "openai": {
2714                    "models": {}
2715                }
2716            }));
2717        });
2718        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2719
2720        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2721        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2722
2723        assert!(matches!(
2724            outcome_a,
2725            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
2726        ));
2727        assert_eq!(
2728            outcome_b,
2729            RefreshOutcome::StaleFallback {
2730                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2731            }
2732        );
2733        assert_eq!(mock.hits(), 1);
2734    }
2735
2736    #[test]
2737    fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
2738        let project = tempdir().unwrap();
2739        let ctx = crate::types::MarsContext::for_test(
2740            project.path().to_path_buf(),
2741            project.path().join(".agents"),
2742        );
2743        assert_eq!(load_models_cache_ttl(&ctx), 24);
2744    }
2745
2746    #[test]
2747    fn load_models_cache_ttl_reads_config_value() {
2748        let project = tempdir().unwrap();
2749        std::fs::write(
2750            project.path().join("mars.toml"),
2751            "[settings]\nmodels_cache_ttl_hours = 48\n",
2752        )
2753        .unwrap();
2754        let ctx = crate::types::MarsContext::for_test(
2755            project.path().to_path_buf(),
2756            project.path().join(".agents"),
2757        );
2758        assert_eq!(load_models_cache_ttl(&ctx), 48);
2759    }
2760}