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(e) => Err(MarsError::Io(e)),
474    }
475}
476
477/// Write models cache to `.mars/models-cache.json` (atomic via tmp+rename).
478pub fn write_cache(mars_dir: &Path, cache: &ModelsCache) -> Result<(), MarsError> {
479    std::fs::create_dir_all(mars_dir)?;
480    let path = mars_dir.join(CACHE_FILE);
481    let tmp_path = mars_dir.join(".models-cache.json.tmp");
482    let content =
483        serde_json::to_string_pretty(cache).map_err(|e| crate::error::ConfigError::Invalid {
484            message: format!("failed to serialize models cache: {e}"),
485        })?;
486    std::fs::write(&tmp_path, content)?;
487    std::fs::rename(&tmp_path, &path)?;
488    Ok(())
489}
490
491/// Fetch models from the models.dev API.
492///
493/// Returns a list of cached model entries. On network failure, returns an error
494/// (callers should fall back to existing cache or explicit pinned IDs).
495pub fn fetch_models() -> Result<Vec<CachedModel>, MarsError> {
496    let url = models_api_url();
497    let agent: ureq::Agent = ureq::Agent::config_builder()
498        .timeout_connect(Some(Duration::from_secs(15)))
499        .timeout_recv_response(Some(Duration::from_secs(15)))
500        .timeout_recv_body(Some(Duration::from_secs(15)))
501        .build()
502        .into();
503
504    let response = agent.get(&url).call().map_err(|e| match e {
505        ureq::Error::StatusCode(status) => MarsError::Http {
506            url: url.clone(),
507            status,
508            message: format!("request failed with HTTP status {status}"),
509        },
510        _ => MarsError::Http {
511            url: url.clone(),
512            status: 0,
513            message: format!("failed to fetch models catalog: {e}"),
514        },
515    })?;
516    let body = response
517        .into_body()
518        .read_to_string()
519        .map_err(|e| MarsError::Http {
520            url: url.clone(),
521            status: 0,
522            message: format!("failed to read response body: {e}"),
523        })?;
524    let raw: serde_json::Value =
525        serde_json::from_str(&body).map_err(|e| crate::error::ConfigError::Invalid {
526            message: format!("failed to parse models API response: {e}"),
527        })?;
528
529    parse_models_dev_catalog(&raw)
530}
531
532fn models_api_url() -> String {
533    std::env::var("MARS_MODELS_API_URL").unwrap_or_else(|_| "https://models.dev/api.json".into())
534}
535
536fn parse_models_dev_catalog(raw: &serde_json::Value) -> Result<Vec<CachedModel>, MarsError> {
537    let providers = raw
538        .as_object()
539        .ok_or_else(|| crate::error::ConfigError::Invalid {
540            message: "models API response must be an object keyed by provider".to_string(),
541        })?;
542
543    let mut models = Vec::new();
544
545    for (provider_key, provider_obj) in providers {
546        if !is_major_provider(provider_key) {
547            continue;
548        }
549
550        let Some(provider_models) = provider_obj.get("models").and_then(|m| m.as_object()) else {
551            continue;
552        };
553
554        for model_obj in provider_models.values() {
555            let Some(model_id) = model_obj.get("id").and_then(|v| v.as_str()) else {
556                continue;
557            };
558            let release_date = model_obj
559                .get("release_date")
560                .and_then(|v| v.as_str())
561                .map(str::to_string);
562            let description = model_obj
563                .get("name")
564                .and_then(|v| v.as_str())
565                .map(str::to_string);
566            let context_window = model_obj
567                .get("limit")
568                .and_then(|v| v.get("context"))
569                .and_then(|v| v.as_u64());
570            let max_output = model_obj
571                .get("limit")
572                .and_then(|v| v.get("output"))
573                .and_then(|v| v.as_u64());
574
575            models.push(CachedModel {
576                id: model_id.to_string(),
577                provider: normalize_provider(provider_key),
578                release_date,
579                description,
580                context_window,
581                max_output,
582            });
583        }
584    }
585
586    Ok(models)
587}
588
589fn is_major_provider(provider_key: &str) -> bool {
590    matches!(
591        provider_key,
592        "anthropic"
593            | "openai"
594            | "google"
595            | "meta-llama"
596            | "meta"
597            | "mistralai"
598            | "mistral"
599            | "deepseek"
600            | "cohere"
601    )
602}
603
604/// Normalize models.dev provider keys to canonical names.
605fn normalize_provider(slug: &str) -> String {
606    match slug {
607        "anthropic" => "Anthropic".to_string(),
608        "openai" => "OpenAI".to_string(),
609        "google" => "Google".to_string(),
610        "meta-llama" | "meta" => "Meta".to_string(),
611        "mistralai" | "mistral" => "Mistral".to_string(),
612        "deepseek" => "DeepSeek".to_string(),
613        "cohere" => "Cohere".to_string(),
614        _ => slug.to_string(),
615    }
616}
617
618// ---------------------------------------------------------------------------
619// Auto-resolve algorithm
620// ---------------------------------------------------------------------------
621
622/// Resolve an auto-resolve spec against the models cache.
623///
624/// Algorithm:
625/// 1. Filter by provider (case-insensitive)
626/// 2. All match patterns must hit (AND)
627/// 3. No exclude patterns may hit (OR)
628/// 4. Skip entries ending with `-latest` (synthetic aliases)
629/// 5. Sort by newest release_date, then shortest ID
630/// 6. Pick first
631pub fn auto_resolve(
632    provider: &str,
633    match_patterns: &[String],
634    exclude_patterns: &[String],
635    cache: &ModelsCache,
636) -> Option<String> {
637    let mut candidates: Vec<&CachedModel> = cache
638        .models
639        .iter()
640        .filter(|m| {
641            // Provider match (case-insensitive)
642            m.provider.eq_ignore_ascii_case(provider)
643        })
644        .filter(|m| {
645            // Skip -latest suffix (synthetic aliases)
646            !m.id.ends_with("-latest")
647        })
648        .filter(|m| {
649            // All match patterns must hit (AND)
650            match_patterns.iter().all(|p| glob_match(p, &m.id))
651        })
652        .filter(|m| {
653            // No exclude patterns may hit (OR)
654            !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
655        })
656        .collect();
657
658    // Sort: newest release_date first, then shortest ID (tiebreaker)
659    candidates.sort_by(|a, b| {
660        let date_cmp = b
661            .release_date
662            .as_deref()
663            .unwrap_or("")
664            .cmp(a.release_date.as_deref().unwrap_or(""));
665        date_cmp.then_with(|| a.id.len().cmp(&b.id.len()))
666    });
667
668    candidates.first().map(|m| m.id.clone())
669}
670
671/// Simple glob matching: `*` matches any sequence of characters.
672/// Everything else is literal. Case-sensitive.
673pub fn glob_match(pattern: &str, text: &str) -> bool {
674    // Split pattern on '*' and match segments in order
675    let segments: Vec<&str> = pattern.split('*').collect();
676
677    if segments.len() == 1 {
678        // No wildcards — exact match
679        return pattern == text;
680    }
681
682    let mut pos = 0;
683
684    // First segment must be a prefix
685    if let Some(first) = segments.first()
686        && !first.is_empty()
687    {
688        if !text.starts_with(first) {
689            return false;
690        }
691        pos = first.len();
692    }
693
694    // Last segment must be a suffix
695    if let Some(last) = segments.last()
696        && !last.is_empty()
697        && !text[pos..].ends_with(last)
698    {
699        return false;
700    }
701
702    // Middle segments must appear in order
703    let end = if let Some(last) = segments.last() {
704        if !last.is_empty() {
705            text.len() - last.len()
706        } else {
707            text.len()
708        }
709    } else {
710        text.len()
711    };
712
713    for segment in &segments[1..segments.len().saturating_sub(1)] {
714        if segment.is_empty() {
715            continue;
716        }
717        if let Some(idx) = text[pos..end].find(segment) {
718            pos += idx + segment.len();
719        } else {
720            return false;
721        }
722    }
723
724    pos <= end
725}
726
727// ---------------------------------------------------------------------------
728// Builtin aliases — bare convenience mappings, no descriptions
729// ---------------------------------------------------------------------------
730
731/// Minimal builtin aliases so common model names work out of the box.
732/// No descriptions — packages layer those on top.
733/// Precedence: consumer > deps > builtins.
734pub fn builtin_aliases() -> IndexMap<String, ModelAlias> {
735    let mut m = IndexMap::new();
736    let add = |m: &mut IndexMap<String, ModelAlias>,
737               name: &str,
738               provider: &str,
739               match_patterns: &[&str],
740               exclude: &[&str]| {
741        m.insert(
742            name.to_string(),
743            ModelAlias {
744                harness: None,
745                description: None,
746                spec: ModelSpec::AutoResolve {
747                    provider: provider.to_string(),
748                    match_patterns: match_patterns.iter().map(|s| s.to_string()).collect(),
749                    exclude_patterns: exclude.iter().map(|s| s.to_string()).collect(),
750                },
751            },
752        );
753    };
754    add(&mut m, "opus", "anthropic", &["*opus*"], &[]);
755    add(&mut m, "sonnet", "anthropic", &["*sonnet*"], &[]);
756    add(&mut m, "haiku", "anthropic", &["*haiku*"], &[]);
757    add(
758        &mut m,
759        "codex",
760        "openai",
761        &["*codex*"],
762        &["*-mini", "*-spark", "*-max"],
763    );
764    add(
765        &mut m,
766        "gpt",
767        "openai",
768        &["gpt-5*"],
769        &["*codex*", "*-mini", "*-nano", "*-chat", "*-turbo"],
770    );
771    add(
772        &mut m,
773        "gemini",
774        "google",
775        &["gemini*", "*pro*"],
776        &["*-customtools"],
777    );
778    m
779}
780
781// ---------------------------------------------------------------------------
782// Dependency-tree merge
783// ---------------------------------------------------------------------------
784
785/// Info about a resolved dependency's model config.
786pub struct ResolvedDepModels {
787    pub source_name: String,
788    pub models: IndexMap<String, ModelAlias>,
789}
790
791/// Merge model aliases from dependency tree.
792///
793/// Precedence: consumer > deps (declaration order) > builtins.
794/// When two deps define the same alias, first in declaration order wins
795/// with a diagnostic warning.
796pub fn merge_model_config(
797    consumer: &IndexMap<String, ModelAlias>,
798    deps: &[ResolvedDepModels],
799    diag: &mut DiagnosticCollector,
800) -> IndexMap<String, ModelAlias> {
801    let mut merged = IndexMap::new();
802    let builtins = builtin_aliases();
803
804    // Layer 0 (lowest): builtins
805    for (name, alias) in &builtins {
806        merged.insert(name.clone(), alias.clone());
807    }
808
809    // Track which aliases were set by a dep (vs builtin)
810    let mut dep_provided: std::collections::HashSet<String> = std::collections::HashSet::new();
811
812    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
813    for dep in deps {
814        for (name, alias) in &dep.models {
815            if consumer.contains_key(name) {
816                // Consumer will override — skip dep's version silently
817                continue;
818            }
819            if dep_provided.contains(name) {
820                // Two deps define same alias — first dep wins, warn
821                diag.warn_with_context(
822                    "model-alias-conflict",
823                    format!(
824                        "model alias `{name}` defined by both `{}` and earlier dependency — using earlier definition",
825                        dep.source_name
826                    ),
827                    dep.source_name.clone(),
828                );
829            } else {
830                // Override builtin or insert new
831                merged.insert(name.clone(), alias.clone());
832                dep_provided.insert(name.clone());
833            }
834        }
835    }
836
837    // Layer 2 (highest): consumer config
838    for (name, alias) in consumer {
839        merged.insert(name.clone(), alias.clone());
840    }
841
842    merged
843}
844
845/// Resolve all aliases to concrete model IDs + harnesses.
846///
847/// Harness detection is encapsulated — callers don't pass installed harnesses.
848pub fn resolve_all(
849    aliases: &IndexMap<String, ModelAlias>,
850    cache: &ModelsCache,
851) -> IndexMap<String, ResolvedAlias> {
852    let installed = harness::detect_installed_harnesses();
853    let mut resolved = IndexMap::new();
854
855    for (name, alias) in aliases {
856        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
857            continue; // unresolvable — omit
858        };
859
860        let candidates = harness::harness_candidates_for_provider(&provider);
861        let (h, source) = resolve_harness(alias, &provider, &installed);
862
863        resolved.insert(
864            name.clone(),
865            ResolvedAlias {
866                name: name.clone(),
867                model_id,
868                provider,
869                harness: h,
870                harness_source: source,
871                harness_candidates: candidates,
872                description: alias.description.clone(),
873            },
874        );
875    }
876
877    resolved
878}
879
880/// Filter resolved aliases by visibility config.
881/// - `include` patterns: keep only aliases where at least one pattern matches
882/// - `exclude` patterns: remove aliases where any pattern matches
883/// - No config (both None): return all aliases unchanged
884pub fn filter_by_visibility(
885    mut aliases: IndexMap<String, ResolvedAlias>,
886    visibility: &crate::config::ModelVisibility,
887) -> IndexMap<String, ResolvedAlias> {
888    if let Some(includes) = &visibility.include {
889        aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
890    } else if let Some(excludes) = &visibility.exclude {
891        aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
892    }
893    aliases
894}
895
896fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
897    match &alias.spec {
898        ModelSpec::Pinned { model, provider } => {
899            let p = provider
900                .clone()
901                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
902                .unwrap_or_else(|| "unknown".to_string());
903            Some((model.clone(), p))
904        }
905        ModelSpec::AutoResolve {
906            provider,
907            match_patterns,
908            exclude_patterns,
909        } => {
910            let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
911            Some((id, provider.clone()))
912        }
913    }
914}
915
916fn resolve_harness(
917    alias: &ModelAlias,
918    provider: &str,
919    installed: &HashSet<String>,
920) -> (Option<String>, HarnessSource) {
921    if let Some(h) = &alias.harness {
922        if installed.contains(h) {
923            (Some(h.clone()), HarnessSource::Explicit)
924        } else {
925            (Some(h.clone()), HarnessSource::Unavailable)
926        }
927    } else {
928        match harness::resolve_harness_for_provider(provider, installed) {
929            Some(h) => (Some(h), HarnessSource::AutoDetected),
930            None => (None, HarnessSource::Unavailable),
931        }
932    }
933}
934
935/// Best-effort provider inference from model ID prefixes.
936/// Returns None for unrecognized patterns.
937#[allow(dead_code)]
938fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
939    let id = model_id.to_lowercase();
940    if id.starts_with("claude-") {
941        return Some("anthropic");
942    }
943    if id.starts_with("gpt-")
944        || id.starts_with("o1")
945        || id.starts_with("o3")
946        || id.starts_with("o4")
947        || id.starts_with("codex-")
948    {
949        return Some("openai");
950    }
951    if id.starts_with("gemini") {
952        return Some("google");
953    }
954    if id.starts_with("llama") {
955        return Some("meta");
956    }
957    if id.starts_with("mistral") || id.starts_with("codestral") {
958        return Some("mistral");
959    }
960    if id.starts_with("deepseek") {
961        return Some("deepseek");
962    }
963    if id.starts_with("command") {
964        return Some("cohere");
965    }
966    None
967}
968
969// ---------------------------------------------------------------------------
970// Tests
971// ---------------------------------------------------------------------------
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use httpmock::prelude::*;
977    use std::collections::HashSet;
978    use std::sync::atomic::{AtomicUsize, Ordering};
979    use std::sync::{Arc, mpsc};
980    use std::thread;
981    use tempfile::tempdir;
982
983    use serial_test::serial;
984
985    #[test]
986    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
987        let raw = serde_json::json!({
988            "anthropic": {
989                "models": {
990                    "claude-opus-4-6": {
991                        "id": "claude-opus-4-6",
992                        "name": "Claude Opus 4.6",
993                        "release_date": "2026-02-05",
994                        "limit": {
995                            "context": 1000000,
996                            "output": 128000
997                        }
998                    }
999                }
1000            },
1001            "openai": {
1002                "models": {
1003                    "gpt-5": {
1004                        "id": "gpt-5",
1005                        "name": "GPT-5"
1006                    }
1007                }
1008            },
1009            "random-host": {
1010                "models": {
1011                    "foo": {
1012                        "id": "foo"
1013                    }
1014                }
1015            }
1016        });
1017
1018        let models = parse_models_dev_catalog(&raw).unwrap();
1019        assert_eq!(models.len(), 2);
1020
1021        let opus = models
1022            .iter()
1023            .find(|m| m.id == "claude-opus-4-6")
1024            .expect("missing claude-opus-4-6");
1025        assert_eq!(opus.provider, "Anthropic");
1026        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1027        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1028        assert_eq!(opus.context_window, Some(1_000_000));
1029        assert_eq!(opus.max_output, Some(128_000));
1030
1031        let gpt = models
1032            .iter()
1033            .find(|m| m.id == "gpt-5")
1034            .expect("missing gpt-5");
1035        assert_eq!(gpt.provider, "OpenAI");
1036        assert_eq!(gpt.release_date, None);
1037        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1038        assert_eq!(gpt.context_window, None);
1039        assert_eq!(gpt.max_output, None);
1040    }
1041
1042    #[test]
1043    fn parse_models_dev_catalog_requires_object_root() {
1044        let raw = serde_json::json!(["not", "an", "object"]);
1045        let err = parse_models_dev_catalog(&raw).unwrap_err();
1046        assert!(err.to_string().contains("keyed by provider"));
1047    }
1048
1049    // -- glob_match tests --
1050
1051    #[test]
1052    fn glob_exact_match() {
1053        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1054        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1055    }
1056
1057    #[test]
1058    fn glob_star_suffix() {
1059        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1060        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1061        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1062    }
1063
1064    #[test]
1065    fn glob_star_prefix() {
1066        assert!(glob_match("*-opus-4", "claude-opus-4"));
1067        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1068    }
1069
1070    #[test]
1071    fn glob_star_middle() {
1072        assert!(glob_match("claude-*-4", "claude-opus-4"));
1073        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1074        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1075    }
1076
1077    #[test]
1078    fn glob_multiple_stars() {
1079        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1080        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1081        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1082    }
1083
1084    #[test]
1085    fn glob_star_only() {
1086        assert!(glob_match("*", "anything"));
1087        assert!(glob_match("*", ""));
1088    }
1089
1090    #[test]
1091    fn glob_empty_pattern() {
1092        assert!(glob_match("", ""));
1093        assert!(!glob_match("", "something"));
1094    }
1095
1096    // -- auto_resolve tests --
1097
1098    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1099        ModelsCache {
1100            models: models
1101                .into_iter()
1102                .map(|(id, provider, date)| CachedModel {
1103                    id: id.to_string(),
1104                    provider: provider.to_string(),
1105                    release_date: date.map(String::from),
1106                    description: None,
1107                    context_window: None,
1108                    max_output: None,
1109                })
1110                .collect(),
1111            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1112        }
1113    }
1114
1115    #[test]
1116    fn auto_resolve_basic() {
1117        let cache = make_cache(vec![
1118            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1119            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1120            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1121        ]);
1122
1123        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1124        // Newest date wins
1125        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1126    }
1127
1128    #[test]
1129    fn auto_resolve_exclude() {
1130        let cache = make_cache(vec![
1131            ("gpt-5", "OpenAI", Some("2025-06-01")),
1132            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1133            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1134        ]);
1135
1136        let result = auto_resolve(
1137            "OpenAI",
1138            &["gpt-*".to_string()],
1139            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1140            &cache,
1141        );
1142        assert_eq!(result, Some("gpt-5".to_string()));
1143    }
1144
1145    #[test]
1146    fn auto_resolve_skip_latest() {
1147        let cache = make_cache(vec![
1148            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1149            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1150        ]);
1151
1152        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1153        // Should skip -latest even though it has a newer date
1154        assert_eq!(result, Some("claude-opus-4".to_string()));
1155    }
1156
1157    #[test]
1158    fn auto_resolve_empty_cache() {
1159        let cache = ModelsCache {
1160            models: Vec::new(),
1161            fetched_at: None,
1162        };
1163
1164        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1165        assert_eq!(result, None);
1166    }
1167
1168    #[test]
1169    fn auto_resolve_no_match() {
1170        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1171
1172        let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1173        assert_eq!(result, None);
1174    }
1175
1176    #[test]
1177    fn auto_resolve_provider_case_insensitive() {
1178        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1179
1180        let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1181        assert_eq!(result, Some("claude-opus-4".to_string()));
1182    }
1183
1184    #[test]
1185    fn auto_resolve_shortest_id_tiebreaker() {
1186        let cache = make_cache(vec![
1187            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1188            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1189        ]);
1190
1191        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1192        // Same date — shorter ID wins
1193        assert_eq!(result, Some("claude-opus-4".to_string()));
1194    }
1195
1196    // -- merge_model_config tests --
1197
1198    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1199        ModelAlias {
1200            harness: harness.map(|h| h.to_string()),
1201            description: None,
1202            spec: ModelSpec::Pinned {
1203                model: model.to_string(),
1204                provider: None,
1205            },
1206        }
1207    }
1208
1209    #[test]
1210    fn merge_empty_returns_builtins() {
1211        let mut diag = DiagnosticCollector::new();
1212        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
1213        // Empty consumer + no deps = builtins only
1214        assert!(merged.contains_key("opus"));
1215        assert!(merged.contains_key("sonnet"));
1216        assert!(merged.contains_key("codex"));
1217    }
1218
1219    #[test]
1220    fn merge_consumer_overrides_dependency_alias() {
1221        let mut consumer = IndexMap::new();
1222        consumer.insert(
1223            "opus".to_string(),
1224            pinned_alias(Some("custom"), "my-opus-model"),
1225        );
1226
1227        let mut diag = DiagnosticCollector::new();
1228        let merged = merge_model_config(&consumer, &[], &mut diag);
1229        assert_eq!(
1230            merged.get("opus").unwrap().spec,
1231            ModelSpec::Pinned {
1232                model: "my-opus-model".to_string(),
1233                provider: None
1234            }
1235        );
1236    }
1237
1238    #[test]
1239    fn merge_dep_overrides_builtin() {
1240        let dep = ResolvedDepModels {
1241            source_name: "my-pkg".to_string(),
1242            models: {
1243                let mut m = IndexMap::new();
1244                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
1245                m
1246            },
1247        };
1248
1249        let mut diag = DiagnosticCollector::new();
1250        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
1251        // Dep overrides builtin
1252        assert_eq!(
1253            merged.get("opus").unwrap().spec,
1254            ModelSpec::Pinned {
1255                model: "pkg-opus".to_string(),
1256                provider: None
1257            }
1258        );
1259    }
1260
1261    #[test]
1262    fn merge_consumer_beats_dep() {
1263        let mut consumer = IndexMap::new();
1264        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
1265
1266        let dep = ResolvedDepModels {
1267            source_name: "pkg".to_string(),
1268            models: {
1269                let mut m = IndexMap::new();
1270                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
1271                m
1272            },
1273        };
1274
1275        let mut diag = DiagnosticCollector::new();
1276        let merged = merge_model_config(&consumer, &[dep], &mut diag);
1277        assert_eq!(
1278            merged.get("opus").unwrap().spec,
1279            ModelSpec::Pinned {
1280                model: "consumer-opus".to_string(),
1281                provider: None
1282            }
1283        );
1284    }
1285
1286    #[test]
1287    fn merge_dep_conflict_warns() {
1288        let dep1 = ResolvedDepModels {
1289            source_name: "pkg-a".to_string(),
1290            models: {
1291                let mut m = IndexMap::new();
1292                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1293                m
1294            },
1295        };
1296        let dep2 = ResolvedDepModels {
1297            source_name: "pkg-b".to_string(),
1298            models: {
1299                let mut m = IndexMap::new();
1300                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1301                m
1302            },
1303        };
1304
1305        let mut diag = DiagnosticCollector::new();
1306        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1307        // First dep wins
1308        assert_eq!(
1309            merged.get("custom").unwrap().spec,
1310            ModelSpec::Pinned {
1311                model: "model-a".to_string(),
1312                provider: None
1313            }
1314        );
1315        // Should have warned
1316        let warnings = diag.drain();
1317        assert_eq!(warnings.len(), 1);
1318        assert_eq!(warnings[0].code, "model-alias-conflict");
1319    }
1320
1321    // -- resolve_all tests --
1322
1323    #[test]
1324    fn resolve_all_pinned() {
1325        let mut aliases = IndexMap::new();
1326        aliases.insert(
1327            "fast".to_string(),
1328            pinned_alias(Some("claude"), "claude-haiku-4-5"),
1329        );
1330
1331        let cache = ModelsCache {
1332            models: Vec::new(),
1333            fetched_at: None,
1334        };
1335
1336        let resolved = resolve_all(&aliases, &cache);
1337        let entry = resolved.get("fast").unwrap();
1338        assert_eq!(entry.model_id, "claude-haiku-4-5");
1339        assert_eq!(entry.provider, "anthropic");
1340    }
1341
1342    #[test]
1343    fn resolve_all_pinned_with_provider() {
1344        let mut aliases = IndexMap::new();
1345        aliases.insert(
1346            "fast".to_string(),
1347            ModelAlias {
1348                harness: None,
1349                description: None,
1350                spec: ModelSpec::Pinned {
1351                    model: "gpt-5.3-codex".to_string(),
1352                    provider: Some("openai".to_string()),
1353                },
1354            },
1355        );
1356
1357        let cache = ModelsCache {
1358            models: Vec::new(),
1359            fetched_at: None,
1360        };
1361
1362        let resolved = resolve_all(&aliases, &cache);
1363        let entry = resolved.get("fast").unwrap();
1364        assert_eq!(entry.model_id, "gpt-5.3-codex");
1365        assert_eq!(entry.provider, "openai");
1366        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1367    }
1368
1369    #[test]
1370    fn resolve_all_pinned_auto_detect_harness() {
1371        let mut aliases = IndexMap::new();
1372        aliases.insert(
1373            "opus".to_string(),
1374            ModelAlias {
1375                harness: None,
1376                description: None,
1377                spec: ModelSpec::Pinned {
1378                    model: "claude-opus-4-6".to_string(),
1379                    provider: Some("anthropic".to_string()),
1380                },
1381            },
1382        );
1383
1384        let cache = ModelsCache {
1385            models: Vec::new(),
1386            fetched_at: None,
1387        };
1388
1389        let resolved = resolve_all(&aliases, &cache);
1390        let entry = resolved.get("opus").unwrap();
1391        assert_eq!(entry.model_id, "claude-opus-4-6");
1392        assert_eq!(entry.provider, "anthropic");
1393
1394        let installed = harness::detect_installed_harnesses();
1395        let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1396        let expected_source = if expected_harness.is_some() {
1397            HarnessSource::AutoDetected
1398        } else {
1399            HarnessSource::Unavailable
1400        };
1401
1402        assert_eq!(entry.harness, expected_harness);
1403        assert_eq!(entry.harness_source, expected_source);
1404    }
1405
1406    #[test]
1407    fn resolve_all_auto_detect_harness() {
1408        let mut aliases = IndexMap::new();
1409        aliases.insert(
1410            "gpt".to_string(),
1411            ModelAlias {
1412                harness: None,
1413                description: None,
1414                spec: ModelSpec::AutoResolve {
1415                    provider: "openai".to_string(),
1416                    match_patterns: vec!["gpt-5*".to_string()],
1417                    exclude_patterns: vec![],
1418                },
1419            },
1420        );
1421        let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1422
1423        let resolved = resolve_all(&aliases, &cache);
1424        let entry = resolved.get("gpt").unwrap();
1425        assert_eq!(entry.model_id, "gpt-5");
1426        assert_eq!(entry.provider, "openai");
1427        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1428        match entry.harness_source {
1429            HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1430            HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1431            HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1432        }
1433    }
1434
1435    #[test]
1436    fn resolve_all_unavailable_harness_still_included() {
1437        let mut aliases = IndexMap::new();
1438        aliases.insert(
1439            "opus".to_string(),
1440            ModelAlias {
1441                harness: Some("missing-harness-xyz".to_string()),
1442                description: None,
1443                spec: ModelSpec::Pinned {
1444                    model: "claude-opus-4-6".to_string(),
1445                    provider: None,
1446                },
1447            },
1448        );
1449
1450        let cache = ModelsCache {
1451            models: Vec::new(),
1452            fetched_at: None,
1453        };
1454
1455        let resolved = resolve_all(&aliases, &cache);
1456        let entry = resolved.get("opus").unwrap();
1457        assert_eq!(entry.model_id, "claude-opus-4-6");
1458        assert_eq!(entry.provider, "anthropic");
1459        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1460        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1461    }
1462
1463    #[test]
1464    fn resolve_all_empty_cache_omits_unresolvable() {
1465        let mut aliases = IndexMap::new();
1466        aliases.insert(
1467            "opus".to_string(),
1468            ModelAlias {
1469                harness: Some("claude".to_string()),
1470                description: None,
1471                spec: ModelSpec::AutoResolve {
1472                    provider: "Anthropic".to_string(),
1473                    match_patterns: vec!["claude-opus-*".to_string()],
1474                    exclude_patterns: vec![],
1475                },
1476            },
1477        );
1478        let cache = ModelsCache {
1479            models: Vec::new(),
1480            fetched_at: None,
1481        };
1482
1483        let resolved = resolve_all(&aliases, &cache);
1484        // No cache → auto-resolve can't match → alias omitted from results
1485        assert!(!resolved.contains_key("opus"));
1486    }
1487
1488    fn make_resolved_alias(name: &str) -> ResolvedAlias {
1489        ResolvedAlias {
1490            name: name.to_string(),
1491            model_id: format!("model-{name}"),
1492            provider: "openai".to_string(),
1493            harness: Some("codex".to_string()),
1494            harness_source: HarnessSource::Explicit,
1495            harness_candidates: vec!["codex".to_string()],
1496            description: None,
1497        }
1498    }
1499
1500    #[test]
1501    fn filter_by_visibility_include_mode_keeps_matches_only() {
1502        let mut aliases = IndexMap::new();
1503        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1504        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1505        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
1506
1507        let filtered = filter_by_visibility(
1508            aliases,
1509            &crate::config::ModelVisibility {
1510                include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
1511                exclude: None,
1512            },
1513        );
1514
1515        assert_eq!(filtered.len(), 2);
1516        assert!(filtered.contains_key("opus"));
1517        assert!(filtered.contains_key("gpt-5"));
1518        assert!(!filtered.contains_key("sonnet"));
1519    }
1520
1521    #[test]
1522    fn filter_by_visibility_exclude_mode_removes_matches() {
1523        let mut aliases = IndexMap::new();
1524        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1525        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
1526        aliases.insert(
1527            "deprecated-gpt".to_string(),
1528            make_resolved_alias("deprecated-gpt"),
1529        );
1530
1531        let filtered = filter_by_visibility(
1532            aliases,
1533            &crate::config::ModelVisibility {
1534                include: None,
1535                exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
1536            },
1537        );
1538
1539        assert_eq!(filtered.len(), 1);
1540        assert!(filtered.contains_key("opus"));
1541        assert!(!filtered.contains_key("test-opus"));
1542        assert!(!filtered.contains_key("deprecated-gpt"));
1543    }
1544
1545    #[test]
1546    fn filter_by_visibility_empty_config_returns_all() {
1547        let mut aliases = IndexMap::new();
1548        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1549        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1550        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
1551        assert_eq!(filtered.len(), 2);
1552        assert!(filtered.contains_key("opus"));
1553        assert!(filtered.contains_key("sonnet"));
1554    }
1555
1556    #[test]
1557    fn resolve_model_and_provider_pinned_explicit_provider() {
1558        let alias = ModelAlias {
1559            harness: None,
1560            description: None,
1561            spec: ModelSpec::Pinned {
1562                model: "claude-opus-4-6".to_string(),
1563                provider: Some("anthropic".to_string()),
1564            },
1565        };
1566        let cache = ModelsCache {
1567            models: Vec::new(),
1568            fetched_at: None,
1569        };
1570
1571        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1572        assert_eq!(
1573            resolved,
1574            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1575        );
1576    }
1577
1578    #[test]
1579    fn resolve_model_and_provider_pinned_inferred() {
1580        let alias = ModelAlias {
1581            harness: None,
1582            description: None,
1583            spec: ModelSpec::Pinned {
1584                model: "claude-opus-4-6".to_string(),
1585                provider: None,
1586            },
1587        };
1588        let cache = ModelsCache {
1589            models: Vec::new(),
1590            fetched_at: None,
1591        };
1592
1593        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1594        assert_eq!(
1595            resolved,
1596            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1597        );
1598    }
1599
1600    #[test]
1601    fn resolve_model_and_provider_pinned_unknown() {
1602        let alias = ModelAlias {
1603            harness: None,
1604            description: None,
1605            spec: ModelSpec::Pinned {
1606                model: "my-custom-model".to_string(),
1607                provider: None,
1608            },
1609        };
1610        let cache = ModelsCache {
1611            models: Vec::new(),
1612            fetched_at: None,
1613        };
1614
1615        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1616        assert_eq!(
1617            resolved,
1618            ("my-custom-model".to_string(), "unknown".to_string())
1619        );
1620    }
1621
1622    #[test]
1623    fn resolve_model_and_provider_auto_resolve() {
1624        let alias = ModelAlias {
1625            harness: None,
1626            description: None,
1627            spec: ModelSpec::AutoResolve {
1628                provider: "openai".to_string(),
1629                match_patterns: vec!["gpt-5*".to_string()],
1630                exclude_patterns: vec![],
1631            },
1632        };
1633        let cache = make_cache(vec![
1634            ("gpt-4o", "OpenAI", Some("2024-06-01")),
1635            ("gpt-5", "OpenAI", Some("2025-06-01")),
1636        ]);
1637
1638        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1639        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1640    }
1641
1642    #[test]
1643    fn resolve_harness_explicit_installed() {
1644        let alias = ModelAlias {
1645            harness: Some("claude".to_string()),
1646            description: None,
1647            spec: ModelSpec::Pinned {
1648                model: "claude-opus-4-6".to_string(),
1649                provider: None,
1650            },
1651        };
1652        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1653
1654        let resolved = resolve_harness(&alias, "anthropic", &installed);
1655        assert_eq!(
1656            resolved,
1657            (Some("claude".to_string()), HarnessSource::Explicit)
1658        );
1659    }
1660
1661    #[test]
1662    fn resolve_harness_explicit_not_installed() {
1663        let alias = ModelAlias {
1664            harness: Some("claude".to_string()),
1665            description: None,
1666            spec: ModelSpec::Pinned {
1667                model: "claude-opus-4-6".to_string(),
1668                provider: None,
1669            },
1670        };
1671        let installed = HashSet::new();
1672
1673        let resolved = resolve_harness(&alias, "anthropic", &installed);
1674        assert_eq!(
1675            resolved,
1676            (Some("claude".to_string()), HarnessSource::Unavailable)
1677        );
1678    }
1679
1680    #[test]
1681    fn resolve_harness_auto_detected() {
1682        let alias = ModelAlias {
1683            harness: None,
1684            description: None,
1685            spec: ModelSpec::Pinned {
1686                model: "claude-opus-4-6".to_string(),
1687                provider: Some("anthropic".to_string()),
1688            },
1689        };
1690        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1691
1692        let resolved = resolve_harness(&alias, "anthropic", &installed);
1693        assert_eq!(
1694            resolved,
1695            (Some("claude".to_string()), HarnessSource::AutoDetected)
1696        );
1697    }
1698
1699    #[test]
1700    fn resolve_harness_unavailable() {
1701        let alias = ModelAlias {
1702            harness: None,
1703            description: None,
1704            spec: ModelSpec::Pinned {
1705                model: "claude-opus-4-6".to_string(),
1706                provider: Some("anthropic".to_string()),
1707            },
1708        };
1709        let installed = HashSet::new();
1710
1711        let resolved = resolve_harness(&alias, "anthropic", &installed);
1712        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1713    }
1714
1715    #[test]
1716    fn resolve_harness_unavailable_no_provider_match() {
1717        let alias = ModelAlias {
1718            harness: None,
1719            description: None,
1720            spec: ModelSpec::Pinned {
1721                model: "my-custom-model".to_string(),
1722                provider: Some("unknown".to_string()),
1723            },
1724        };
1725        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1726
1727        let resolved = resolve_harness(&alias, "unknown", &installed);
1728        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1729    }
1730
1731    // -- serde roundtrip tests --
1732
1733    #[test]
1734    fn harness_source_serializes_snake_case() {
1735        assert_eq!(
1736            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1737            "\"explicit\""
1738        );
1739        assert_eq!(
1740            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1741            "\"auto_detected\""
1742        );
1743        assert_eq!(
1744            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1745            "\"unavailable\""
1746        );
1747    }
1748
1749    #[test]
1750    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1751        let toml_str = r#"
1752[models.fast]
1753harness = "claude"
1754model = "claude-haiku-4-5"
1755description = "Fast and cheap"
1756"#;
1757
1758        #[derive(Debug, Deserialize)]
1759        struct Wrapper {
1760            models: IndexMap<String, ModelAlias>,
1761        }
1762
1763        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1764        let alias = parsed.models.get("fast").unwrap();
1765        assert_eq!(
1766            alias.spec,
1767            ModelSpec::Pinned {
1768                model: "claude-haiku-4-5".to_string(),
1769                provider: None
1770            }
1771        );
1772        assert_eq!(alias.harness.as_deref(), Some("claude"));
1773        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1774
1775        let json = serde_json::to_string(alias).unwrap();
1776        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1777        assert_eq!(roundtripped, *alias);
1778    }
1779
1780    #[test]
1781    fn model_alias_pinned_toml_roundtrip_without_harness() {
1782        let toml_str = r#"
1783[models.fast]
1784model = "claude-haiku-4-5"
1785"#;
1786
1787        #[derive(Debug, Deserialize)]
1788        struct Wrapper {
1789            models: IndexMap<String, ModelAlias>,
1790        }
1791
1792        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1793        let alias = parsed.models.get("fast").unwrap();
1794        assert_eq!(alias.harness, None);
1795        assert_eq!(
1796            alias.spec,
1797            ModelSpec::Pinned {
1798                model: "claude-haiku-4-5".to_string(),
1799                provider: None
1800            }
1801        );
1802
1803        let json = serde_json::to_string(alias).unwrap();
1804        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1805        assert!(value.get("harness").is_none());
1806        assert!(value.get("provider").is_none());
1807        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1808        assert_eq!(roundtripped, *alias);
1809    }
1810
1811    #[test]
1812    fn model_alias_pinned_toml_roundtrip_with_provider() {
1813        let toml_str = r#"
1814[models.fast]
1815model = "claude-haiku-4-5"
1816provider = "anthropic"
1817"#;
1818
1819        #[derive(Debug, Deserialize)]
1820        struct Wrapper {
1821            models: IndexMap<String, ModelAlias>,
1822        }
1823
1824        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1825        let alias = parsed.models.get("fast").unwrap();
1826        assert_eq!(alias.harness, None);
1827        assert_eq!(
1828            alias.spec,
1829            ModelSpec::Pinned {
1830                model: "claude-haiku-4-5".to_string(),
1831                provider: Some("anthropic".to_string())
1832            }
1833        );
1834
1835        let json = serde_json::to_string(alias).unwrap();
1836        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1837        assert_eq!(
1838            value.get("provider").and_then(serde_json::Value::as_str),
1839            Some("anthropic")
1840        );
1841        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1842        assert_eq!(roundtripped, *alias);
1843    }
1844
1845    #[test]
1846    fn model_alias_pinned_json_roundtrip_with_provider() {
1847        let json = r#"{
1848            "model": "gpt-5.3-codex",
1849            "provider": "openai"
1850        }"#;
1851
1852        let alias: ModelAlias = serde_json::from_str(json).unwrap();
1853        assert_eq!(alias.harness, None);
1854        assert_eq!(alias.description, None);
1855        assert_eq!(
1856            alias.spec,
1857            ModelSpec::Pinned {
1858                model: "gpt-5.3-codex".to_string(),
1859                provider: Some("openai".to_string())
1860            }
1861        );
1862
1863        let encoded = serde_json::to_string(&alias).unwrap();
1864        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
1865        assert_eq!(roundtripped, alias);
1866    }
1867
1868    #[test]
1869    fn model_alias_auto_resolve_toml_roundtrip() {
1870        let toml_str = r#"
1871[models.opus]
1872harness = "claude"
1873provider = "Anthropic"
1874match = ["claude-opus-*"]
1875exclude = ["claude-opus-3*"]
1876description = "Best reasoning"
1877"#;
1878
1879        #[derive(Debug, Deserialize)]
1880        struct Wrapper {
1881            models: IndexMap<String, ModelAlias>,
1882        }
1883
1884        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1885        let alias = parsed.models.get("opus").unwrap();
1886        assert_eq!(alias.harness.as_deref(), Some("claude"));
1887        match &alias.spec {
1888            ModelSpec::AutoResolve {
1889                provider,
1890                match_patterns,
1891                exclude_patterns,
1892            } => {
1893                assert_eq!(provider, "Anthropic");
1894                assert_eq!(match_patterns, &["claude-opus-*"]);
1895                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
1896            }
1897            _ => panic!("expected AutoResolve"),
1898        }
1899    }
1900
1901    #[test]
1902    fn model_alias_both_model_and_match_errors() {
1903        let toml_str = r#"
1904[models.bad]
1905harness = "claude"
1906model = "some-model"
1907match = ["pattern-*"]
1908"#;
1909
1910        #[derive(Debug, Deserialize)]
1911        struct Wrapper {
1912            #[expect(dead_code)]
1913            models: IndexMap<String, ModelAlias>,
1914        }
1915
1916        let result = toml::from_str::<Wrapper>(toml_str);
1917        assert!(result.is_err());
1918        let err_msg = result.unwrap_err().to_string();
1919        assert!(err_msg.contains("both"));
1920    }
1921
1922    #[test]
1923    fn model_alias_neither_model_nor_match_errors() {
1924        let toml_str = r#"
1925[models.bad]
1926harness = "claude"
1927"#;
1928
1929        #[derive(Debug, Deserialize)]
1930        struct Wrapper {
1931            #[expect(dead_code)]
1932            models: IndexMap<String, ModelAlias>,
1933        }
1934
1935        let result = toml::from_str::<Wrapper>(toml_str);
1936        assert!(result.is_err());
1937    }
1938
1939    #[test]
1940    fn infer_provider_from_model_id_detects_known_prefixes() {
1941        assert_eq!(
1942            infer_provider_from_model_id("claude-opus-4-6"),
1943            Some("anthropic")
1944        );
1945        assert_eq!(
1946            infer_provider_from_model_id("gpt-5.3-codex"),
1947            Some("openai")
1948        );
1949        assert_eq!(
1950            infer_provider_from_model_id("gemini-2.5-pro"),
1951            Some("google")
1952        );
1953        assert_eq!(
1954            infer_provider_from_model_id("llama-4-maverick"),
1955            Some("meta")
1956        );
1957        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
1958        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
1959        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
1960        assert_eq!(
1961            infer_provider_from_model_id("codex-mini-latest"),
1962            Some("openai")
1963        );
1964        assert_eq!(
1965            infer_provider_from_model_id("mistral-large"),
1966            Some("mistral")
1967        );
1968        assert_eq!(
1969            infer_provider_from_model_id("codestral-latest"),
1970            Some("mistral")
1971        );
1972        assert_eq!(
1973            infer_provider_from_model_id("deepseek-chat"),
1974            Some("deepseek")
1975        );
1976        assert_eq!(
1977            infer_provider_from_model_id("command-r-plus"),
1978            Some("cohere")
1979        );
1980    }
1981
1982    #[test]
1983    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
1984        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
1985    }
1986
1987    #[test]
1988    fn infer_provider_from_model_id_returns_none_for_empty_string() {
1989        assert_eq!(infer_provider_from_model_id(""), None);
1990    }
1991
1992    #[test]
1993    fn infer_provider_from_model_id_is_case_insensitive() {
1994        assert_eq!(
1995            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
1996            Some("anthropic")
1997        );
1998        assert_eq!(
1999            infer_provider_from_model_id("GPT-5.3-codex"),
2000            Some("openai")
2001        );
2002        assert_eq!(
2003            infer_provider_from_model_id("CoDeStRaL-latest"),
2004            Some("mistral")
2005        );
2006    }
2007
2008    #[allow(unused_unsafe)]
2009    fn env_set(key: &str, value: &str) {
2010        unsafe {
2011            std::env::set_var(key, value);
2012        }
2013    }
2014
2015    #[allow(unused_unsafe)]
2016    fn env_remove(key: &str) {
2017        unsafe {
2018            std::env::remove_var(key);
2019        }
2020    }
2021
2022    struct EnvVarGuard {
2023        key: String,
2024        prev: Option<String>,
2025    }
2026
2027    impl EnvVarGuard {
2028        fn set(key: &str, value: &str) -> Self {
2029            let prev = std::env::var(key).ok();
2030            env_set(key, value);
2031            Self {
2032                key: key.to_string(),
2033                prev,
2034            }
2035        }
2036    }
2037
2038    impl Drop for EnvVarGuard {
2039        fn drop(&mut self) {
2040            if let Some(prev) = &self.prev {
2041                env_set(&self.key, prev);
2042            } else {
2043                env_remove(&self.key);
2044            }
2045        }
2046    }
2047
2048    fn sample_catalog_json() -> serde_json::Value {
2049        serde_json::json!({
2050            "openai": {
2051                "models": {
2052                    "gpt-5": {
2053                        "id": "gpt-5",
2054                        "name": "GPT-5",
2055                        "release_date": "2025-06-01",
2056                        "limit": {
2057                            "context": 400000,
2058                            "output": 128000
2059                        }
2060                    }
2061                }
2062            },
2063            "anthropic": {
2064                "models": {
2065                    "claude-sonnet-4-5": {
2066                        "id": "claude-sonnet-4-5",
2067                        "name": "Claude Sonnet 4.5",
2068                        "release_date": "2025-03-01"
2069                    }
2070                }
2071            }
2072        })
2073    }
2074
2075    fn sample_cached_model(id: &str) -> CachedModel {
2076        CachedModel {
2077            id: id.to_string(),
2078            provider: "OpenAI".to_string(),
2079            release_date: None,
2080            description: None,
2081            context_window: None,
2082            max_output: None,
2083        }
2084    }
2085
2086    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
2087        write_cache(
2088            mars_dir,
2089            &ModelsCache {
2090                models,
2091                fetched_at: Some(fetched_at.to_string()),
2092            },
2093        )
2094        .expect("failed to write cache fixture");
2095    }
2096
2097    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
2098        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
2099        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
2100    }
2101
2102    fn stale_timestamp() -> String {
2103        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
2104    }
2105
2106    fn fresh_timestamp() -> String {
2107        now_unix_secs_value().saturating_sub(60).to_string()
2108    }
2109
2110    fn assert_model_cache_unavailable(
2111        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
2112        reason_contains: &str,
2113    ) {
2114        match result {
2115            Err(MarsError::ModelCacheUnavailable { reason }) => {
2116                assert!(
2117                    reason.contains(reason_contains),
2118                    "unexpected reason: {reason}"
2119                );
2120            }
2121            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
2122        }
2123    }
2124
2125    #[test]
2126    #[serial]
2127    fn ensure_fresh_1_missing_cache_offline_errors() {
2128        let mars = tempdir().unwrap();
2129        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2130
2131        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2132        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
2133    }
2134
2135    #[test]
2136    #[serial]
2137    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
2138        let mars = tempdir().unwrap();
2139        let server = MockServer::start();
2140        let mock = server.mock(|when, then| {
2141            when.method(GET).path("/api.json");
2142            then.status(500).body("server error");
2143        });
2144        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2145
2146        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2147        assert_model_cache_unavailable(result, "automatic refresh failed");
2148        assert_eq!(mock.hits(), 1);
2149    }
2150
2151    #[test]
2152    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
2153        let mars = tempdir().unwrap();
2154        write_cache_state(
2155            mars.path(),
2156            vec![sample_cached_model("stale-model")],
2157            &stale_timestamp(),
2158        );
2159
2160        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
2161        assert_eq!(cache.models.len(), 1);
2162        assert_eq!(cache.models[0].id, "stale-model");
2163        assert_eq!(outcome, RefreshOutcome::Offline);
2164    }
2165
2166    #[test]
2167    #[serial]
2168    fn ensure_fresh_4_fresh_auto_skips_http() {
2169        let mars = tempdir().unwrap();
2170        write_cache_state(
2171            mars.path(),
2172            vec![sample_cached_model("fresh-model")],
2173            &fresh_timestamp(),
2174        );
2175
2176        let server = MockServer::start();
2177        let mock = server.mock(|when, then| {
2178            when.method(GET).path("/api.json");
2179            then.status(200).json_body(sample_catalog_json());
2180        });
2181        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2182
2183        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2184        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
2185        assert_eq!(mock.hits(), 0);
2186    }
2187
2188    #[test]
2189    #[serial]
2190    fn ensure_fresh_5_stale_auto_success_refreshes() {
2191        let mars = tempdir().unwrap();
2192        write_cache_state(
2193            mars.path(),
2194            vec![sample_cached_model("old-model")],
2195            &stale_timestamp(),
2196        );
2197
2198        let server = MockServer::start();
2199        let mock = server.mock(|when, then| {
2200            when.method(GET).path("/api.json");
2201            then.status(200).json_body(sample_catalog_json());
2202        });
2203        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2204
2205        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2206        assert!(matches!(
2207            outcome,
2208            RefreshOutcome::Refreshed { models_count } if models_count == 2
2209        ));
2210        assert_eq!(cache.models.len(), 2);
2211        assert!(!cache.models.is_empty());
2212        assert!(cache.fetched_at.is_some());
2213        assert_eq!(mock.hits(), 1);
2214    }
2215
2216    #[test]
2217    #[serial]
2218    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
2219        let mars = tempdir().unwrap();
2220        write_cache_state(
2221            mars.path(),
2222            vec![sample_cached_model("stale-model")],
2223            &stale_timestamp(),
2224        );
2225
2226        let server = MockServer::start();
2227        let mock = server.mock(|when, then| {
2228            when.method(GET).path("/api.json");
2229            then.status(500).body("server error");
2230        });
2231        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2232
2233        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2234        assert_eq!(cache.models[0].id, "stale-model");
2235        assert!(matches!(
2236            outcome,
2237            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2238        ));
2239        assert_eq!(mock.hits(), 1);
2240    }
2241
2242    #[test]
2243    #[serial]
2244    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
2245        let mars = tempdir().unwrap();
2246        write_cache_state(
2247            mars.path(),
2248            vec![sample_cached_model("stale-model")],
2249            &stale_timestamp(),
2250        );
2251
2252        let server = MockServer::start();
2253        let mock = server.mock(|when, then| {
2254            when.method(GET).path("/api.json");
2255            then.status(200).json_body(serde_json::json!({}));
2256        });
2257        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2258
2259        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2260        assert_eq!(cache.models[0].id, "stale-model");
2261        assert!(matches!(
2262            outcome,
2263            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
2264        ));
2265        assert_eq!(mock.hits(), 1);
2266    }
2267
2268    #[test]
2269    #[serial]
2270    fn ensure_fresh_8_empty_cache_auto_refetches() {
2271        let mars = tempdir().unwrap();
2272        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2273
2274        let server = MockServer::start();
2275        let mock = server.mock(|when, then| {
2276            when.method(GET).path("/api.json");
2277            then.status(200).json_body(sample_catalog_json());
2278        });
2279        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2280
2281        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2282        assert!(!cache.models.is_empty());
2283        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2284        assert_eq!(mock.hits(), 1);
2285    }
2286
2287    #[test]
2288    fn ensure_fresh_9_empty_cache_offline_errors() {
2289        let mars = tempdir().unwrap();
2290        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2291
2292        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2293        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2294    }
2295
2296    #[test]
2297    #[serial]
2298    fn ensure_fresh_10_corrupt_json_auto_refetches() {
2299        let mars = tempdir().unwrap();
2300        write_raw_cache_file(mars.path(), "{ not-json ");
2301
2302        let server = MockServer::start();
2303        let mock = server.mock(|when, then| {
2304            when.method(GET).path("/api.json");
2305            then.status(200).json_body(sample_catalog_json());
2306        });
2307        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2308
2309        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2310        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2311        assert!(!cache.models.is_empty());
2312        assert_eq!(mock.hits(), 1);
2313    }
2314
2315    #[test]
2316    fn ensure_fresh_11_corrupt_json_offline_errors() {
2317        let mars = tempdir().unwrap();
2318        write_raw_cache_file(mars.path(), "{ not-json ");
2319
2320        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2321        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2322    }
2323
2324    #[test]
2325    #[serial]
2326    fn ensure_fresh_12_ttl_zero_always_refetches() {
2327        let mars = tempdir().unwrap();
2328        write_cache_state(
2329            mars.path(),
2330            vec![sample_cached_model("fresh-model")],
2331            &fresh_timestamp(),
2332        );
2333
2334        let server = MockServer::start();
2335        let mock = server.mock(|when, then| {
2336            when.method(GET).path("/api.json");
2337            then.status(200).json_body(sample_catalog_json());
2338        });
2339        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2340
2341        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
2342        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2343        assert_eq!(mock.hits(), 1);
2344    }
2345
2346    #[test]
2347    #[serial]
2348    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
2349        let mars = tempdir().unwrap();
2350        write_cache_state(
2351            mars.path(),
2352            vec![sample_cached_model("stale-model")],
2353            "not-a-timestamp",
2354        );
2355
2356        let server = MockServer::start();
2357        let mock = server.mock(|when, then| {
2358            when.method(GET).path("/api.json");
2359            then.status(200).json_body(sample_catalog_json());
2360        });
2361        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2362
2363        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2364        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2365        assert_eq!(mock.hits(), 1);
2366    }
2367
2368    #[test]
2369    #[serial]
2370    fn ensure_fresh_14_future_fetched_at_is_stale() {
2371        let mars = tempdir().unwrap();
2372        let future = now_unix_secs_value() + 3600;
2373        write_cache_state(
2374            mars.path(),
2375            vec![sample_cached_model("future-model")],
2376            &future.to_string(),
2377        );
2378
2379        let server = MockServer::start();
2380        let mock = server.mock(|when, then| {
2381            when.method(GET).path("/api.json");
2382            then.status(200).json_body(sample_catalog_json());
2383        });
2384        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2385
2386        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2387        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2388        assert_eq!(mock.hits(), 1);
2389    }
2390
2391    #[test]
2392    #[serial]
2393    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
2394        let mars = tempdir().unwrap();
2395        write_cache_state(
2396            mars.path(),
2397            vec![sample_cached_model("fresh-model")],
2398            &fresh_timestamp(),
2399        );
2400
2401        let server = MockServer::start();
2402        let mock = server.mock(|when, then| {
2403            when.method(GET).path("/api.json");
2404            then.status(200).json_body(sample_catalog_json());
2405        });
2406        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2407        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2408
2409        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2410        assert_eq!(outcome, RefreshOutcome::Offline);
2411        assert_eq!(mock.hits(), 0);
2412    }
2413
2414    #[test]
2415    #[serial]
2416    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
2417        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
2418        assert!(!is_mars_offline());
2419        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2420    }
2421
2422    #[test]
2423    #[serial]
2424    fn ensure_fresh_17_offline_env_truthy_is_offline() {
2425        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
2426        assert!(is_mars_offline());
2427        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2428    }
2429
2430    #[test]
2431    #[serial]
2432    fn ensure_fresh_18_force_ignores_offline_env() {
2433        let mars = tempdir().unwrap();
2434        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2435
2436        let server = MockServer::start();
2437        let mock = server.mock(|when, then| {
2438            when.method(GET).path("/api.json");
2439            then.status(200).json_body(sample_catalog_json());
2440        });
2441        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2442
2443        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
2444        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2445        assert_eq!(mock.hits(), 1);
2446    }
2447
2448    #[test]
2449    #[serial]
2450    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
2451        let mars = tempdir().unwrap();
2452        write_cache_state(
2453            mars.path(),
2454            vec![sample_cached_model("stale-model")],
2455            &stale_timestamp(),
2456        );
2457
2458        let path = Arc::new(mars.path().to_path_buf());
2459        let path_a = Arc::clone(&path);
2460        let path_b = Arc::clone(&path);
2461        let fetch_hits = Arc::new(AtomicUsize::new(0));
2462        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
2463        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
2464
2465        let fetch_hits_a = Arc::clone(&fetch_hits);
2466        let t1 = thread::spawn(move || {
2467            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
2468                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
2469                fetch_started_tx.send(()).unwrap();
2470                release_fetch_rx.recv().unwrap();
2471                Ok(vec![sample_cached_model("fresh-model")])
2472            })
2473            .unwrap()
2474            .1
2475        });
2476
2477        fetch_started_rx.recv().unwrap();
2478
2479        let fetch_hits_b = Arc::clone(&fetch_hits);
2480        let t2 = thread::spawn(move || {
2481            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
2482                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
2483                Ok(vec![sample_cached_model("unexpected-second-refresh")])
2484            })
2485            .unwrap()
2486            .1
2487        });
2488
2489        release_fetch_tx.send(()).unwrap();
2490
2491        let outcome_a = t1.join().unwrap();
2492        let outcome_b = t2.join().unwrap();
2493
2494        let outcomes = [outcome_a, outcome_b];
2495        let refreshed = outcomes
2496            .iter()
2497            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
2498            .count();
2499        let already_fresh = outcomes
2500            .iter()
2501            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
2502            .count();
2503
2504        assert_eq!(refreshed, 1);
2505        assert_eq!(already_fresh, 1);
2506        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
2507    }
2508
2509    #[test]
2510    #[serial]
2511    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
2512        let mars = tempdir().unwrap();
2513        write_cache_state(
2514            mars.path(),
2515            vec![sample_cached_model("stale-model")],
2516            &stale_timestamp(),
2517        );
2518
2519        let server = MockServer::start();
2520        let mock = server.mock(|when, then| {
2521            when.method(GET).path("/api.json");
2522            then.status(500).body("server error");
2523        });
2524        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2525
2526        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2527        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2528
2529        assert!(matches!(
2530            outcome_a,
2531            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2532        ));
2533        assert_eq!(
2534            outcome_b,
2535            RefreshOutcome::StaleFallback {
2536                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2537            }
2538        );
2539        assert_eq!(mock.hits(), 1);
2540    }
2541
2542    #[test]
2543    #[serial]
2544    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
2545        let mars = tempdir().unwrap();
2546        write_cache_state(
2547            mars.path(),
2548            vec![sample_cached_model("stale-model")],
2549            &stale_timestamp(),
2550        );
2551
2552        let server = MockServer::start();
2553        let mock = server.mock(|when, then| {
2554            when.method(GET).path("/api.json");
2555            then.status(200).json_body(serde_json::json!({
2556                "openai": {
2557                    "models": {}
2558                }
2559            }));
2560        });
2561        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2562
2563        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2564        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2565
2566        assert!(matches!(
2567            outcome_a,
2568            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
2569        ));
2570        assert_eq!(
2571            outcome_b,
2572            RefreshOutcome::StaleFallback {
2573                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2574            }
2575        );
2576        assert_eq!(mock.hits(), 1);
2577    }
2578
2579    #[test]
2580    fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
2581        let project = tempdir().unwrap();
2582        let ctx = crate::types::MarsContext::for_test(
2583            project.path().to_path_buf(),
2584            project.path().join(".agents"),
2585        );
2586        assert_eq!(load_models_cache_ttl(&ctx), 24);
2587    }
2588
2589    #[test]
2590    fn load_models_cache_ttl_reads_config_value() {
2591        let project = tempdir().unwrap();
2592        std::fs::write(
2593            project.path().join("mars.toml"),
2594            "[settings]\nmodels_cache_ttl_hours = 48\n",
2595        )
2596        .unwrap();
2597        let ctx = crate::types::MarsContext::for_test(
2598            project.path().to_path_buf(),
2599            project.path().join(".agents"),
2600        );
2601        assert_eq!(load_models_cache_ttl(&ctx), 48);
2602    }
2603}