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 dep won each alias (vs builtin)
810    let mut dep_provided: std::collections::HashMap<String, String> =
811        std::collections::HashMap::new();
812
813    // Layer 1: dependencies (override builtins silently, first dep wins on conflicts)
814    for dep in deps {
815        for (name, alias) in &dep.models {
816            if consumer.contains_key(name) {
817                // Consumer will override — skip dep's version silently
818                continue;
819            }
820            if let Some(winner) = dep_provided.get(name) {
821                // Two deps define same alias — first dep wins, warn
822                diag.warn_with_context(
823                    "model-alias-conflict",
824                    format!(
825                        "model alias `{name}` defined by both `{winner}` and `{}` — using {winner} (declared first)\n  → add [models.{name}] to your mars.toml to resolve explicitly",
826                        dep.source_name
827                    ),
828                    dep.source_name.clone(),
829                );
830            } else {
831                // Override builtin or insert new
832                merged.insert(name.clone(), alias.clone());
833                dep_provided.insert(name.clone(), dep.source_name.clone());
834            }
835        }
836    }
837
838    // Layer 2 (highest): consumer config
839    for (name, alias) in consumer {
840        merged.insert(name.clone(), alias.clone());
841    }
842
843    merged
844}
845
846/// Resolve all aliases to concrete model IDs + harnesses.
847///
848/// Harness detection is encapsulated — callers don't pass installed harnesses.
849pub fn resolve_all(
850    aliases: &IndexMap<String, ModelAlias>,
851    cache: &ModelsCache,
852) -> IndexMap<String, ResolvedAlias> {
853    let installed = harness::detect_installed_harnesses();
854    let mut resolved = IndexMap::new();
855
856    for (name, alias) in aliases {
857        let Some((model_id, provider)) = resolve_model_and_provider(alias, cache) else {
858            continue; // unresolvable — omit
859        };
860
861        let candidates = harness::harness_candidates_for_provider(&provider);
862        let (h, source) = resolve_harness(alias, &provider, &installed);
863
864        resolved.insert(
865            name.clone(),
866            ResolvedAlias {
867                name: name.clone(),
868                model_id,
869                provider,
870                harness: h,
871                harness_source: source,
872                harness_candidates: candidates,
873                description: alias.description.clone(),
874            },
875        );
876    }
877
878    resolved
879}
880
881/// Filter resolved aliases by visibility config.
882/// - `include` patterns: keep only aliases where at least one pattern matches
883/// - `exclude` patterns: remove aliases where any pattern matches
884/// - No config (both None): return all aliases unchanged
885pub fn filter_by_visibility(
886    mut aliases: IndexMap<String, ResolvedAlias>,
887    visibility: &crate::config::ModelVisibility,
888) -> IndexMap<String, ResolvedAlias> {
889    if let Some(includes) = &visibility.include {
890        aliases.retain(|name, _| includes.iter().any(|p| glob_match(p, name)));
891    } else if let Some(excludes) = &visibility.exclude {
892        aliases.retain(|name, _| !excludes.iter().any(|p| glob_match(p, name)));
893    }
894    aliases
895}
896
897fn resolve_model_and_provider(alias: &ModelAlias, cache: &ModelsCache) -> Option<(String, String)> {
898    match &alias.spec {
899        ModelSpec::Pinned { model, provider } => {
900            let p = provider
901                .clone()
902                .or_else(|| infer_provider_from_model_id(model).map(str::to_string))
903                .unwrap_or_else(|| "unknown".to_string());
904            Some((model.clone(), p))
905        }
906        ModelSpec::AutoResolve {
907            provider,
908            match_patterns,
909            exclude_patterns,
910        } => {
911            let id = auto_resolve(provider, match_patterns, exclude_patterns, cache)?;
912            Some((id, provider.clone()))
913        }
914    }
915}
916
917fn resolve_harness(
918    alias: &ModelAlias,
919    provider: &str,
920    installed: &HashSet<String>,
921) -> (Option<String>, HarnessSource) {
922    if let Some(h) = &alias.harness {
923        if installed.contains(h) {
924            (Some(h.clone()), HarnessSource::Explicit)
925        } else {
926            (Some(h.clone()), HarnessSource::Unavailable)
927        }
928    } else {
929        match harness::resolve_harness_for_provider(provider, installed) {
930            Some(h) => (Some(h), HarnessSource::AutoDetected),
931            None => (None, HarnessSource::Unavailable),
932        }
933    }
934}
935
936/// Best-effort provider inference from model ID prefixes.
937/// Returns None for unrecognized patterns.
938#[allow(dead_code)]
939fn infer_provider_from_model_id(model_id: &str) -> Option<&'static str> {
940    let id = model_id.to_lowercase();
941    if id.starts_with("claude-") {
942        return Some("anthropic");
943    }
944    if id.starts_with("gpt-")
945        || id.starts_with("o1")
946        || id.starts_with("o3")
947        || id.starts_with("o4")
948        || id.starts_with("codex-")
949    {
950        return Some("openai");
951    }
952    if id.starts_with("gemini") {
953        return Some("google");
954    }
955    if id.starts_with("llama") {
956        return Some("meta");
957    }
958    if id.starts_with("mistral") || id.starts_with("codestral") {
959        return Some("mistral");
960    }
961    if id.starts_with("deepseek") {
962        return Some("deepseek");
963    }
964    if id.starts_with("command") {
965        return Some("cohere");
966    }
967    None
968}
969
970// ---------------------------------------------------------------------------
971// Tests
972// ---------------------------------------------------------------------------
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977    use httpmock::prelude::*;
978    use std::collections::HashSet;
979    use std::sync::atomic::{AtomicUsize, Ordering};
980    use std::sync::{Arc, mpsc};
981    use std::thread;
982    use tempfile::tempdir;
983
984    use serial_test::serial;
985
986    #[test]
987    fn parse_models_dev_catalog_maps_fields_and_filters_providers() {
988        let raw = serde_json::json!({
989            "anthropic": {
990                "models": {
991                    "claude-opus-4-6": {
992                        "id": "claude-opus-4-6",
993                        "name": "Claude Opus 4.6",
994                        "release_date": "2026-02-05",
995                        "limit": {
996                            "context": 1000000,
997                            "output": 128000
998                        }
999                    }
1000                }
1001            },
1002            "openai": {
1003                "models": {
1004                    "gpt-5": {
1005                        "id": "gpt-5",
1006                        "name": "GPT-5"
1007                    }
1008                }
1009            },
1010            "random-host": {
1011                "models": {
1012                    "foo": {
1013                        "id": "foo"
1014                    }
1015                }
1016            }
1017        });
1018
1019        let models = parse_models_dev_catalog(&raw).unwrap();
1020        assert_eq!(models.len(), 2);
1021
1022        let opus = models
1023            .iter()
1024            .find(|m| m.id == "claude-opus-4-6")
1025            .expect("missing claude-opus-4-6");
1026        assert_eq!(opus.provider, "Anthropic");
1027        assert_eq!(opus.release_date.as_deref(), Some("2026-02-05"));
1028        assert_eq!(opus.description.as_deref(), Some("Claude Opus 4.6"));
1029        assert_eq!(opus.context_window, Some(1_000_000));
1030        assert_eq!(opus.max_output, Some(128_000));
1031
1032        let gpt = models
1033            .iter()
1034            .find(|m| m.id == "gpt-5")
1035            .expect("missing gpt-5");
1036        assert_eq!(gpt.provider, "OpenAI");
1037        assert_eq!(gpt.release_date, None);
1038        assert_eq!(gpt.description.as_deref(), Some("GPT-5"));
1039        assert_eq!(gpt.context_window, None);
1040        assert_eq!(gpt.max_output, None);
1041    }
1042
1043    #[test]
1044    fn parse_models_dev_catalog_requires_object_root() {
1045        let raw = serde_json::json!(["not", "an", "object"]);
1046        let err = parse_models_dev_catalog(&raw).unwrap_err();
1047        assert!(err.to_string().contains("keyed by provider"));
1048    }
1049
1050    // -- glob_match tests --
1051
1052    #[test]
1053    fn glob_exact_match() {
1054        assert!(glob_match("claude-opus-4", "claude-opus-4"));
1055        assert!(!glob_match("claude-opus-4", "claude-opus-5"));
1056    }
1057
1058    #[test]
1059    fn glob_star_suffix() {
1060        assert!(glob_match("claude-opus-*", "claude-opus-4"));
1061        assert!(glob_match("claude-opus-*", "claude-opus-4-20250514"));
1062        assert!(!glob_match("claude-opus-*", "claude-sonnet-4"));
1063    }
1064
1065    #[test]
1066    fn glob_star_prefix() {
1067        assert!(glob_match("*-opus-4", "claude-opus-4"));
1068        assert!(!glob_match("*-opus-4", "claude-opus-5"));
1069    }
1070
1071    #[test]
1072    fn glob_star_middle() {
1073        assert!(glob_match("claude-*-4", "claude-opus-4"));
1074        assert!(glob_match("claude-*-4", "claude-sonnet-4"));
1075        assert!(!glob_match("claude-*-4", "claude-opus-5"));
1076    }
1077
1078    #[test]
1079    fn glob_multiple_stars() {
1080        assert!(glob_match("*claude*opus*", "claude-opus-4"));
1081        assert!(glob_match("*claude*opus*", "my-claude-opus-4-special"));
1082        assert!(!glob_match("*claude*opus*", "claude-sonnet-4"));
1083    }
1084
1085    #[test]
1086    fn glob_star_only() {
1087        assert!(glob_match("*", "anything"));
1088        assert!(glob_match("*", ""));
1089    }
1090
1091    #[test]
1092    fn glob_empty_pattern() {
1093        assert!(glob_match("", ""));
1094        assert!(!glob_match("", "something"));
1095    }
1096
1097    // -- auto_resolve tests --
1098
1099    fn make_cache(models: Vec<(&str, &str, Option<&str>)>) -> ModelsCache {
1100        ModelsCache {
1101            models: models
1102                .into_iter()
1103                .map(|(id, provider, date)| CachedModel {
1104                    id: id.to_string(),
1105                    provider: provider.to_string(),
1106                    release_date: date.map(String::from),
1107                    description: None,
1108                    context_window: None,
1109                    max_output: None,
1110                })
1111                .collect(),
1112            fetched_at: Some("2025-01-01T00:00:00Z".to_string()),
1113        }
1114    }
1115
1116    #[test]
1117    fn auto_resolve_basic() {
1118        let cache = make_cache(vec![
1119            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1120            ("claude-opus-4-20250514", "Anthropic", Some("2025-05-14")),
1121            ("claude-sonnet-4", "Anthropic", Some("2025-03-01")),
1122        ]);
1123
1124        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1125        // Newest date wins
1126        assert_eq!(result, Some("claude-opus-4-20250514".to_string()));
1127    }
1128
1129    #[test]
1130    fn auto_resolve_exclude() {
1131        let cache = make_cache(vec![
1132            ("gpt-5", "OpenAI", Some("2025-06-01")),
1133            ("gpt-4o-mini", "OpenAI", Some("2024-07-01")),
1134            ("gpt-3.5-turbo", "OpenAI", Some("2023-03-01")),
1135        ]);
1136
1137        let result = auto_resolve(
1138            "OpenAI",
1139            &["gpt-*".to_string()],
1140            &["gpt-3*".to_string(), "gpt-4o*".to_string()],
1141            &cache,
1142        );
1143        assert_eq!(result, Some("gpt-5".to_string()));
1144    }
1145
1146    #[test]
1147    fn auto_resolve_skip_latest() {
1148        let cache = make_cache(vec![
1149            ("claude-opus-latest", "Anthropic", Some("9999-01-01")),
1150            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1151        ]);
1152
1153        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1154        // Should skip -latest even though it has a newer date
1155        assert_eq!(result, Some("claude-opus-4".to_string()));
1156    }
1157
1158    #[test]
1159    fn auto_resolve_empty_cache() {
1160        let cache = ModelsCache {
1161            models: Vec::new(),
1162            fetched_at: None,
1163        };
1164
1165        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1166        assert_eq!(result, None);
1167    }
1168
1169    #[test]
1170    fn auto_resolve_no_match() {
1171        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1172
1173        let result = auto_resolve("OpenAI", &["gpt-*".to_string()], &[], &cache);
1174        assert_eq!(result, None);
1175    }
1176
1177    #[test]
1178    fn auto_resolve_provider_case_insensitive() {
1179        let cache = make_cache(vec![("claude-opus-4", "Anthropic", Some("2025-03-01"))]);
1180
1181        let result = auto_resolve("anthropic", &["claude-opus-*".to_string()], &[], &cache);
1182        assert_eq!(result, Some("claude-opus-4".to_string()));
1183    }
1184
1185    #[test]
1186    fn auto_resolve_shortest_id_tiebreaker() {
1187        let cache = make_cache(vec![
1188            ("claude-opus-4", "Anthropic", Some("2025-03-01")),
1189            ("claude-opus-4x", "Anthropic", Some("2025-03-01")),
1190        ]);
1191
1192        let result = auto_resolve("Anthropic", &["claude-opus-*".to_string()], &[], &cache);
1193        // Same date — shorter ID wins
1194        assert_eq!(result, Some("claude-opus-4".to_string()));
1195    }
1196
1197    // -- merge_model_config tests --
1198
1199    fn pinned_alias(harness: Option<&str>, model: &str) -> ModelAlias {
1200        ModelAlias {
1201            harness: harness.map(|h| h.to_string()),
1202            description: None,
1203            spec: ModelSpec::Pinned {
1204                model: model.to_string(),
1205                provider: None,
1206            },
1207        }
1208    }
1209
1210    #[test]
1211    fn merge_empty_returns_builtins() {
1212        let mut diag = DiagnosticCollector::new();
1213        let merged = merge_model_config(&IndexMap::new(), &[], &mut diag);
1214        // Empty consumer + no deps = builtins only
1215        assert!(merged.contains_key("opus"));
1216        assert!(merged.contains_key("sonnet"));
1217        assert!(merged.contains_key("codex"));
1218    }
1219
1220    #[test]
1221    fn merge_consumer_overrides_dependency_alias() {
1222        let mut consumer = IndexMap::new();
1223        consumer.insert(
1224            "opus".to_string(),
1225            pinned_alias(Some("custom"), "my-opus-model"),
1226        );
1227
1228        let mut diag = DiagnosticCollector::new();
1229        let merged = merge_model_config(&consumer, &[], &mut diag);
1230        assert_eq!(
1231            merged.get("opus").unwrap().spec,
1232            ModelSpec::Pinned {
1233                model: "my-opus-model".to_string(),
1234                provider: None
1235            }
1236        );
1237    }
1238
1239    #[test]
1240    fn merge_dep_overrides_builtin() {
1241        let dep = ResolvedDepModels {
1242            source_name: "my-pkg".to_string(),
1243            models: {
1244                let mut m = IndexMap::new();
1245                m.insert("opus".to_string(), pinned_alias(Some("custom"), "pkg-opus"));
1246                m
1247            },
1248        };
1249
1250        let mut diag = DiagnosticCollector::new();
1251        let merged = merge_model_config(&IndexMap::new(), &[dep], &mut diag);
1252        // Dep overrides builtin
1253        assert_eq!(
1254            merged.get("opus").unwrap().spec,
1255            ModelSpec::Pinned {
1256                model: "pkg-opus".to_string(),
1257                provider: None
1258            }
1259        );
1260    }
1261
1262    #[test]
1263    fn merge_consumer_beats_dep() {
1264        let mut consumer = IndexMap::new();
1265        consumer.insert("opus".to_string(), pinned_alias(Some("c"), "consumer-opus"));
1266
1267        let dep = ResolvedDepModels {
1268            source_name: "pkg".to_string(),
1269            models: {
1270                let mut m = IndexMap::new();
1271                m.insert("opus".to_string(), pinned_alias(Some("d"), "dep-opus"));
1272                m
1273            },
1274        };
1275
1276        let mut diag = DiagnosticCollector::new();
1277        let merged = merge_model_config(&consumer, &[dep], &mut diag);
1278        assert_eq!(
1279            merged.get("opus").unwrap().spec,
1280            ModelSpec::Pinned {
1281                model: "consumer-opus".to_string(),
1282                provider: None
1283            }
1284        );
1285    }
1286
1287    #[test]
1288    fn merge_dep_conflict_warns_with_winner_and_resolution_hint() {
1289        let dep1 = ResolvedDepModels {
1290            source_name: "pkg-a".to_string(),
1291            models: {
1292                let mut m = IndexMap::new();
1293                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1294                m
1295            },
1296        };
1297        let dep2 = ResolvedDepModels {
1298            source_name: "pkg-b".to_string(),
1299            models: {
1300                let mut m = IndexMap::new();
1301                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1302                m
1303            },
1304        };
1305
1306        let mut diag = DiagnosticCollector::new();
1307        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1308        // First dep wins
1309        assert_eq!(
1310            merged.get("custom").unwrap().spec,
1311            ModelSpec::Pinned {
1312                model: "model-a".to_string(),
1313                provider: None
1314            }
1315        );
1316        // Should have warned
1317        let warnings = diag.drain();
1318        assert_eq!(warnings.len(), 1);
1319        assert_eq!(warnings[0].code, "model-alias-conflict");
1320        assert_eq!(
1321            warnings[0].message,
1322            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
1323        );
1324    }
1325
1326    #[test]
1327    fn merge_dep_three_way_conflict_warns_each_loser_against_first_winner() {
1328        let dep1 = ResolvedDepModels {
1329            source_name: "pkg-a".to_string(),
1330            models: {
1331                let mut m = IndexMap::new();
1332                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1333                m
1334            },
1335        };
1336        let dep2 = ResolvedDepModels {
1337            source_name: "pkg-b".to_string(),
1338            models: {
1339                let mut m = IndexMap::new();
1340                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1341                m
1342            },
1343        };
1344        let dep3 = ResolvedDepModels {
1345            source_name: "pkg-c".to_string(),
1346            models: {
1347                let mut m = IndexMap::new();
1348                m.insert("custom".to_string(), pinned_alias(Some("c"), "model-c"));
1349                m
1350            },
1351        };
1352
1353        let mut diag = DiagnosticCollector::new();
1354        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2, dep3], &mut diag);
1355
1356        assert_eq!(
1357            merged.get("custom").unwrap().spec,
1358            ModelSpec::Pinned {
1359                model: "model-a".to_string(),
1360                provider: None
1361            }
1362        );
1363
1364        let warnings = diag.drain();
1365        assert_eq!(warnings.len(), 2);
1366        assert_eq!(
1367            warnings[0].message,
1368            "model alias `custom` defined by both `pkg-a` and `pkg-b` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
1369        );
1370        assert_eq!(
1371            warnings[1].message,
1372            "model alias `custom` defined by both `pkg-a` and `pkg-c` — using pkg-a (declared first)\n  → add [models.custom] to your mars.toml to resolve explicitly"
1373        );
1374    }
1375
1376    #[test]
1377    fn merge_consumer_override_suppresses_dep_conflict_warning() {
1378        let mut consumer = IndexMap::new();
1379        consumer.insert(
1380            "custom".to_string(),
1381            pinned_alias(Some("consumer"), "consumer-model"),
1382        );
1383
1384        let dep1 = ResolvedDepModels {
1385            source_name: "pkg-a".to_string(),
1386            models: {
1387                let mut m = IndexMap::new();
1388                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1389                m
1390            },
1391        };
1392        let dep2 = ResolvedDepModels {
1393            source_name: "pkg-b".to_string(),
1394            models: {
1395                let mut m = IndexMap::new();
1396                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1397                m
1398            },
1399        };
1400
1401        let mut diag = DiagnosticCollector::new();
1402        let merged = merge_model_config(&consumer, &[dep1, dep2], &mut diag);
1403
1404        assert_eq!(
1405            merged.get("custom").unwrap().spec,
1406            ModelSpec::Pinned {
1407                model: "consumer-model".to_string(),
1408                provider: None
1409            }
1410        );
1411        assert!(diag.drain().is_empty());
1412    }
1413
1414    #[test]
1415    fn merge_dep_conflicts_are_non_blocking() {
1416        let dep1 = ResolvedDepModels {
1417            source_name: "pkg-a".to_string(),
1418            models: {
1419                let mut m = IndexMap::new();
1420                m.insert("custom".to_string(), pinned_alias(Some("a"), "model-a"));
1421                m
1422            },
1423        };
1424        let dep2 = ResolvedDepModels {
1425            source_name: "pkg-b".to_string(),
1426            models: {
1427                let mut m = IndexMap::new();
1428                m.insert("custom".to_string(), pinned_alias(Some("b"), "model-b"));
1429                m.insert("extra".to_string(), pinned_alias(Some("b"), "model-extra"));
1430                m
1431            },
1432        };
1433
1434        let mut diag = DiagnosticCollector::new();
1435        let merged = merge_model_config(&IndexMap::new(), &[dep1, dep2], &mut diag);
1436
1437        assert!(merged.contains_key("opus"));
1438        assert_eq!(
1439            merged.get("custom").unwrap().spec,
1440            ModelSpec::Pinned {
1441                model: "model-a".to_string(),
1442                provider: None
1443            }
1444        );
1445        assert_eq!(
1446            merged.get("extra").unwrap().spec,
1447            ModelSpec::Pinned {
1448                model: "model-extra".to_string(),
1449                provider: None
1450            }
1451        );
1452        assert_eq!(diag.drain().len(), 1);
1453    }
1454
1455    // -- resolve_all tests --
1456
1457    #[test]
1458    fn resolve_all_pinned() {
1459        let mut aliases = IndexMap::new();
1460        aliases.insert(
1461            "fast".to_string(),
1462            pinned_alias(Some("claude"), "claude-haiku-4-5"),
1463        );
1464
1465        let cache = ModelsCache {
1466            models: Vec::new(),
1467            fetched_at: None,
1468        };
1469
1470        let resolved = resolve_all(&aliases, &cache);
1471        let entry = resolved.get("fast").unwrap();
1472        assert_eq!(entry.model_id, "claude-haiku-4-5");
1473        assert_eq!(entry.provider, "anthropic");
1474    }
1475
1476    #[test]
1477    fn resolve_all_pinned_with_provider() {
1478        let mut aliases = IndexMap::new();
1479        aliases.insert(
1480            "fast".to_string(),
1481            ModelAlias {
1482                harness: None,
1483                description: None,
1484                spec: ModelSpec::Pinned {
1485                    model: "gpt-5.3-codex".to_string(),
1486                    provider: Some("openai".to_string()),
1487                },
1488            },
1489        );
1490
1491        let cache = ModelsCache {
1492            models: Vec::new(),
1493            fetched_at: None,
1494        };
1495
1496        let resolved = resolve_all(&aliases, &cache);
1497        let entry = resolved.get("fast").unwrap();
1498        assert_eq!(entry.model_id, "gpt-5.3-codex");
1499        assert_eq!(entry.provider, "openai");
1500        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1501    }
1502
1503    #[test]
1504    fn resolve_all_pinned_auto_detect_harness() {
1505        let mut aliases = IndexMap::new();
1506        aliases.insert(
1507            "opus".to_string(),
1508            ModelAlias {
1509                harness: None,
1510                description: None,
1511                spec: ModelSpec::Pinned {
1512                    model: "claude-opus-4-6".to_string(),
1513                    provider: Some("anthropic".to_string()),
1514                },
1515            },
1516        );
1517
1518        let cache = ModelsCache {
1519            models: Vec::new(),
1520            fetched_at: None,
1521        };
1522
1523        let resolved = resolve_all(&aliases, &cache);
1524        let entry = resolved.get("opus").unwrap();
1525        assert_eq!(entry.model_id, "claude-opus-4-6");
1526        assert_eq!(entry.provider, "anthropic");
1527
1528        let installed = harness::detect_installed_harnesses();
1529        let expected_harness = harness::resolve_harness_for_provider("anthropic", &installed);
1530        let expected_source = if expected_harness.is_some() {
1531            HarnessSource::AutoDetected
1532        } else {
1533            HarnessSource::Unavailable
1534        };
1535
1536        assert_eq!(entry.harness, expected_harness);
1537        assert_eq!(entry.harness_source, expected_source);
1538    }
1539
1540    #[test]
1541    fn resolve_all_auto_detect_harness() {
1542        let mut aliases = IndexMap::new();
1543        aliases.insert(
1544            "gpt".to_string(),
1545            ModelAlias {
1546                harness: None,
1547                description: None,
1548                spec: ModelSpec::AutoResolve {
1549                    provider: "openai".to_string(),
1550                    match_patterns: vec!["gpt-5*".to_string()],
1551                    exclude_patterns: vec![],
1552                },
1553            },
1554        );
1555        let cache = make_cache(vec![("gpt-5", "OpenAI", Some("2025-06-01"))]);
1556
1557        let resolved = resolve_all(&aliases, &cache);
1558        let entry = resolved.get("gpt").unwrap();
1559        assert_eq!(entry.model_id, "gpt-5");
1560        assert_eq!(entry.provider, "openai");
1561        assert_eq!(entry.harness_candidates, vec!["codex", "opencode"]);
1562        match entry.harness_source {
1563            HarnessSource::AutoDetected => assert!(entry.harness.is_some()),
1564            HarnessSource::Unavailable => assert!(entry.harness.is_none()),
1565            HarnessSource::Explicit => panic!("unexpected explicit harness source"),
1566        }
1567    }
1568
1569    #[test]
1570    fn resolve_all_unavailable_harness_still_included() {
1571        let mut aliases = IndexMap::new();
1572        aliases.insert(
1573            "opus".to_string(),
1574            ModelAlias {
1575                harness: Some("missing-harness-xyz".to_string()),
1576                description: None,
1577                spec: ModelSpec::Pinned {
1578                    model: "claude-opus-4-6".to_string(),
1579                    provider: None,
1580                },
1581            },
1582        );
1583
1584        let cache = ModelsCache {
1585            models: Vec::new(),
1586            fetched_at: None,
1587        };
1588
1589        let resolved = resolve_all(&aliases, &cache);
1590        let entry = resolved.get("opus").unwrap();
1591        assert_eq!(entry.model_id, "claude-opus-4-6");
1592        assert_eq!(entry.provider, "anthropic");
1593        assert_eq!(entry.harness.as_deref(), Some("missing-harness-xyz"));
1594        assert_eq!(entry.harness_source, HarnessSource::Unavailable);
1595    }
1596
1597    #[test]
1598    fn resolve_all_empty_cache_omits_unresolvable() {
1599        let mut aliases = IndexMap::new();
1600        aliases.insert(
1601            "opus".to_string(),
1602            ModelAlias {
1603                harness: Some("claude".to_string()),
1604                description: None,
1605                spec: ModelSpec::AutoResolve {
1606                    provider: "Anthropic".to_string(),
1607                    match_patterns: vec!["claude-opus-*".to_string()],
1608                    exclude_patterns: vec![],
1609                },
1610            },
1611        );
1612        let cache = ModelsCache {
1613            models: Vec::new(),
1614            fetched_at: None,
1615        };
1616
1617        let resolved = resolve_all(&aliases, &cache);
1618        // No cache → auto-resolve can't match → alias omitted from results
1619        assert!(!resolved.contains_key("opus"));
1620    }
1621
1622    fn make_resolved_alias(name: &str) -> ResolvedAlias {
1623        ResolvedAlias {
1624            name: name.to_string(),
1625            model_id: format!("model-{name}"),
1626            provider: "openai".to_string(),
1627            harness: Some("codex".to_string()),
1628            harness_source: HarnessSource::Explicit,
1629            harness_candidates: vec!["codex".to_string()],
1630            description: None,
1631        }
1632    }
1633
1634    #[test]
1635    fn filter_by_visibility_include_mode_keeps_matches_only() {
1636        let mut aliases = IndexMap::new();
1637        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1638        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1639        aliases.insert("gpt-5".to_string(), make_resolved_alias("gpt-5"));
1640
1641        let filtered = filter_by_visibility(
1642            aliases,
1643            &crate::config::ModelVisibility {
1644                include: Some(vec!["opus*".to_string(), "gpt-*".to_string()]),
1645                exclude: None,
1646            },
1647        );
1648
1649        assert_eq!(filtered.len(), 2);
1650        assert!(filtered.contains_key("opus"));
1651        assert!(filtered.contains_key("gpt-5"));
1652        assert!(!filtered.contains_key("sonnet"));
1653    }
1654
1655    #[test]
1656    fn filter_by_visibility_exclude_mode_removes_matches() {
1657        let mut aliases = IndexMap::new();
1658        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1659        aliases.insert("test-opus".to_string(), make_resolved_alias("test-opus"));
1660        aliases.insert(
1661            "deprecated-gpt".to_string(),
1662            make_resolved_alias("deprecated-gpt"),
1663        );
1664
1665        let filtered = filter_by_visibility(
1666            aliases,
1667            &crate::config::ModelVisibility {
1668                include: None,
1669                exclude: Some(vec!["test-*".to_string(), "deprecated-*".to_string()]),
1670            },
1671        );
1672
1673        assert_eq!(filtered.len(), 1);
1674        assert!(filtered.contains_key("opus"));
1675        assert!(!filtered.contains_key("test-opus"));
1676        assert!(!filtered.contains_key("deprecated-gpt"));
1677    }
1678
1679    #[test]
1680    fn filter_by_visibility_empty_config_returns_all() {
1681        let mut aliases = IndexMap::new();
1682        aliases.insert("opus".to_string(), make_resolved_alias("opus"));
1683        aliases.insert("sonnet".to_string(), make_resolved_alias("sonnet"));
1684        let filtered = filter_by_visibility(aliases, &crate::config::ModelVisibility::default());
1685        assert_eq!(filtered.len(), 2);
1686        assert!(filtered.contains_key("opus"));
1687        assert!(filtered.contains_key("sonnet"));
1688    }
1689
1690    #[test]
1691    fn resolve_model_and_provider_pinned_explicit_provider() {
1692        let alias = ModelAlias {
1693            harness: None,
1694            description: None,
1695            spec: ModelSpec::Pinned {
1696                model: "claude-opus-4-6".to_string(),
1697                provider: Some("anthropic".to_string()),
1698            },
1699        };
1700        let cache = ModelsCache {
1701            models: Vec::new(),
1702            fetched_at: None,
1703        };
1704
1705        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1706        assert_eq!(
1707            resolved,
1708            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1709        );
1710    }
1711
1712    #[test]
1713    fn resolve_model_and_provider_pinned_inferred() {
1714        let alias = ModelAlias {
1715            harness: None,
1716            description: None,
1717            spec: ModelSpec::Pinned {
1718                model: "claude-opus-4-6".to_string(),
1719                provider: None,
1720            },
1721        };
1722        let cache = ModelsCache {
1723            models: Vec::new(),
1724            fetched_at: None,
1725        };
1726
1727        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1728        assert_eq!(
1729            resolved,
1730            ("claude-opus-4-6".to_string(), "anthropic".to_string())
1731        );
1732    }
1733
1734    #[test]
1735    fn resolve_model_and_provider_pinned_unknown() {
1736        let alias = ModelAlias {
1737            harness: None,
1738            description: None,
1739            spec: ModelSpec::Pinned {
1740                model: "my-custom-model".to_string(),
1741                provider: None,
1742            },
1743        };
1744        let cache = ModelsCache {
1745            models: Vec::new(),
1746            fetched_at: None,
1747        };
1748
1749        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1750        assert_eq!(
1751            resolved,
1752            ("my-custom-model".to_string(), "unknown".to_string())
1753        );
1754    }
1755
1756    #[test]
1757    fn resolve_model_and_provider_auto_resolve() {
1758        let alias = ModelAlias {
1759            harness: None,
1760            description: None,
1761            spec: ModelSpec::AutoResolve {
1762                provider: "openai".to_string(),
1763                match_patterns: vec!["gpt-5*".to_string()],
1764                exclude_patterns: vec![],
1765            },
1766        };
1767        let cache = make_cache(vec![
1768            ("gpt-4o", "OpenAI", Some("2024-06-01")),
1769            ("gpt-5", "OpenAI", Some("2025-06-01")),
1770        ]);
1771
1772        let resolved = resolve_model_and_provider(&alias, &cache).unwrap();
1773        assert_eq!(resolved, ("gpt-5".to_string(), "openai".to_string()));
1774    }
1775
1776    #[test]
1777    fn resolve_harness_explicit_installed() {
1778        let alias = ModelAlias {
1779            harness: Some("claude".to_string()),
1780            description: None,
1781            spec: ModelSpec::Pinned {
1782                model: "claude-opus-4-6".to_string(),
1783                provider: None,
1784            },
1785        };
1786        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1787
1788        let resolved = resolve_harness(&alias, "anthropic", &installed);
1789        assert_eq!(
1790            resolved,
1791            (Some("claude".to_string()), HarnessSource::Explicit)
1792        );
1793    }
1794
1795    #[test]
1796    fn resolve_harness_explicit_not_installed() {
1797        let alias = ModelAlias {
1798            harness: Some("claude".to_string()),
1799            description: None,
1800            spec: ModelSpec::Pinned {
1801                model: "claude-opus-4-6".to_string(),
1802                provider: None,
1803            },
1804        };
1805        let installed = HashSet::new();
1806
1807        let resolved = resolve_harness(&alias, "anthropic", &installed);
1808        assert_eq!(
1809            resolved,
1810            (Some("claude".to_string()), HarnessSource::Unavailable)
1811        );
1812    }
1813
1814    #[test]
1815    fn resolve_harness_auto_detected() {
1816        let alias = ModelAlias {
1817            harness: None,
1818            description: None,
1819            spec: ModelSpec::Pinned {
1820                model: "claude-opus-4-6".to_string(),
1821                provider: Some("anthropic".to_string()),
1822            },
1823        };
1824        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1825
1826        let resolved = resolve_harness(&alias, "anthropic", &installed);
1827        assert_eq!(
1828            resolved,
1829            (Some("claude".to_string()), HarnessSource::AutoDetected)
1830        );
1831    }
1832
1833    #[test]
1834    fn resolve_harness_unavailable() {
1835        let alias = ModelAlias {
1836            harness: None,
1837            description: None,
1838            spec: ModelSpec::Pinned {
1839                model: "claude-opus-4-6".to_string(),
1840                provider: Some("anthropic".to_string()),
1841            },
1842        };
1843        let installed = HashSet::new();
1844
1845        let resolved = resolve_harness(&alias, "anthropic", &installed);
1846        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1847    }
1848
1849    #[test]
1850    fn resolve_harness_unavailable_no_provider_match() {
1851        let alias = ModelAlias {
1852            harness: None,
1853            description: None,
1854            spec: ModelSpec::Pinned {
1855                model: "my-custom-model".to_string(),
1856                provider: Some("unknown".to_string()),
1857            },
1858        };
1859        let installed: HashSet<String> = ["claude"].iter().map(|s| s.to_string()).collect();
1860
1861        let resolved = resolve_harness(&alias, "unknown", &installed);
1862        assert_eq!(resolved, (None, HarnessSource::Unavailable));
1863    }
1864
1865    // -- serde roundtrip tests --
1866
1867    #[test]
1868    fn harness_source_serializes_snake_case() {
1869        assert_eq!(
1870            serde_json::to_string(&HarnessSource::Explicit).unwrap(),
1871            "\"explicit\""
1872        );
1873        assert_eq!(
1874            serde_json::to_string(&HarnessSource::AutoDetected).unwrap(),
1875            "\"auto_detected\""
1876        );
1877        assert_eq!(
1878            serde_json::to_string(&HarnessSource::Unavailable).unwrap(),
1879            "\"unavailable\""
1880        );
1881    }
1882
1883    #[test]
1884    fn model_alias_pinned_toml_roundtrip_backwards_compat_harness() {
1885        let toml_str = r#"
1886[models.fast]
1887harness = "claude"
1888model = "claude-haiku-4-5"
1889description = "Fast and cheap"
1890"#;
1891
1892        #[derive(Debug, Deserialize)]
1893        struct Wrapper {
1894            models: IndexMap<String, ModelAlias>,
1895        }
1896
1897        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1898        let alias = parsed.models.get("fast").unwrap();
1899        assert_eq!(
1900            alias.spec,
1901            ModelSpec::Pinned {
1902                model: "claude-haiku-4-5".to_string(),
1903                provider: None
1904            }
1905        );
1906        assert_eq!(alias.harness.as_deref(), Some("claude"));
1907        assert_eq!(alias.description.as_deref(), Some("Fast and cheap"));
1908
1909        let json = serde_json::to_string(alias).unwrap();
1910        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1911        assert_eq!(roundtripped, *alias);
1912    }
1913
1914    #[test]
1915    fn model_alias_pinned_toml_roundtrip_without_harness() {
1916        let toml_str = r#"
1917[models.fast]
1918model = "claude-haiku-4-5"
1919"#;
1920
1921        #[derive(Debug, Deserialize)]
1922        struct Wrapper {
1923            models: IndexMap<String, ModelAlias>,
1924        }
1925
1926        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1927        let alias = parsed.models.get("fast").unwrap();
1928        assert_eq!(alias.harness, None);
1929        assert_eq!(
1930            alias.spec,
1931            ModelSpec::Pinned {
1932                model: "claude-haiku-4-5".to_string(),
1933                provider: None
1934            }
1935        );
1936
1937        let json = serde_json::to_string(alias).unwrap();
1938        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1939        assert!(value.get("harness").is_none());
1940        assert!(value.get("provider").is_none());
1941        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1942        assert_eq!(roundtripped, *alias);
1943    }
1944
1945    #[test]
1946    fn model_alias_pinned_toml_roundtrip_with_provider() {
1947        let toml_str = r#"
1948[models.fast]
1949model = "claude-haiku-4-5"
1950provider = "anthropic"
1951"#;
1952
1953        #[derive(Debug, Deserialize)]
1954        struct Wrapper {
1955            models: IndexMap<String, ModelAlias>,
1956        }
1957
1958        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
1959        let alias = parsed.models.get("fast").unwrap();
1960        assert_eq!(alias.harness, None);
1961        assert_eq!(
1962            alias.spec,
1963            ModelSpec::Pinned {
1964                model: "claude-haiku-4-5".to_string(),
1965                provider: Some("anthropic".to_string())
1966            }
1967        );
1968
1969        let json = serde_json::to_string(alias).unwrap();
1970        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1971        assert_eq!(
1972            value.get("provider").and_then(serde_json::Value::as_str),
1973            Some("anthropic")
1974        );
1975        let roundtripped: ModelAlias = serde_json::from_str(&json).unwrap();
1976        assert_eq!(roundtripped, *alias);
1977    }
1978
1979    #[test]
1980    fn model_alias_pinned_json_roundtrip_with_provider() {
1981        let json = r#"{
1982            "model": "gpt-5.3-codex",
1983            "provider": "openai"
1984        }"#;
1985
1986        let alias: ModelAlias = serde_json::from_str(json).unwrap();
1987        assert_eq!(alias.harness, None);
1988        assert_eq!(alias.description, None);
1989        assert_eq!(
1990            alias.spec,
1991            ModelSpec::Pinned {
1992                model: "gpt-5.3-codex".to_string(),
1993                provider: Some("openai".to_string())
1994            }
1995        );
1996
1997        let encoded = serde_json::to_string(&alias).unwrap();
1998        let roundtripped: ModelAlias = serde_json::from_str(&encoded).unwrap();
1999        assert_eq!(roundtripped, alias);
2000    }
2001
2002    #[test]
2003    fn model_alias_auto_resolve_toml_roundtrip() {
2004        let toml_str = r#"
2005[models.opus]
2006harness = "claude"
2007provider = "Anthropic"
2008match = ["claude-opus-*"]
2009exclude = ["claude-opus-3*"]
2010description = "Best reasoning"
2011"#;
2012
2013        #[derive(Debug, Deserialize)]
2014        struct Wrapper {
2015            models: IndexMap<String, ModelAlias>,
2016        }
2017
2018        let parsed: Wrapper = toml::from_str(toml_str).unwrap();
2019        let alias = parsed.models.get("opus").unwrap();
2020        assert_eq!(alias.harness.as_deref(), Some("claude"));
2021        match &alias.spec {
2022            ModelSpec::AutoResolve {
2023                provider,
2024                match_patterns,
2025                exclude_patterns,
2026            } => {
2027                assert_eq!(provider, "Anthropic");
2028                assert_eq!(match_patterns, &["claude-opus-*"]);
2029                assert_eq!(exclude_patterns, &["claude-opus-3*"]);
2030            }
2031            _ => panic!("expected AutoResolve"),
2032        }
2033    }
2034
2035    #[test]
2036    fn model_alias_both_model_and_match_errors() {
2037        let toml_str = r#"
2038[models.bad]
2039harness = "claude"
2040model = "some-model"
2041match = ["pattern-*"]
2042"#;
2043
2044        #[derive(Debug, Deserialize)]
2045        struct Wrapper {
2046            #[expect(dead_code)]
2047            models: IndexMap<String, ModelAlias>,
2048        }
2049
2050        let result = toml::from_str::<Wrapper>(toml_str);
2051        assert!(result.is_err());
2052        let err_msg = result.unwrap_err().to_string();
2053        assert!(err_msg.contains("both"));
2054    }
2055
2056    #[test]
2057    fn model_alias_neither_model_nor_match_errors() {
2058        let toml_str = r#"
2059[models.bad]
2060harness = "claude"
2061"#;
2062
2063        #[derive(Debug, Deserialize)]
2064        struct Wrapper {
2065            #[expect(dead_code)]
2066            models: IndexMap<String, ModelAlias>,
2067        }
2068
2069        let result = toml::from_str::<Wrapper>(toml_str);
2070        assert!(result.is_err());
2071    }
2072
2073    #[test]
2074    fn infer_provider_from_model_id_detects_known_prefixes() {
2075        assert_eq!(
2076            infer_provider_from_model_id("claude-opus-4-6"),
2077            Some("anthropic")
2078        );
2079        assert_eq!(
2080            infer_provider_from_model_id("gpt-5.3-codex"),
2081            Some("openai")
2082        );
2083        assert_eq!(
2084            infer_provider_from_model_id("gemini-2.5-pro"),
2085            Some("google")
2086        );
2087        assert_eq!(
2088            infer_provider_from_model_id("llama-4-maverick"),
2089            Some("meta")
2090        );
2091        assert_eq!(infer_provider_from_model_id("o1-preview"), Some("openai"));
2092        assert_eq!(infer_provider_from_model_id("o3-mini"), Some("openai"));
2093        assert_eq!(infer_provider_from_model_id("o4-mini"), Some("openai"));
2094        assert_eq!(
2095            infer_provider_from_model_id("codex-mini-latest"),
2096            Some("openai")
2097        );
2098        assert_eq!(
2099            infer_provider_from_model_id("mistral-large"),
2100            Some("mistral")
2101        );
2102        assert_eq!(
2103            infer_provider_from_model_id("codestral-latest"),
2104            Some("mistral")
2105        );
2106        assert_eq!(
2107            infer_provider_from_model_id("deepseek-chat"),
2108            Some("deepseek")
2109        );
2110        assert_eq!(
2111            infer_provider_from_model_id("command-r-plus"),
2112            Some("cohere")
2113        );
2114    }
2115
2116    #[test]
2117    fn infer_provider_from_model_id_returns_none_for_unknown_model() {
2118        assert_eq!(infer_provider_from_model_id("unknown-model"), None);
2119    }
2120
2121    #[test]
2122    fn infer_provider_from_model_id_returns_none_for_empty_string() {
2123        assert_eq!(infer_provider_from_model_id(""), None);
2124    }
2125
2126    #[test]
2127    fn infer_provider_from_model_id_is_case_insensitive() {
2128        assert_eq!(
2129            infer_provider_from_model_id("CLAUDE-OPUS-4-6"),
2130            Some("anthropic")
2131        );
2132        assert_eq!(
2133            infer_provider_from_model_id("GPT-5.3-codex"),
2134            Some("openai")
2135        );
2136        assert_eq!(
2137            infer_provider_from_model_id("CoDeStRaL-latest"),
2138            Some("mistral")
2139        );
2140    }
2141
2142    #[allow(unused_unsafe)]
2143    fn env_set(key: &str, value: &str) {
2144        unsafe {
2145            std::env::set_var(key, value);
2146        }
2147    }
2148
2149    #[allow(unused_unsafe)]
2150    fn env_remove(key: &str) {
2151        unsafe {
2152            std::env::remove_var(key);
2153        }
2154    }
2155
2156    struct EnvVarGuard {
2157        key: String,
2158        prev: Option<String>,
2159    }
2160
2161    impl EnvVarGuard {
2162        fn set(key: &str, value: &str) -> Self {
2163            let prev = std::env::var(key).ok();
2164            env_set(key, value);
2165            Self {
2166                key: key.to_string(),
2167                prev,
2168            }
2169        }
2170    }
2171
2172    impl Drop for EnvVarGuard {
2173        fn drop(&mut self) {
2174            if let Some(prev) = &self.prev {
2175                env_set(&self.key, prev);
2176            } else {
2177                env_remove(&self.key);
2178            }
2179        }
2180    }
2181
2182    fn sample_catalog_json() -> serde_json::Value {
2183        serde_json::json!({
2184            "openai": {
2185                "models": {
2186                    "gpt-5": {
2187                        "id": "gpt-5",
2188                        "name": "GPT-5",
2189                        "release_date": "2025-06-01",
2190                        "limit": {
2191                            "context": 400000,
2192                            "output": 128000
2193                        }
2194                    }
2195                }
2196            },
2197            "anthropic": {
2198                "models": {
2199                    "claude-sonnet-4-5": {
2200                        "id": "claude-sonnet-4-5",
2201                        "name": "Claude Sonnet 4.5",
2202                        "release_date": "2025-03-01"
2203                    }
2204                }
2205            }
2206        })
2207    }
2208
2209    fn sample_cached_model(id: &str) -> CachedModel {
2210        CachedModel {
2211            id: id.to_string(),
2212            provider: "OpenAI".to_string(),
2213            release_date: None,
2214            description: None,
2215            context_window: None,
2216            max_output: None,
2217        }
2218    }
2219
2220    fn write_cache_state(mars_dir: &std::path::Path, models: Vec<CachedModel>, fetched_at: &str) {
2221        write_cache(
2222            mars_dir,
2223            &ModelsCache {
2224                models,
2225                fetched_at: Some(fetched_at.to_string()),
2226            },
2227        )
2228        .expect("failed to write cache fixture");
2229    }
2230
2231    fn write_raw_cache_file(mars_dir: &std::path::Path, raw: &str) {
2232        std::fs::create_dir_all(mars_dir).expect("failed to create mars dir");
2233        std::fs::write(mars_dir.join(CACHE_FILE), raw).expect("failed to write raw cache");
2234    }
2235
2236    fn stale_timestamp() -> String {
2237        now_unix_secs_value().saturating_sub(48 * 3600).to_string()
2238    }
2239
2240    fn fresh_timestamp() -> String {
2241        now_unix_secs_value().saturating_sub(60).to_string()
2242    }
2243
2244    fn assert_model_cache_unavailable(
2245        result: Result<(ModelsCache, RefreshOutcome), MarsError>,
2246        reason_contains: &str,
2247    ) {
2248        match result {
2249            Err(MarsError::ModelCacheUnavailable { reason }) => {
2250                assert!(
2251                    reason.contains(reason_contains),
2252                    "unexpected reason: {reason}"
2253                );
2254            }
2255            other => panic!("expected ModelCacheUnavailable, got {other:?}"),
2256        }
2257    }
2258
2259    #[test]
2260    #[serial]
2261    fn ensure_fresh_1_missing_cache_offline_errors() {
2262        let mars = tempdir().unwrap();
2263        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2264
2265        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2266        assert_model_cache_unavailable(result, "MARS_OFFLINE is set");
2267    }
2268
2269    #[test]
2270    #[serial]
2271    fn ensure_fresh_2_missing_cache_auto_fetch_failure_errors() {
2272        let mars = tempdir().unwrap();
2273        let server = MockServer::start();
2274        let mock = server.mock(|when, then| {
2275            when.method(GET).path("/api.json");
2276            then.status(500).body("server error");
2277        });
2278        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2279
2280        let result = ensure_fresh(mars.path(), 24, RefreshMode::Auto);
2281        assert_model_cache_unavailable(result, "automatic refresh failed");
2282        assert_eq!(mock.hits(), 1);
2283    }
2284
2285    #[test]
2286    fn ensure_fresh_3_stale_usable_offline_returns_stale() {
2287        let mars = tempdir().unwrap();
2288        write_cache_state(
2289            mars.path(),
2290            vec![sample_cached_model("stale-model")],
2291            &stale_timestamp(),
2292        );
2293
2294        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Offline).unwrap();
2295        assert_eq!(cache.models.len(), 1);
2296        assert_eq!(cache.models[0].id, "stale-model");
2297        assert_eq!(outcome, RefreshOutcome::Offline);
2298    }
2299
2300    #[test]
2301    #[serial]
2302    fn ensure_fresh_4_fresh_auto_skips_http() {
2303        let mars = tempdir().unwrap();
2304        write_cache_state(
2305            mars.path(),
2306            vec![sample_cached_model("fresh-model")],
2307            &fresh_timestamp(),
2308        );
2309
2310        let server = MockServer::start();
2311        let mock = server.mock(|when, then| {
2312            when.method(GET).path("/api.json");
2313            then.status(200).json_body(sample_catalog_json());
2314        });
2315        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2316
2317        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2318        assert_eq!(outcome, RefreshOutcome::AlreadyFresh);
2319        assert_eq!(mock.hits(), 0);
2320    }
2321
2322    #[test]
2323    #[serial]
2324    fn ensure_fresh_5_stale_auto_success_refreshes() {
2325        let mars = tempdir().unwrap();
2326        write_cache_state(
2327            mars.path(),
2328            vec![sample_cached_model("old-model")],
2329            &stale_timestamp(),
2330        );
2331
2332        let server = MockServer::start();
2333        let mock = server.mock(|when, then| {
2334            when.method(GET).path("/api.json");
2335            then.status(200).json_body(sample_catalog_json());
2336        });
2337        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2338
2339        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2340        assert!(matches!(
2341            outcome,
2342            RefreshOutcome::Refreshed { models_count } if models_count == 2
2343        ));
2344        assert_eq!(cache.models.len(), 2);
2345        assert!(!cache.models.is_empty());
2346        assert!(cache.fetched_at.is_some());
2347        assert_eq!(mock.hits(), 1);
2348    }
2349
2350    #[test]
2351    #[serial]
2352    fn ensure_fresh_6_stale_auto_fetch_failure_falls_back() {
2353        let mars = tempdir().unwrap();
2354        write_cache_state(
2355            mars.path(),
2356            vec![sample_cached_model("stale-model")],
2357            &stale_timestamp(),
2358        );
2359
2360        let server = MockServer::start();
2361        let mock = server.mock(|when, then| {
2362            when.method(GET).path("/api.json");
2363            then.status(500).body("server error");
2364        });
2365        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2366
2367        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2368        assert_eq!(cache.models[0].id, "stale-model");
2369        assert!(matches!(
2370            outcome,
2371            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2372        ));
2373        assert_eq!(mock.hits(), 1);
2374    }
2375
2376    #[test]
2377    #[serial]
2378    fn ensure_fresh_7_stale_auto_empty_catalog_falls_back() {
2379        let mars = tempdir().unwrap();
2380        write_cache_state(
2381            mars.path(),
2382            vec![sample_cached_model("stale-model")],
2383            &stale_timestamp(),
2384        );
2385
2386        let server = MockServer::start();
2387        let mock = server.mock(|when, then| {
2388            when.method(GET).path("/api.json");
2389            then.status(200).json_body(serde_json::json!({}));
2390        });
2391        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2392
2393        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2394        assert_eq!(cache.models[0].id, "stale-model");
2395        assert!(matches!(
2396            outcome,
2397            RefreshOutcome::StaleFallback { reason } if reason == "API returned empty catalog"
2398        ));
2399        assert_eq!(mock.hits(), 1);
2400    }
2401
2402    #[test]
2403    #[serial]
2404    fn ensure_fresh_8_empty_cache_auto_refetches() {
2405        let mars = tempdir().unwrap();
2406        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2407
2408        let server = MockServer::start();
2409        let mock = server.mock(|when, then| {
2410            when.method(GET).path("/api.json");
2411            then.status(200).json_body(sample_catalog_json());
2412        });
2413        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2414
2415        let (cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2416        assert!(!cache.models.is_empty());
2417        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2418        assert_eq!(mock.hits(), 1);
2419    }
2420
2421    #[test]
2422    fn ensure_fresh_9_empty_cache_offline_errors() {
2423        let mars = tempdir().unwrap();
2424        write_cache_state(mars.path(), Vec::new(), &fresh_timestamp());
2425
2426        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2427        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2428    }
2429
2430    #[test]
2431    #[serial]
2432    fn ensure_fresh_10_corrupt_json_auto_refetches() {
2433        let mars = tempdir().unwrap();
2434        write_raw_cache_file(mars.path(), "{ not-json ");
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::Auto).unwrap();
2444        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2445        assert!(!cache.models.is_empty());
2446        assert_eq!(mock.hits(), 1);
2447    }
2448
2449    #[test]
2450    fn ensure_fresh_11_corrupt_json_offline_errors() {
2451        let mars = tempdir().unwrap();
2452        write_raw_cache_file(mars.path(), "{ not-json ");
2453
2454        let result = ensure_fresh(mars.path(), 24, RefreshMode::Offline);
2455        assert_model_cache_unavailable(result, "--no-refresh-models was passed");
2456    }
2457
2458    #[test]
2459    #[serial]
2460    fn ensure_fresh_12_ttl_zero_always_refetches() {
2461        let mars = tempdir().unwrap();
2462        write_cache_state(
2463            mars.path(),
2464            vec![sample_cached_model("fresh-model")],
2465            &fresh_timestamp(),
2466        );
2467
2468        let server = MockServer::start();
2469        let mock = server.mock(|when, then| {
2470            when.method(GET).path("/api.json");
2471            then.status(200).json_body(sample_catalog_json());
2472        });
2473        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2474
2475        let (_cache, outcome) = ensure_fresh(mars.path(), 0, RefreshMode::Auto).unwrap();
2476        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2477        assert_eq!(mock.hits(), 1);
2478    }
2479
2480    #[test]
2481    #[serial]
2482    fn ensure_fresh_13_unparseable_fetched_at_is_stale() {
2483        let mars = tempdir().unwrap();
2484        write_cache_state(
2485            mars.path(),
2486            vec![sample_cached_model("stale-model")],
2487            "not-a-timestamp",
2488        );
2489
2490        let server = MockServer::start();
2491        let mock = server.mock(|when, then| {
2492            when.method(GET).path("/api.json");
2493            then.status(200).json_body(sample_catalog_json());
2494        });
2495        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2496
2497        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2498        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2499        assert_eq!(mock.hits(), 1);
2500    }
2501
2502    #[test]
2503    #[serial]
2504    fn ensure_fresh_14_future_fetched_at_is_stale() {
2505        let mars = tempdir().unwrap();
2506        let future = now_unix_secs_value() + 3600;
2507        write_cache_state(
2508            mars.path(),
2509            vec![sample_cached_model("future-model")],
2510            &future.to_string(),
2511        );
2512
2513        let server = MockServer::start();
2514        let mock = server.mock(|when, then| {
2515            when.method(GET).path("/api.json");
2516            then.status(200).json_body(sample_catalog_json());
2517        });
2518        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2519
2520        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2521        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2522        assert_eq!(mock.hits(), 1);
2523    }
2524
2525    #[test]
2526    #[serial]
2527    fn ensure_fresh_15_offline_env_auto_fresh_returns_offline() {
2528        let mars = tempdir().unwrap();
2529        write_cache_state(
2530            mars.path(),
2531            vec![sample_cached_model("fresh-model")],
2532            &fresh_timestamp(),
2533        );
2534
2535        let server = MockServer::start();
2536        let mock = server.mock(|when, then| {
2537            when.method(GET).path("/api.json");
2538            then.status(200).json_body(sample_catalog_json());
2539        });
2540        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2541        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2542
2543        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2544        assert_eq!(outcome, RefreshOutcome::Offline);
2545        assert_eq!(mock.hits(), 0);
2546    }
2547
2548    #[test]
2549    #[serial]
2550    fn ensure_fresh_16_offline_env_zero_is_not_offline() {
2551        let _offline = EnvVarGuard::set("MARS_OFFLINE", "0");
2552        assert!(!is_mars_offline());
2553        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2554    }
2555
2556    #[test]
2557    #[serial]
2558    fn ensure_fresh_17_offline_env_truthy_is_offline() {
2559        let _offline = EnvVarGuard::set("MARS_OFFLINE", " TRUE ");
2560        assert!(is_mars_offline());
2561        assert_eq!(resolve_refresh_mode(false), RefreshMode::Auto);
2562    }
2563
2564    #[test]
2565    #[serial]
2566    fn ensure_fresh_18_force_ignores_offline_env() {
2567        let mars = tempdir().unwrap();
2568        let _offline = EnvVarGuard::set("MARS_OFFLINE", "1");
2569
2570        let server = MockServer::start();
2571        let mock = server.mock(|when, then| {
2572            when.method(GET).path("/api.json");
2573            then.status(200).json_body(sample_catalog_json());
2574        });
2575        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2576
2577        let (_cache, outcome) = ensure_fresh(mars.path(), 24, RefreshMode::Force).unwrap();
2578        assert!(matches!(outcome, RefreshOutcome::Refreshed { .. }));
2579        assert_eq!(mock.hits(), 1);
2580    }
2581
2582    #[test]
2583    #[serial]
2584    fn ensure_fresh_19_concurrent_auto_refresh_hits_api_once() {
2585        let mars = tempdir().unwrap();
2586        write_cache_state(
2587            mars.path(),
2588            vec![sample_cached_model("stale-model")],
2589            &stale_timestamp(),
2590        );
2591
2592        let path = Arc::new(mars.path().to_path_buf());
2593        let path_a = Arc::clone(&path);
2594        let path_b = Arc::clone(&path);
2595        let fetch_hits = Arc::new(AtomicUsize::new(0));
2596        let (fetch_started_tx, fetch_started_rx) = mpsc::channel::<()>();
2597        let (release_fetch_tx, release_fetch_rx) = mpsc::channel::<()>();
2598
2599        let fetch_hits_a = Arc::clone(&fetch_hits);
2600        let t1 = thread::spawn(move || {
2601            ensure_fresh_with_fetcher(&path_a, 24, RefreshMode::Auto, move || {
2602                fetch_hits_a.fetch_add(1, Ordering::SeqCst);
2603                fetch_started_tx.send(()).unwrap();
2604                release_fetch_rx.recv().unwrap();
2605                Ok(vec![sample_cached_model("fresh-model")])
2606            })
2607            .unwrap()
2608            .1
2609        });
2610
2611        fetch_started_rx.recv().unwrap();
2612
2613        let fetch_hits_b = Arc::clone(&fetch_hits);
2614        let t2 = thread::spawn(move || {
2615            ensure_fresh_with_fetcher(&path_b, 24, RefreshMode::Auto, move || {
2616                fetch_hits_b.fetch_add(1, Ordering::SeqCst);
2617                Ok(vec![sample_cached_model("unexpected-second-refresh")])
2618            })
2619            .unwrap()
2620            .1
2621        });
2622
2623        release_fetch_tx.send(()).unwrap();
2624
2625        let outcome_a = t1.join().unwrap();
2626        let outcome_b = t2.join().unwrap();
2627
2628        let outcomes = [outcome_a, outcome_b];
2629        let refreshed = outcomes
2630            .iter()
2631            .filter(|o| matches!(o, RefreshOutcome::Refreshed { .. }))
2632            .count();
2633        let already_fresh = outcomes
2634            .iter()
2635            .filter(|o| matches!(o, RefreshOutcome::AlreadyFresh))
2636            .count();
2637
2638        assert_eq!(refreshed, 1);
2639        assert_eq!(already_fresh, 1);
2640        assert_eq!(fetch_hits.load(Ordering::SeqCst), 1);
2641    }
2642
2643    #[test]
2644    #[serial]
2645    fn ensure_fresh_20_failed_fetch_cooldown_coalesces_sequential_calls() {
2646        let mars = tempdir().unwrap();
2647        write_cache_state(
2648            mars.path(),
2649            vec![sample_cached_model("stale-model")],
2650            &stale_timestamp(),
2651        );
2652
2653        let server = MockServer::start();
2654        let mock = server.mock(|when, then| {
2655            when.method(GET).path("/api.json");
2656            then.status(500).body("server error");
2657        });
2658        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2659
2660        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2661        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2662
2663        assert!(matches!(
2664            outcome_a,
2665            RefreshOutcome::StaleFallback { reason } if reason.contains("fetch failed")
2666        ));
2667        assert_eq!(
2668            outcome_b,
2669            RefreshOutcome::StaleFallback {
2670                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2671            }
2672        );
2673        assert_eq!(mock.hits(), 1);
2674    }
2675
2676    #[test]
2677    #[serial]
2678    fn ensure_fresh_21_empty_catalog_cooldown_coalesces_sequential_calls() {
2679        let mars = tempdir().unwrap();
2680        write_cache_state(
2681            mars.path(),
2682            vec![sample_cached_model("stale-model")],
2683            &stale_timestamp(),
2684        );
2685
2686        let server = MockServer::start();
2687        let mock = server.mock(|when, then| {
2688            when.method(GET).path("/api.json");
2689            then.status(200).json_body(serde_json::json!({
2690                "openai": {
2691                    "models": {}
2692                }
2693            }));
2694        });
2695        let _api = EnvVarGuard::set("MARS_MODELS_API_URL", &server.url("/api.json"));
2696
2697        let (_cache_a, outcome_a) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2698        let (_cache_b, outcome_b) = ensure_fresh(mars.path(), 24, RefreshMode::Auto).unwrap();
2699
2700        assert!(matches!(
2701            outcome_a,
2702            RefreshOutcome::StaleFallback { reason } if reason.contains("API returned empty catalog")
2703        ));
2704        assert_eq!(
2705            outcome_b,
2706            RefreshOutcome::StaleFallback {
2707                reason: FETCH_FAIL_COOLDOWN_REASON.to_string()
2708            }
2709        );
2710        assert_eq!(mock.hits(), 1);
2711    }
2712
2713    #[test]
2714    fn load_models_cache_ttl_defaults_to_24_when_config_missing() {
2715        let project = tempdir().unwrap();
2716        let ctx = crate::types::MarsContext::for_test(
2717            project.path().to_path_buf(),
2718            project.path().join(".agents"),
2719        );
2720        assert_eq!(load_models_cache_ttl(&ctx), 24);
2721    }
2722
2723    #[test]
2724    fn load_models_cache_ttl_reads_config_value() {
2725        let project = tempdir().unwrap();
2726        std::fs::write(
2727            project.path().join("mars.toml"),
2728            "[settings]\nmodels_cache_ttl_hours = 48\n",
2729        )
2730        .unwrap();
2731        let ctx = crate::types::MarsContext::for_test(
2732            project.path().to_path_buf(),
2733            project.path().join(".agents"),
2734        );
2735        assert_eq!(load_models_cache_ttl(&ctx), 48);
2736    }
2737}