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