1use 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#[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#[derive(Debug, Clone, PartialEq)]
54pub enum ModelSpec {
55 Pinned {
57 model: String,
58 provider: Option<String>,
59 },
60 AutoResolve {
62 provider: String,
63 match_patterns: Vec<String>,
64 exclude_patterns: Vec<String>,
65 },
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HarnessSource {
72 Explicit,
73 AutoDetected,
74 Unavailable,
75}
76
77#[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
90impl 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; 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#[derive(Debug, Deserialize)]
130struct RawModelAlias {
131 harness: Option<String>,
132 #[serde(default)]
133 description: Option<String>,
134 #[serde(default)]
136 model: Option<String>,
137 #[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#[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#[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 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
458pub 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
477pub 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
491pub 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
604fn 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
618pub 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 m.provider.eq_ignore_ascii_case(provider)
643 })
644 .filter(|m| {
645 !m.id.ends_with("-latest")
647 })
648 .filter(|m| {
649 match_patterns.iter().all(|p| glob_match(p, &m.id))
651 })
652 .filter(|m| {
653 !exclude_patterns.iter().any(|p| glob_match(p, &m.id))
655 })
656 .collect();
657
658 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
671pub fn glob_match(pattern: &str, text: &str) -> bool {
674 let segments: Vec<&str> = pattern.split('*').collect();
676
677 if segments.len() == 1 {
678 return pattern == text;
680 }
681
682 let mut pos = 0;
683
684 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 if let Some(last) = segments.last()
696 && !last.is_empty()
697 && !text[pos..].ends_with(last)
698 {
699 return false;
700 }
701
702 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
727pub 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
781pub struct ResolvedDepModels {
787 pub source_name: String,
788 pub models: IndexMap<String, ModelAlias>,
789}
790
791pub 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 for (name, alias) in &builtins {
806 merged.insert(name.clone(), alias.clone());
807 }
808
809 let mut dep_provided: std::collections::HashMap<String, String> =
811 std::collections::HashMap::new();
812
813 for dep in deps {
815 for (name, alias) in &dep.models {
816 if consumer.contains_key(name) {
817 continue;
819 }
820 if let Some(winner) = dep_provided.get(name) {
821 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 merged.insert(name.clone(), alias.clone());
833 dep_provided.insert(name.clone(), dep.source_name.clone());
834 }
835 }
836 }
837
838 for (name, alias) in consumer {
840 merged.insert(name.clone(), alias.clone());
841 }
842
843 merged
844}
845
846pub 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; };
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
881pub 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#[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#[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 #[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 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 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 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 assert_eq!(result, Some("claude-opus-4".to_string()));
1195 }
1196
1197 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 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 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 assert_eq!(
1310 merged.get("custom").unwrap().spec,
1311 ModelSpec::Pinned {
1312 model: "model-a".to_string(),
1313 provider: None
1314 }
1315 );
1316 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 #[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 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 #[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}