Skip to main content

mars_agents/cli/
models.rs

1//! CLI handlers for `mars models` subcommands.
2#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6use std::collections::HashSet;
7
8use crate::diagnostic::{Diagnostic, DiagnosticCollector, DiagnosticLevel};
9use crate::error::MarsError;
10use crate::models::availability::{AvailabilityStatus, ModelAvailability};
11use crate::models::probes::OpenCodeProbeResult;
12use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
13use crate::types::MarsContext;
14
15/// Manage model aliases and the models cache.
16#[derive(Debug, Parser)]
17pub struct ModelsArgs {
18    #[command(subcommand)]
19    pub command: ModelsCommand,
20}
21
22#[derive(Debug, Subcommand)]
23pub enum ModelsCommand {
24    /// Fetch models from API and update the local cache.
25    Refresh,
26    /// List all model aliases (consumer + deps) with resolved IDs.
27    List(ListArgs),
28    /// Show resolution chain for a specific alias.
29    Resolve(ResolveAliasArgs),
30    /// Quick-add a pinned alias to mars.toml [models].
31    Alias(AddAliasArgs),
32}
33
34#[derive(Debug, Parser)]
35pub struct ListArgs {
36    /// Show all alias candidates with availability info. Does NOT show raw catalog - use --catalog for that.
37    #[arg(long, conflicts_with = "catalog", conflicts_with = "unavailable")]
38    all: bool,
39    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
40    #[arg(long)]
41    no_refresh_models: bool,
42    /// Only show aliases matching these patterns (overrides config).
43    #[arg(long, value_delimiter = ',')]
44    include: Option<Vec<String>>,
45    /// Hide aliases matching these patterns (overrides config).
46    #[arg(long, value_delimiter = ',')]
47    exclude: Option<Vec<String>>,
48    /// Show raw models.dev cache entries (diagnostic view). Ignores aliases.
49    #[arg(long, conflicts_with = "all")]
50    catalog: bool,
51    /// Include unavailable models in output (normally pruned).
52    #[arg(long)]
53    unavailable: bool,
54}
55
56#[derive(Debug, Parser)]
57pub struct ResolveAliasArgs {
58    /// Alias name to resolve.
59    pub name: String,
60    /// Skip automatic models-cache refresh; use whatever's on disk (equivalent to MARS_OFFLINE=1).
61    #[arg(long)]
62    no_refresh_models: bool,
63}
64
65#[derive(Debug, Parser)]
66pub struct AddAliasArgs {
67    /// Alias name.
68    pub name: String,
69    /// Model ID to pin.
70    pub model_id: String,
71    /// Harness for this alias (default: claude).
72    #[arg(long, default_value = "claude")]
73    pub harness: String,
74    /// Optional description.
75    #[arg(long)]
76    pub description: Option<String>,
77}
78
79pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
80    match &args.command {
81        ModelsCommand::Refresh => run_refresh(ctx, json),
82        ModelsCommand::List(args) => run_list(args, ctx, json),
83        ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
84        ModelsCommand::Alias(a) => run_alias(a, ctx, json),
85    }
86}
87
88fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
89    ctx.project_root.join(".mars")
90}
91
92fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
93    let mars = mars_dir(ctx);
94    let ttl = models::load_models_cache_ttl(ctx);
95    eprint!("Fetching models catalog... ");
96
97    let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
98    let count = cache.models.len();
99    let cache_warning = cache_warning(&outcome);
100
101    if let Some(warning) = cache_warning.as_deref() {
102        eprintln!("warning: {warning}");
103    } else if !json {
104        eprintln!("done.");
105    }
106
107    if json {
108        let out = serde_json::json!({
109            "status": "ok",
110            "models_count": count,
111            "fetched_at": cache.fetched_at,
112        });
113        let mut out = out;
114        if let Some(warning) = cache_warning.as_deref() {
115            out["cache_warning"] = serde_json::json!(warning);
116        }
117        println!("{}", serde_json::to_string_pretty(&out).unwrap());
118    } else {
119        if cache_warning.is_some() {
120            println!(
121                "Using stale models cache with {} models in .mars/models-cache.json",
122                count
123            );
124        } else {
125            println!("Cached {} models in .mars/models-cache.json", count);
126        }
127    }
128
129    Ok(0)
130}
131
132fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
133    let mars = mars_dir(ctx);
134    let ttl = models::load_models_cache_ttl(ctx);
135    let mode = models::resolve_refresh_mode(args.no_refresh_models);
136    let Some((cache, outcome)) = ensure_fresh_or_json_error(&mars, ttl, mode, json)? else {
137        return Ok(1);
138    };
139
140    if args.catalog {
141        return run_list_catalog(&cache, &outcome, ctx, args, json);
142    }
143
144    // Load config to get consumer models + trigger merge
145    let merged = load_merged_aliases(ctx)?;
146    let installed = models::harness::detect_installed_harnesses();
147    let is_offline = models::is_mars_offline() || args.no_refresh_models;
148    let probe_result = probe_opencode_if_needed(&installed, is_offline, json);
149    if args.all {
150        let availability_ctx = AvailabilityContext {
151            installed: &installed,
152            probe_result: probe_result.as_ref(),
153            is_offline,
154        };
155        return run_list_all(&merged, &cache, &outcome, ctx, args, availability_ctx, json);
156    }
157
158    let cache_warning = cache_warning(&outcome);
159    let mut diag = DiagnosticCollector::new();
160
161    let mut resolved = models::resolve_all(&merged, &cache, &mut diag);
162    annotate_resolved_availability(&mut resolved, &installed, probe_result.as_ref(), is_offline);
163    if !args.unavailable {
164        prune_unavailable(&mut resolved);
165    }
166
167    // Build effective visibility: CLI overrides config entirely.
168    let visibility = effective_visibility(ctx, args);
169    let resolved = models::filter_by_visibility(resolved, &visibility);
170
171    if json {
172        let entries: Vec<serde_json::Value> = resolved
173            .values()
174            .map(|r| {
175                let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
176                let mut obj = serde_json::json!({
177                    "name": r.name,
178                    "harness": r.harness,
179                    "harness_source": r.harness_source,
180                    "harness_candidates": r.harness_candidates,
181                    "provider": r.provider,
182                    "mode": mode,
183                    "model_id": r.model_id,
184                    "resolved_model": r.model_id,
185                    "description": r.description,
186                });
187                if let Some(error) = unavailable_harness_error(r) {
188                    obj["error"] = serde_json::json!(error);
189                }
190                if let Some(default_effort) = &r.default_effort {
191                    obj["default_effort"] = serde_json::json!(default_effort);
192                }
193                if let Some(autocompact) = r.autocompact {
194                    obj["autocompact"] = serde_json::json!(autocompact);
195                }
196                add_availability_json_fields(&mut obj, r.availability.as_ref());
197                obj
198            })
199            .collect();
200        let mut out = serde_json::json!({
201            "aliases": entries,
202            "cache_available": cache.fetched_at.is_some(),
203        });
204        add_probe_results_json(&mut out, probe_result.as_ref());
205        if let Some(warning) = cache_warning.as_deref() {
206            out["cache_warning"] = serde_json::json!(warning);
207        }
208        if let Some(diagnostics) = drain_diagnostics_json(&mut diag) {
209            out["diagnostics"] = diagnostics;
210        }
211        println!("{}", serde_json::to_string_pretty(&out).unwrap());
212    } else {
213        if let Some(warning) = cache_warning.as_deref() {
214            eprintln!("warning: {warning}");
215        }
216        // Table output
217        println!(
218            "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
219            "ALIAS", "HARNESS", "MODE", "RESOLVED", "AVAILABILITY", "DESCRIPTION"
220        );
221        for r in resolved.values() {
222            let harness = r.harness.as_deref().unwrap_or("—");
223            let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
224            let availability = availability_status_label(r.availability.as_ref());
225            let desc = r.description.clone().unwrap_or_default();
226            println!(
227                "{:<12} {:<10} {:<14} {:<30} {:<12} {}",
228                r.name, harness, mode, r.model_id, availability, desc
229            );
230        }
231        emit_text_diagnostics(&mut diag);
232    }
233
234    Ok(0)
235}
236
237#[derive(Debug, Clone)]
238struct ListModelEntry {
239    id: String,
240    provider: String,
241    release_date: Option<String>,
242    harness: Option<String>,
243    harness_source: HarnessSource,
244    harness_candidates: Vec<String>,
245    description: Option<String>,
246    matched_aliases: Vec<String>,
247    availability: Option<ModelAvailability>,
248}
249
250#[derive(Clone, Copy)]
251struct AvailabilityContext<'a> {
252    installed: &'a HashSet<String>,
253    probe_result: Option<&'a OpenCodeProbeResult>,
254    is_offline: bool,
255}
256
257fn run_list_all(
258    merged: &IndexMap<String, ModelAlias>,
259    cache: &models::ModelsCache,
260    outcome: &models::RefreshOutcome,
261    ctx: &MarsContext,
262    args: &ListArgs,
263    availability_ctx: AvailabilityContext<'_>,
264    json: bool,
265) -> Result<i32, MarsError> {
266    let cache_warning = cache_warning(outcome);
267    let visibility = effective_visibility(ctx, args);
268    let models = collect_all_model_entries(
269        merged,
270        cache,
271        availability_ctx.installed,
272        availability_ctx.probe_result,
273        availability_ctx.is_offline,
274    );
275    let models = filter_model_entries_by_visibility(models, &visibility);
276
277    if json {
278        let entries: Vec<serde_json::Value> = models
279            .into_iter()
280            .map(|model| {
281                let mut obj = serde_json::json!({
282                    "id": model.id,
283                    "provider": model.provider,
284                    "release_date": model.release_date,
285                    "harness": model.harness,
286                    "harness_source": model.harness_source,
287                    "harness_candidates": model.harness_candidates,
288                    "description": model.description,
289                    "matched_aliases": model.matched_aliases,
290                });
291                add_availability_json_fields(&mut obj, model.availability.as_ref());
292                obj
293            })
294            .collect();
295        let mut out = serde_json::json!({
296            "models": entries,
297            "cache_available": cache.fetched_at.is_some(),
298        });
299        add_probe_results_json(&mut out, availability_ctx.probe_result);
300        if let Some(warning) = cache_warning.as_deref() {
301            out["cache_warning"] = serde_json::json!(warning);
302        }
303        println!("{}", serde_json::to_string_pretty(&out).unwrap());
304    } else {
305        if let Some(warning) = cache_warning.as_deref() {
306            eprintln!("warning: {warning}");
307        }
308        println!(
309            "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
310            "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY", "ALIASES"
311        );
312        for model in models {
313            let release = model.release_date.as_deref().unwrap_or("—");
314            let harness = model.harness.as_deref().unwrap_or("—");
315            let availability = availability_status_label(model.availability.as_ref());
316            println!(
317                "{:<10} {:<34} {:<12} {:<10} {:<12} {}",
318                model.provider,
319                model.id,
320                release,
321                harness,
322                availability,
323                model.matched_aliases.join(",")
324            );
325        }
326    }
327
328    Ok(0)
329}
330
331fn run_list_catalog(
332    cache: &models::ModelsCache,
333    outcome: &models::RefreshOutcome,
334    ctx: &MarsContext,
335    args: &ListArgs,
336    json: bool,
337) -> Result<i32, MarsError> {
338    let cache_warning = cache_warning(outcome);
339    let installed = models::harness::detect_installed_harnesses();
340    let is_offline = models::is_mars_offline() || args.no_refresh_models;
341    let probe_result = probe_opencode_if_needed(&installed, is_offline, json);
342    let visibility = effective_visibility(ctx, args);
343    let models =
344        collect_catalog_model_entries(cache, &installed, probe_result.as_ref(), is_offline);
345    let models = filter_model_entries_by_visibility(models, &visibility);
346
347    if json {
348        let entries: Vec<serde_json::Value> = models
349            .into_iter()
350            .map(|model| {
351                let mut obj = serde_json::json!({
352                    "id": model.id,
353                    "provider": model.provider,
354                    "release_date": model.release_date,
355                    "harness": model.harness,
356                    "harness_source": model.harness_source,
357                    "harness_candidates": model.harness_candidates,
358                    "description": model.description,
359                });
360                add_availability_json_fields(&mut obj, model.availability.as_ref());
361                obj
362            })
363            .collect();
364        let mut out = serde_json::json!({
365            "models": entries,
366            "cache_available": cache.fetched_at.is_some(),
367        });
368        add_probe_results_json(&mut out, probe_result.as_ref());
369        if let Some(warning) = cache_warning.as_deref() {
370            out["cache_warning"] = serde_json::json!(warning);
371        }
372        println!("{}", serde_json::to_string_pretty(&out).unwrap());
373    } else {
374        if let Some(warning) = cache_warning.as_deref() {
375            eprintln!("warning: {warning}");
376        }
377        println!(
378            "{:<10} {:<34} {:<12} {:<10} {:<12}",
379            "PROVIDER", "MODEL ID", "RELEASE", "HARNESS", "AVAILABILITY"
380        );
381        for model in models {
382            let release = model.release_date.as_deref().unwrap_or("—");
383            let harness = model.harness.as_deref().unwrap_or("—");
384            let availability = availability_status_label(model.availability.as_ref());
385            println!(
386                "{:<10} {:<34} {:<12} {:<10} {:<12}",
387                model.provider, model.id, release, harness, availability
388            );
389        }
390    }
391
392    Ok(0)
393}
394
395fn collect_all_model_entries(
396    merged: &IndexMap<String, ModelAlias>,
397    cache: &models::ModelsCache,
398    installed: &HashSet<String>,
399    probe_result: Option<&OpenCodeProbeResult>,
400    is_offline: bool,
401) -> Vec<ListModelEntry> {
402    let mut by_model_id: IndexMap<String, ListModelEntry> = IndexMap::new();
403
404    for (alias_name, alias) in merged {
405        match &alias.spec {
406            ModelSpec::AutoResolve {
407                provider,
408                match_patterns,
409                exclude_patterns,
410            } => {
411                for matched in
412                    models::auto_resolve_all(provider, match_patterns, exclude_patterns, cache)
413                {
414                    append_alias_match(
415                        &mut by_model_id,
416                        matched,
417                        installed,
418                        probe_result,
419                        is_offline,
420                        alias_name,
421                    );
422                }
423            }
424            ModelSpec::Pinned {
425                model, provider, ..
426            } => {
427                if let Some(matched) = cache
428                    .models
429                    .iter()
430                    .find(|cache_model| cache_model.id == *model)
431                {
432                    append_alias_match(
433                        &mut by_model_id,
434                        matched,
435                        installed,
436                        probe_result,
437                        is_offline,
438                        alias_name,
439                    );
440                } else {
441                    append_pinned_alias_match(
442                        &mut by_model_id,
443                        model,
444                        provider.as_deref(),
445                        alias.description.as_deref(),
446                        AvailabilityContext {
447                            installed,
448                            probe_result,
449                            is_offline,
450                        },
451                        alias_name,
452                    );
453                }
454            }
455            ModelSpec::PinnedWithMatch {
456                model,
457                provider,
458                match_patterns,
459                exclude_patterns,
460            } => {
461                if let Some(matched) = cache
462                    .models
463                    .iter()
464                    .find(|cache_model| cache_model.id == *model)
465                {
466                    append_alias_match(
467                        &mut by_model_id,
468                        matched,
469                        installed,
470                        probe_result,
471                        is_offline,
472                        alias_name,
473                    );
474                } else {
475                    append_pinned_alias_match(
476                        &mut by_model_id,
477                        model,
478                        provider.as_deref(),
479                        alias.description.as_deref(),
480                        AvailabilityContext {
481                            installed,
482                            probe_result,
483                            is_offline,
484                        },
485                        alias_name,
486                    );
487                }
488
489                let provider_for_discovery = provider
490                    .as_deref()
491                    .or_else(|| models::infer_provider_from_model_id(model));
492                if let Some(provider_for_discovery) = provider_for_discovery {
493                    for matched in models::auto_resolve_all(
494                        provider_for_discovery,
495                        match_patterns,
496                        exclude_patterns,
497                        cache,
498                    ) {
499                        append_alias_match(
500                            &mut by_model_id,
501                            matched,
502                            installed,
503                            probe_result,
504                            is_offline,
505                            alias_name,
506                        );
507                    }
508                }
509            }
510        }
511    }
512
513    let mut out: Vec<ListModelEntry> = by_model_id.into_values().collect();
514    sort_list_model_entries(&mut out);
515    out
516}
517
518fn collect_catalog_model_entries(
519    cache: &models::ModelsCache,
520    installed: &HashSet<String>,
521    probe_result: Option<&OpenCodeProbeResult>,
522    is_offline: bool,
523) -> Vec<ListModelEntry> {
524    let mut out: Vec<ListModelEntry> = cache
525        .models
526        .iter()
527        .map(|model| model_entry_for_cached(model, installed, probe_result, is_offline))
528        .collect();
529    sort_list_model_entries(&mut out);
530    out
531}
532
533fn append_alias_match(
534    by_model_id: &mut IndexMap<String, ListModelEntry>,
535    model: &models::CachedModel,
536    installed: &HashSet<String>,
537    probe_result: Option<&OpenCodeProbeResult>,
538    is_offline: bool,
539    alias_name: &str,
540) {
541    let entry = by_model_id
542        .entry(model.id.clone())
543        .or_insert_with(|| model_entry_for_cached(model, installed, probe_result, is_offline));
544
545    append_alias_name(entry, alias_name);
546}
547
548fn append_pinned_alias_match(
549    by_model_id: &mut IndexMap<String, ListModelEntry>,
550    model_id: &str,
551    provider: Option<&str>,
552    description: Option<&str>,
553    availability_ctx: AvailabilityContext<'_>,
554    alias_name: &str,
555) {
556    let entry = by_model_id.entry(model_id.to_string()).or_insert_with(|| {
557        model_entry_for_pinned(
558            model_id,
559            provider,
560            description,
561            availability_ctx.installed,
562            availability_ctx.probe_result,
563            availability_ctx.is_offline,
564        )
565    });
566
567    append_alias_name(entry, alias_name);
568}
569
570fn append_alias_name(entry: &mut ListModelEntry, alias_name: &str) {
571    if !entry
572        .matched_aliases
573        .iter()
574        .any(|existing| existing == alias_name)
575    {
576        entry.matched_aliases.push(alias_name.to_string());
577    }
578}
579
580fn model_entry_for_cached(
581    model: &models::CachedModel,
582    installed: &HashSet<String>,
583    probe_result: Option<&OpenCodeProbeResult>,
584    is_offline: bool,
585) -> ListModelEntry {
586    let harness = models::harness::resolve_harness_for_provider(&model.provider, installed);
587    let harness_source = if harness.is_some() {
588        HarnessSource::AutoDetected
589    } else {
590        HarnessSource::Unavailable
591    };
592
593    ListModelEntry {
594        id: model.id.clone(),
595        provider: model.provider.clone(),
596        release_date: model.release_date.clone(),
597        harness,
598        harness_source,
599        harness_candidates: models::harness::harness_candidates_for_provider(&model.provider),
600        description: model.description.clone(),
601        matched_aliases: Vec::new(),
602        availability: Some(models::availability::classify_model(
603            &model.id,
604            &model.provider,
605            installed,
606            probe_result,
607            is_offline,
608        )),
609    }
610}
611
612fn model_entry_for_pinned(
613    model_id: &str,
614    provider: Option<&str>,
615    description: Option<&str>,
616    installed: &HashSet<String>,
617    probe_result: Option<&OpenCodeProbeResult>,
618    is_offline: bool,
619) -> ListModelEntry {
620    let provider = provider
621        .map(str::to_string)
622        .or_else(|| models::infer_provider_from_model_id(model_id).map(str::to_string))
623        .unwrap_or_else(|| "unknown".to_string());
624    let harness = models::harness::resolve_harness_for_provider(&provider, installed);
625    let harness_source = if harness.is_some() {
626        HarnessSource::AutoDetected
627    } else {
628        HarnessSource::Unavailable
629    };
630
631    ListModelEntry {
632        id: model_id.to_string(),
633        provider: provider.clone(),
634        release_date: None,
635        harness,
636        harness_source,
637        harness_candidates: models::harness::harness_candidates_for_provider(&provider),
638        description: description.map(str::to_string),
639        matched_aliases: Vec::new(),
640        availability: Some(models::availability::classify_model(
641            model_id,
642            &provider,
643            installed,
644            probe_result,
645            is_offline,
646        )),
647    }
648}
649
650fn sort_list_model_entries(entries: &mut [ListModelEntry]) {
651    entries.sort_by(|a, b| {
652        a.provider
653            .to_ascii_lowercase()
654            .cmp(&b.provider.to_ascii_lowercase())
655            .then_with(|| {
656                b.release_date
657                    .as_deref()
658                    .unwrap_or("")
659                    .cmp(a.release_date.as_deref().unwrap_or(""))
660            })
661            .then_with(|| a.id.cmp(&b.id))
662    });
663}
664
665fn effective_visibility(ctx: &MarsContext, args: &ListArgs) -> crate::config::ModelVisibility {
666    if args.include.is_some() || args.exclude.is_some() {
667        return crate::config::ModelVisibility {
668            include: args.include.clone(),
669            exclude: args.exclude.clone(),
670        };
671    }
672
673    crate::config::load(&ctx.project_root)
674        .map(|config| config.settings.model_visibility)
675        .unwrap_or_default()
676}
677
678fn probe_opencode_if_needed(
679    installed: &HashSet<String>,
680    is_offline: bool,
681    json: bool,
682) -> Option<OpenCodeProbeResult> {
683    if is_offline || !installed.contains("opencode") {
684        return None;
685    }
686    if !json {
687        eprint!("Probing OpenCode providers... ");
688    }
689    let probe = models::probes::opencode::probe();
690    if !json {
691        if probe.provider_probe_success {
692            eprintln!(
693                "done ({} providers, {} models)",
694                probe.providers.len(),
695                probe.model_slugs.len()
696            );
697        } else {
698            eprintln!("unknown");
699        }
700    }
701    Some(probe)
702}
703
704fn annotate_resolved_availability(
705    resolved: &mut IndexMap<String, models::ResolvedAlias>,
706    installed: &HashSet<String>,
707    probe_result: Option<&OpenCodeProbeResult>,
708    is_offline: bool,
709) {
710    for alias in resolved.values_mut() {
711        alias.availability = Some(models::availability::classify_model(
712            &alias.model_id,
713            &alias.provider,
714            installed,
715            probe_result,
716            is_offline,
717        ));
718    }
719}
720
721fn prune_unavailable(resolved: &mut IndexMap<String, models::ResolvedAlias>) {
722    resolved.retain(|_, alias| {
723        alias
724            .availability
725            .as_ref()
726            .map(|availability| availability.status != AvailabilityStatus::Unavailable)
727            .unwrap_or(true)
728    });
729}
730
731fn filter_model_entries_by_visibility(
732    entries: Vec<ListModelEntry>,
733    visibility: &crate::config::ModelVisibility,
734) -> Vec<ListModelEntry> {
735    if visibility.include.is_none() && visibility.exclude.is_none() {
736        return entries;
737    }
738
739    entries
740        .into_iter()
741        .filter(|entry| {
742            let paths = entry
743                .availability
744                .as_ref()
745                .map(|availability| availability.runnable_paths.as_slice())
746                .unwrap_or(&[]);
747            let included = visibility.include.as_ref().is_none_or(|includes| {
748                includes.iter().any(|pattern| {
749                    models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
750                })
751            });
752            let excluded = visibility.exclude.as_ref().is_some_and(|excludes| {
753                excludes.iter().any(|pattern| {
754                    models::matches_visibility_pattern(pattern, &entry.id, &entry.provider, paths)
755                })
756            });
757            included && !excluded
758        })
759        .collect()
760}
761
762fn add_availability_json_fields(
763    obj: &mut serde_json::Value,
764    availability: Option<&ModelAvailability>,
765) {
766    if let Some(availability) = availability {
767        obj["availability"] = serde_json::json!(availability.status);
768        obj["availability_source"] = serde_json::json!(availability.source);
769        obj["runnable_paths"] = serde_json::json!(availability.runnable_paths);
770    }
771}
772
773fn add_probe_results_json(out: &mut serde_json::Value, probe_result: Option<&OpenCodeProbeResult>) {
774    if let Some(probe) = probe_result {
775        out["probe_results"] = serde_json::json!({
776            "opencode": {
777                "success": probe.provider_probe_success && probe.model_probe_success,
778                "providers_found": probe.providers.keys().collect::<Vec<_>>(),
779                "models_found": probe.model_slugs.len(),
780            }
781        });
782    }
783}
784
785fn availability_status_label(availability: Option<&ModelAvailability>) -> &'static str {
786    match availability.map(|value| value.status) {
787        Some(AvailabilityStatus::Runnable) => "runnable",
788        Some(AvailabilityStatus::Unavailable) => "unavailable",
789        Some(AvailabilityStatus::Unknown) => "unknown",
790        None => "unknown",
791    }
792}
793
794fn annotate_one_availability(
795    resolved: &mut models::ResolvedAlias,
796    args: &ResolveAliasArgs,
797    probe_result: Option<&OpenCodeProbeResult>,
798) {
799    let installed = models::harness::detect_installed_harnesses();
800    let is_offline = models::is_mars_offline() || args.no_refresh_models;
801    let owned_probe = if probe_result.is_none() && !is_offline && installed.contains("opencode") {
802        Some(models::probes::opencode::probe())
803    } else {
804        None
805    };
806    let probe_result = probe_result.or(owned_probe.as_ref());
807    resolved.availability = Some(models::availability::classify_model(
808        &resolved.model_id,
809        &resolved.provider,
810        &installed,
811        probe_result,
812        is_offline,
813    ));
814}
815
816fn print_availability_text(availability: Option<&ModelAvailability>) {
817    if let Some(availability) = availability {
818        println!(
819            "Availability: {} ({:?})",
820            availability_status_label(Some(availability)),
821            availability.source
822        );
823        for (idx, path) in availability.runnable_paths.iter().enumerate() {
824            let label = if idx == 0 {
825                "Runnable via:"
826            } else {
827                "             "
828            };
829            println!("{label} {} -> {}", path.harness, path.harness_model_id);
830        }
831    }
832}
833
834fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
835    let merged = load_merged_aliases(ctx)?;
836    let mars = mars_dir(ctx);
837    let ttl = models::load_models_cache_ttl(ctx);
838    let mode = models::resolve_refresh_mode(args.no_refresh_models);
839
840    // Cache is enrichment, not a gate. If unavailable, skip to passthrough.
841    let cache_result = ensure_fresh_or_json_error(&mars, ttl, mode, json)?;
842
843    if let Some((cache, outcome)) = &cache_result {
844        // Step 1: exact alias lookup
845        if let Some(alias) = merged.get(&args.name) {
846            return run_resolve_exact_alias(args, alias, &merged, ctx, cache, outcome, json);
847        }
848
849        // Step 2: alias-prefix resolution
850        if let Some(mut resolved) = models::resolve_with_alias_prefix(&args.name, &merged, cache) {
851            annotate_one_availability(&mut resolved, args, None);
852            return run_output_resolved(&args.name, &resolved, "alias_prefix", outcome, json);
853        }
854    }
855
856    // Step 3: passthrough — no cache needed
857    let outcome = cache_result
858        .as_ref()
859        .map(|(_, o)| o.clone())
860        .unwrap_or(models::RefreshOutcome::Offline);
861    let is_offline = models::is_mars_offline() || args.no_refresh_models;
862    run_output_passthrough(&args.name, &outcome, is_offline, json)
863}
864
865fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
866    let mut config = crate::config::load(&ctx.project_root)?;
867    config.models.insert(
868        args.name.clone(),
869        ModelAlias {
870            harness: Some(args.harness.clone()),
871            description: args.description.clone(),
872            default_effort: None,
873            autocompact: None,
874            spec: ModelSpec::Pinned {
875                model: args.model_id.clone(),
876                provider: None,
877            },
878        },
879    );
880    crate::config::save(&ctx.project_root, &config)?;
881
882    if json {
883        println!(
884            "{}",
885            serde_json::to_string_pretty(&serde_json::json!({
886                "status": "ok",
887                "alias": args.name,
888                "model": args.model_id,
889                "harness": args.harness,
890            }))
891            .unwrap()
892        );
893    } else {
894        println!(
895            "Added alias `{}` → {} (harness: {})",
896            args.name, args.model_id, args.harness
897        );
898    }
899
900    Ok(0)
901}
902
903fn ensure_fresh_or_json_error(
904    mars: &std::path::Path,
905    ttl: u32,
906    mode: models::RefreshMode,
907    json: bool,
908) -> Result<Option<(models::ModelsCache, models::RefreshOutcome)>, MarsError> {
909    match models::ensure_fresh(mars, ttl, mode) {
910        Ok(ok) => Ok(Some(ok)),
911        Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
912            println!(
913                "{}",
914                serde_json::to_string_pretty(&serde_json::json!({
915                    "error": format!("{err}"),
916                }))
917                .unwrap()
918            );
919            Ok(None)
920        }
921        Err(err) => Err(err),
922    }
923}
924
925fn run_resolve_exact_alias(
926    args: &ResolveAliasArgs,
927    alias: &ModelAlias,
928    merged: &IndexMap<String, ModelAlias>,
929    ctx: &MarsContext,
930    cache: &models::ModelsCache,
931    outcome: &models::RefreshOutcome,
932    json: bool,
933) -> Result<i32, MarsError> {
934    let cache_warning = cache_warning(outcome);
935    if let Some(warning) = cache_warning.as_deref()
936        && !json
937    {
938        eprintln!("warning: {warning}");
939    }
940
941    let name = &args.name;
942    let source = determine_source(name, ctx)?;
943    let mut diag = DiagnosticCollector::new();
944    let mut resolved_entry = models::resolve_one(name, merged, cache, &mut diag);
945    if let Some(r) = resolved_entry.as_mut() {
946        annotate_one_availability(r, args, None);
947    }
948    let diagnostics = diag.drain();
949
950    if json {
951        if let Some(r) = resolved_entry.as_ref() {
952            let mut out = serde_json::json!({
953                "name": r.name,
954                "source": source,
955                "provider": r.provider,
956                "harness": r.harness,
957                "harness_source": r.harness_source,
958                "harness_candidates": r.harness_candidates,
959                "model_id": r.model_id,
960                "resolved_model": r.model_id,
961                "spec": format_spec(&alias.spec),
962                "description": r.description,
963            });
964            if let Some(error) = unavailable_harness_error(r) {
965                out["error"] = serde_json::json!(error);
966            }
967            if let Some(default_effort) = &r.default_effort {
968                out["default_effort"] = serde_json::json!(default_effort);
969            }
970            if let Some(autocompact) = r.autocompact {
971                out["autocompact"] = serde_json::json!(autocompact);
972            }
973            add_availability_json_fields(&mut out, r.availability.as_ref());
974            if let Some(warning) = cache_warning.as_deref() {
975                out["cache_warning"] = serde_json::json!(warning);
976            }
977            if !diagnostics.is_empty() {
978                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
979            }
980            println!("{}", serde_json::to_string_pretty(&out).unwrap());
981        } else {
982            let mut out = serde_json::json!({
983                "error": format!("alias `{}` did not resolve to a model ID", name),
984            });
985            if let Some(warning) = cache_warning.as_deref() {
986                out["cache_warning"] = serde_json::json!(warning);
987            }
988            if !diagnostics.is_empty() {
989                out["diagnostics"] = serde_json::json!(diagnostics_to_json_entries(&diagnostics));
990            }
991            println!("{}", serde_json::to_string_pretty(&out).unwrap());
992            return Ok(1);
993        }
994    } else {
995        let Some(r) = resolved_entry.as_ref() else {
996            eprintln!("error: alias `{}` did not resolve to a model ID", name);
997            return Ok(1);
998        };
999        let harness = r.harness.as_deref().unwrap_or("—");
1000        println!("Alias:    {}", name);
1001        println!("Source:   {}", source);
1002        println!(
1003            "Harness:  {} ({})",
1004            harness,
1005            harness_source_label(&r.harness_source)
1006        );
1007        println!("Provider: {}", r.provider);
1008        match &alias.spec {
1009            ModelSpec::Pinned { model, provider: _ } => {
1010                println!("Mode:     pinned");
1011                println!("Model:    {}", model);
1012            }
1013            ModelSpec::PinnedWithMatch {
1014                model,
1015                provider: _,
1016                match_patterns,
1017                exclude_patterns,
1018            } => {
1019                println!("Mode:     pinned");
1020                println!("Model:    {}", model);
1021                println!("Match:    {}", match_patterns.join(", "));
1022                if !exclude_patterns.is_empty() {
1023                    println!("Exclude:  {}", exclude_patterns.join(", "));
1024                }
1025                println!("Resolved: {}", r.model_id);
1026            }
1027            ModelSpec::AutoResolve {
1028                provider: _,
1029                match_patterns,
1030                exclude_patterns,
1031            } => {
1032                println!("Mode:     auto-resolve");
1033                println!("Match:    {}", match_patterns.join(", "));
1034                if !exclude_patterns.is_empty() {
1035                    println!("Exclude:  {}", exclude_patterns.join(", "));
1036                }
1037                println!("Resolved: {}", r.model_id);
1038            }
1039        }
1040        if let Some(error) = unavailable_harness_error(r) {
1041            println!("Error:    {}", error);
1042        }
1043        print_availability_text(r.availability.as_ref());
1044        if let Some(desc) = &r.description {
1045            println!("Desc:     {}", desc);
1046        }
1047        emit_drained_text_diagnostics(&diagnostics);
1048    }
1049
1050    Ok(0)
1051}
1052
1053fn run_output_resolved(
1054    name: &str,
1055    resolved: &models::ResolvedAlias,
1056    source: &str,
1057    outcome: &models::RefreshOutcome,
1058    json: bool,
1059) -> Result<i32, MarsError> {
1060    let cache_warning = cache_warning(outcome);
1061    if let Some(warning) = cache_warning.as_deref()
1062        && !json
1063    {
1064        eprintln!("warning: {warning}");
1065    }
1066
1067    if json {
1068        let mut out = serde_json::json!({
1069            "name": name,
1070            "source": source,
1071            "provider": resolved.provider,
1072            "harness": resolved.harness,
1073            "harness_source": resolved.harness_source,
1074            "harness_candidates": resolved.harness_candidates,
1075            "model_id": resolved.model_id,
1076            "resolved_model": resolved.model_id,
1077            "description": resolved.description,
1078        });
1079        if let Some(error) = unavailable_harness_error(resolved) {
1080            out["error"] = serde_json::json!(error);
1081        }
1082        if let Some(default_effort) = &resolved.default_effort {
1083            out["default_effort"] = serde_json::json!(default_effort);
1084        }
1085        if let Some(autocompact) = resolved.autocompact {
1086            out["autocompact"] = serde_json::json!(autocompact);
1087        }
1088        add_availability_json_fields(&mut out, resolved.availability.as_ref());
1089        if let Some(warning) = cache_warning.as_deref() {
1090            out["cache_warning"] = serde_json::json!(warning);
1091        }
1092        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1093    } else {
1094        let harness = resolved.harness.as_deref().unwrap_or("—");
1095        println!("Alias:    {}", name);
1096        println!("Source:   {}", source);
1097        println!(
1098            "Harness:  {} ({})",
1099            harness,
1100            harness_source_label(&resolved.harness_source)
1101        );
1102        println!("Provider: {}", resolved.provider);
1103        println!("Resolved: {}", resolved.model_id);
1104        if let Some(error) = unavailable_harness_error(resolved) {
1105            println!("Error:    {}", error);
1106        }
1107        print_availability_text(resolved.availability.as_ref());
1108        if let Some(desc) = &resolved.description {
1109            println!("Desc:     {}", desc);
1110        }
1111    }
1112
1113    Ok(0)
1114}
1115
1116fn run_output_passthrough(
1117    name: &str,
1118    outcome: &models::RefreshOutcome,
1119    is_offline: bool,
1120    json: bool,
1121) -> Result<i32, MarsError> {
1122    if name.trim().is_empty() {
1123        if json {
1124            println!(
1125                "{}",
1126                serde_json::to_string_pretty(&serde_json::json!({
1127                    "error": "model name cannot be empty"
1128                }))
1129                .unwrap()
1130            );
1131        } else {
1132            eprintln!("error: model name cannot be empty");
1133        }
1134        return Ok(1);
1135    }
1136
1137    let cache_warning = cache_warning(outcome);
1138    if let Some(warning) = cache_warning.as_deref()
1139        && !json
1140    {
1141        eprintln!("warning: {warning}");
1142    }
1143
1144    let installed = models::harness::detect_installed_harnesses();
1145    let guessed_provider = models::infer_provider_from_model_id(name).map(str::to_string);
1146    let harness = guessed_provider
1147        .as_deref()
1148        .and_then(|p| models::harness::resolve_harness_for_provider(p, &installed));
1149    let harness_source = if harness.is_some() {
1150        "pattern_guess"
1151    } else {
1152        "unavailable"
1153    };
1154    let harness_candidates = guessed_provider
1155        .as_deref()
1156        .map(models::harness::harness_candidates_for_provider)
1157        .unwrap_or_default();
1158    let probe_result = if !is_offline && installed.contains("opencode") {
1159        Some(models::probes::opencode::probe())
1160    } else {
1161        None
1162    };
1163    let availability = models::availability::classify_model(
1164        name,
1165        guessed_provider.as_deref().unwrap_or("unknown"),
1166        &installed,
1167        probe_result.as_ref(),
1168        is_offline,
1169    );
1170
1171    let warning = format!(
1172        "model '{}' not found in catalog, passing through to harness",
1173        name
1174    );
1175
1176    if json {
1177        let mut out = serde_json::json!({
1178            "name": name,
1179            "source": "passthrough",
1180            "model_id": name,
1181            "resolved_model": name,
1182            "provider": guessed_provider,
1183            "harness": harness,
1184            "harness_source": harness_source,
1185            "harness_candidates": harness_candidates,
1186            "description": serde_json::Value::Null,
1187            "warning": warning,
1188        });
1189        add_availability_json_fields(&mut out, Some(&availability));
1190        if let Some(warning) = cache_warning.as_deref() {
1191            out["cache_warning"] = serde_json::json!(warning);
1192        }
1193        println!("{}", serde_json::to_string_pretty(&out).unwrap());
1194    } else {
1195        eprintln!("warning: {}", warning);
1196        let h = harness.as_deref().unwrap_or("—");
1197        println!("Model:      {}", name);
1198        println!("Source:     passthrough");
1199        println!("Harness:    {} ({})", h, harness_source);
1200        if let Some(provider) = guessed_provider {
1201            println!("Provider:   {}", provider);
1202        }
1203        if !harness_candidates.is_empty() {
1204            println!("Candidates: {}", harness_candidates.join(", "));
1205        }
1206    }
1207
1208    Ok(0)
1209}
1210
1211// ---------------------------------------------------------------------------
1212// Helpers
1213// ---------------------------------------------------------------------------
1214
1215/// Load model aliases by combining cached dependency aliases with consumer config.
1216fn load_merged_aliases(
1217    ctx: &MarsContext,
1218) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
1219    // Start with builtins (lowest precedence)
1220    let mut merged = models::builtin_aliases();
1221
1222    // Layer dep aliases from cached merge file (overrides builtins)
1223    let mars_dir = ctx.project_root.join(".mars");
1224    let merged_path = mars_dir.join("models-merged.json");
1225    if let Ok(content) = std::fs::read_to_string(&merged_path)
1226        && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
1227    {
1228        for (name, alias) in cached {
1229            merged.insert(name, alias);
1230        }
1231    }
1232
1233    // Layer consumer config on top (highest precedence)
1234    if let Ok(config) = crate::config::load(&ctx.project_root) {
1235        for (name, alias) in &config.models {
1236            merged.insert(name.clone(), alias.clone());
1237        }
1238    }
1239
1240    Ok(merged)
1241}
1242
1243/// Determine which layer provides an alias (consumer or dependency).
1244fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
1245    let config = match crate::config::load(&ctx.project_root) {
1246        Ok(c) => c,
1247        Err(_) => return Ok("unknown".to_string()),
1248    };
1249
1250    if config.models.contains_key(name) {
1251        return Ok("consumer (mars.toml)".to_string());
1252    }
1253
1254    Ok("dependency".to_string())
1255}
1256
1257fn format_spec(spec: &ModelSpec) -> serde_json::Value {
1258    match spec {
1259        ModelSpec::Pinned { model, provider } => {
1260            let mut out = serde_json::json!({ "mode": "pinned", "model": model });
1261            if let Some(provider) = provider {
1262                out["provider"] = serde_json::json!(provider);
1263            }
1264            out
1265        }
1266        ModelSpec::PinnedWithMatch {
1267            model,
1268            provider,
1269            match_patterns,
1270            exclude_patterns,
1271        } => {
1272            let mut out = serde_json::json!({
1273                "mode": "pinned",
1274                "model": model,
1275                "match": match_patterns,
1276                "exclude": exclude_patterns,
1277            });
1278            if let Some(provider) = provider {
1279                out["provider"] = serde_json::json!(provider);
1280            }
1281            out
1282        }
1283        ModelSpec::AutoResolve {
1284            provider,
1285            match_patterns,
1286            exclude_patterns,
1287        } => {
1288            serde_json::json!({
1289                "mode": "auto-resolve",
1290                "provider": provider,
1291                "match": match_patterns,
1292                "exclude": exclude_patterns,
1293            })
1294        }
1295    }
1296}
1297
1298fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
1299    match spec {
1300        Some(ModelSpec::Pinned { .. }) | Some(ModelSpec::PinnedWithMatch { .. }) => "pinned",
1301        Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
1302        None => "unknown",
1303    }
1304}
1305
1306fn harness_source_label(source: &HarnessSource) -> &'static str {
1307    match source {
1308        HarnessSource::Explicit => "explicit",
1309        HarnessSource::AutoDetected => "auto-detected",
1310        HarnessSource::Unavailable => "unavailable",
1311    }
1312}
1313
1314fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
1315    if resolved.harness_source != HarnessSource::Unavailable {
1316        return None;
1317    }
1318    if let Some(h) = &resolved.harness {
1319        Some(format!("Harness '{}' is not installed", h))
1320    } else {
1321        Some(format!(
1322            "No installed harness for provider '{}'. Install one of: {}",
1323            resolved.provider,
1324            resolved.harness_candidates.join(", ")
1325        ))
1326    }
1327}
1328
1329fn stale_warning(reason: &str) -> String {
1330    format!("models cache refresh failed: {reason}; using stale cache")
1331}
1332
1333fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
1334    match outcome {
1335        models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
1336        _ => None,
1337    }
1338}
1339
1340fn diagnostics_to_json_entries(diagnostics: &[Diagnostic]) -> Vec<serde_json::Value> {
1341    diagnostics
1342        .iter()
1343        .map(|diagnostic| {
1344            serde_json::json!({
1345                "level": diagnostic_level_label(diagnostic.level),
1346                "code": diagnostic.code,
1347                "message": diagnostic.message,
1348                "context": diagnostic.context,
1349            })
1350        })
1351        .collect()
1352}
1353
1354fn drain_diagnostics_json(diag: &mut DiagnosticCollector) -> Option<serde_json::Value> {
1355    let diagnostics = diag.drain();
1356    if diagnostics.is_empty() {
1357        None
1358    } else {
1359        Some(serde_json::json!(diagnostics_to_json_entries(&diagnostics)))
1360    }
1361}
1362
1363fn emit_drained_text_diagnostics(diagnostics: &[Diagnostic]) {
1364    for diagnostic in diagnostics {
1365        let label = diagnostic_level_label(diagnostic.level);
1366        eprintln!("{label}: {}", diagnostic.message);
1367    }
1368}
1369
1370fn emit_text_diagnostics(diag: &mut DiagnosticCollector) {
1371    let diagnostics = diag.drain();
1372    emit_drained_text_diagnostics(&diagnostics);
1373}
1374
1375fn diagnostic_level_label(level: DiagnosticLevel) -> &'static str {
1376    match level {
1377        DiagnosticLevel::Error => "error",
1378        DiagnosticLevel::Warning => "warning",
1379        DiagnosticLevel::Info => "info",
1380    }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386    use clap::Parser;
1387    use indexmap::IndexMap;
1388    use tempfile::TempDir;
1389
1390    fn write_mars_toml(temp: &TempDir, contents: &str) {
1391        std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
1392    }
1393
1394    fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
1395        match result {
1396            Ok(code) => code,
1397            Err(err) => err.exit_code(),
1398        }
1399    }
1400
1401    #[test]
1402    fn list_args_parses_no_refresh_models() {
1403        let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
1404        assert!(args.no_refresh_models);
1405    }
1406
1407    #[test]
1408    fn list_args_parses_catalog() {
1409        let args = ListArgs::try_parse_from(["mars", "--catalog"]).unwrap();
1410        assert!(args.catalog);
1411    }
1412
1413    #[test]
1414    fn list_all_and_catalog_conflict() {
1415        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--catalog"]);
1416        assert!(parsed.is_err());
1417    }
1418
1419    #[test]
1420    fn list_all_and_include_can_combine() {
1421        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--all", "--include", "opus"]);
1422        assert!(parsed.is_ok());
1423    }
1424
1425    #[test]
1426    fn list_catalog_and_include_can_combine() {
1427        let parsed = ModelsArgs::try_parse_from(["mars", "list", "--catalog", "--include", "opus"]);
1428        assert!(parsed.is_ok());
1429    }
1430
1431    #[test]
1432    fn resolve_alias_args_parses_no_refresh_models() {
1433        let args =
1434            ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
1435        assert!(args.no_refresh_models);
1436    }
1437
1438    #[test]
1439    fn list_no_refresh_without_cache_is_non_zero() {
1440        let temp = TempDir::new().unwrap();
1441        write_mars_toml(&temp, "[settings]\n");
1442        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1443        let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
1444
1445        let exit = normalized_exit_code(run(&args, &ctx, false));
1446        assert_ne!(exit, 0);
1447    }
1448
1449    #[test]
1450    fn resolve_no_refresh_without_cache_is_non_zero() {
1451        let temp = TempDir::new().unwrap();
1452        write_mars_toml(
1453            &temp,
1454            r#"[settings]
1455
1456[models.opus]
1457harness = "claude"
1458model = "claude-opus-4-6"
1459"#,
1460        );
1461        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1462        let args =
1463            ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
1464
1465        let exit = normalized_exit_code(run(&args, &ctx, false));
1466        assert_ne!(exit, 0);
1467    }
1468
1469    #[test]
1470    fn alias_updates_existing_model_entry() {
1471        let temp = TempDir::new().unwrap();
1472        write_mars_toml(
1473            &temp,
1474            r#"[settings]
1475
1476[models.fast]
1477harness = "claude"
1478model = "claude-3-5-sonnet"
1479description = "Old alias"
1480"#,
1481        );
1482        let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
1483
1484        let args = AddAliasArgs {
1485            name: "fast".to_string(),
1486            model_id: "gpt-5.3-codex".to_string(),
1487            harness: "codex".to_string(),
1488            description: Some("Updated alias".to_string()),
1489        };
1490
1491        let exit = run_alias(&args, &ctx, false).unwrap();
1492        assert_eq!(exit, 0);
1493
1494        let config = crate::config::load(temp.path()).unwrap();
1495        assert_eq!(config.models.len(), 1);
1496
1497        let alias = config.models.get("fast").unwrap();
1498        assert_eq!(alias.harness.as_deref(), Some("codex"));
1499        assert_eq!(alias.description.as_deref(), Some("Updated alias"));
1500        match &alias.spec {
1501            ModelSpec::Pinned { model, provider } => {
1502                assert_eq!(model, "gpt-5.3-codex");
1503                assert_eq!(provider, &None);
1504            }
1505            _ => panic!("expected pinned alias"),
1506        }
1507    }
1508
1509    fn auto_alias(
1510        provider: &str,
1511        match_patterns: &[&str],
1512        exclude_patterns: &[&str],
1513    ) -> ModelAlias {
1514        ModelAlias {
1515            harness: None,
1516            description: None,
1517            default_effort: None,
1518            autocompact: None,
1519            spec: ModelSpec::AutoResolve {
1520                provider: provider.to_string(),
1521                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1522                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1523            },
1524        }
1525    }
1526
1527    fn pinned_with_match_alias(
1528        model: &str,
1529        provider: &str,
1530        match_patterns: &[&str],
1531        exclude_patterns: &[&str],
1532    ) -> ModelAlias {
1533        ModelAlias {
1534            harness: None,
1535            description: None,
1536            default_effort: None,
1537            autocompact: None,
1538            spec: ModelSpec::PinnedWithMatch {
1539                model: model.to_string(),
1540                provider: Some(provider.to_string()),
1541                match_patterns: match_patterns.iter().map(|v| (*v).to_string()).collect(),
1542                exclude_patterns: exclude_patterns.iter().map(|v| (*v).to_string()).collect(),
1543            },
1544        }
1545    }
1546
1547    fn pinned_alias(model: &str) -> ModelAlias {
1548        ModelAlias {
1549            harness: None,
1550            description: None,
1551            default_effort: None,
1552            autocompact: None,
1553            spec: ModelSpec::Pinned {
1554                model: model.to_string(),
1555                provider: None,
1556            },
1557        }
1558    }
1559
1560    fn pinned_alias_with_provider(model: &str, provider: &str) -> ModelAlias {
1561        ModelAlias {
1562            harness: None,
1563            description: None,
1564            default_effort: None,
1565            autocompact: None,
1566            spec: ModelSpec::Pinned {
1567                model: model.to_string(),
1568                provider: Some(provider.to_string()),
1569            },
1570        }
1571    }
1572
1573    fn cached_model(id: &str, provider: &str, release_date: Option<&str>) -> models::CachedModel {
1574        models::CachedModel {
1575            id: id.to_string(),
1576            provider: provider.to_string(),
1577            release_date: release_date.map(|value| value.to_string()),
1578            description: Some(format!("desc-{id}")),
1579            context_window: None,
1580            max_output: None,
1581        }
1582    }
1583
1584    fn cache(models: Vec<models::CachedModel>) -> models::ModelsCache {
1585        models::ModelsCache {
1586            models,
1587            fetched_at: Some("123".to_string()),
1588        }
1589    }
1590
1591    fn installed(names: &[&str]) -> HashSet<String> {
1592        names.iter().map(|name| (*name).to_string()).collect()
1593    }
1594
1595    #[test]
1596    fn list_all_shows_multiple_per_alias() {
1597        let mut merged = IndexMap::new();
1598        merged.insert(
1599            "opus".to_string(),
1600            auto_alias("Anthropic", &["claude-opus-*"], &[]),
1601        );
1602
1603        let models_cache = cache(vec![
1604            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1605            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-01")),
1606        ]);
1607
1608        let installed = installed(&[]);
1609        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1610        assert_eq!(rows.len(), 2);
1611        assert_eq!(rows[0].id, "claude-opus-4-7");
1612        assert_eq!(rows[1].id, "claude-opus-4-6");
1613    }
1614
1615    #[test]
1616    fn list_all_includes_matched_aliases_with_dedup() {
1617        let mut merged = IndexMap::new();
1618        merged.insert(
1619            "opus".to_string(),
1620            auto_alias("Anthropic", &["claude-opus-*"], &[]),
1621        );
1622        merged.insert(
1623            "legacy".to_string(),
1624            auto_alias("Anthropic", &["*4-6"], &[]),
1625        );
1626
1627        let models_cache = cache(vec![cached_model(
1628            "claude-opus-4-6",
1629            "Anthropic",
1630            Some("2026-02-05"),
1631        )]);
1632
1633        let installed = installed(&[]);
1634        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1635        assert_eq!(rows.len(), 1);
1636        assert_eq!(rows[0].id, "claude-opus-4-6");
1637        assert_eq!(rows[0].matched_aliases, vec!["opus", "legacy"]);
1638    }
1639
1640    #[test]
1641    fn list_all_includes_pinned_cache_entries() {
1642        let mut merged = IndexMap::new();
1643        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1644
1645        let models_cache = cache(vec![cached_model(
1646            "gpt-5.3-codex",
1647            "OpenAI",
1648            Some("2026-01-01"),
1649        )]);
1650        let installed = installed(&[]);
1651        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1652        assert_eq!(rows.len(), 1);
1653        assert_eq!(rows[0].id, "gpt-5.3-codex");
1654        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1655    }
1656
1657    #[test]
1658    fn list_all_includes_pinned_cache_miss_entries() {
1659        let mut merged = IndexMap::new();
1660        merged.insert("fixed".to_string(), pinned_alias("gpt-5.3-codex"));
1661
1662        let models_cache = cache(Vec::new());
1663        let installed = installed(&[]);
1664        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1665        assert_eq!(rows.len(), 1);
1666        assert_eq!(rows[0].id, "gpt-5.3-codex");
1667        assert!(rows[0].provider.eq_ignore_ascii_case("openai"));
1668        assert_eq!(rows[0].release_date, None);
1669        assert_eq!(rows[0].matched_aliases, vec!["fixed"]);
1670    }
1671
1672    #[test]
1673    fn list_all_uses_declared_provider_for_pinned_cache_miss_entries() {
1674        let mut merged = IndexMap::new();
1675        merged.insert(
1676            "custom".to_string(),
1677            pinned_alias_with_provider("custom-model-id", "Anthropic"),
1678        );
1679
1680        let models_cache = cache(Vec::new());
1681        let installed = installed(&[]);
1682        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1683        assert_eq!(rows.len(), 1);
1684        assert_eq!(rows[0].id, "custom-model-id");
1685        assert_eq!(rows[0].provider, "Anthropic");
1686        assert_eq!(rows[0].release_date, None);
1687        assert_eq!(rows[0].matched_aliases, vec!["custom"]);
1688    }
1689
1690    #[test]
1691    fn list_all_includes_unavailable_harness_entries() {
1692        let mut merged = IndexMap::new();
1693        merged.insert("x".to_string(), auto_alias("Unknown", &["x-*"], &[]));
1694        let models_cache = cache(vec![cached_model("x-1", "Unknown", Some("2026-01-01"))]);
1695
1696        let installed = installed(&[]);
1697        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1698        assert_eq!(rows.len(), 1);
1699        assert_eq!(rows[0].harness, None);
1700        assert_eq!(rows[0].harness_source, HarnessSource::Unavailable);
1701        assert!(rows[0].harness_candidates.is_empty());
1702    }
1703
1704    #[test]
1705    fn list_catalog_shows_all_cache_sorted() {
1706        let models_cache = cache(vec![
1707            cached_model("gpt-5", "OpenAI", Some("2025-06-01")),
1708            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1709            cached_model("claude-sonnet-4-5", "Anthropic", Some("2025-08-01")),
1710        ]);
1711
1712        let installed = installed(&[]);
1713        let rows = collect_catalog_model_entries(&models_cache, &installed, None, false);
1714        assert_eq!(rows.len(), 3);
1715        assert_eq!(rows[0].id, "claude-opus-4-6");
1716        assert_eq!(rows[1].id, "claude-sonnet-4-5");
1717        assert_eq!(rows[2].id, "gpt-5");
1718    }
1719
1720    #[test]
1721    fn list_all_includes_pinned_with_match_discovery_candidates() {
1722        let mut merged = IndexMap::new();
1723        merged.insert(
1724            "opus".to_string(),
1725            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1726        );
1727        let models_cache = cache(vec![
1728            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1729            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1730        ]);
1731
1732        let installed = installed(&[]);
1733        let rows = collect_all_model_entries(&merged, &models_cache, &installed, None, false);
1734        assert_eq!(rows.len(), 2);
1735        assert_eq!(rows[0].id, "claude-opus-4-7");
1736        assert_eq!(rows[1].id, "claude-opus-4-6");
1737        assert_eq!(rows[0].matched_aliases, vec!["opus"]);
1738        assert_eq!(rows[1].matched_aliases, vec!["opus"]);
1739    }
1740
1741    #[test]
1742    fn resolve_pinned_with_match_uses_model_field() {
1743        let mut merged = IndexMap::new();
1744        merged.insert(
1745            "opus".to_string(),
1746            pinned_with_match_alias("claude-opus-4-6", "Anthropic", &["claude-opus-*"], &[]),
1747        );
1748        let models_cache = cache(vec![
1749            cached_model("claude-opus-4-7", "Anthropic", Some("2026-04-16")),
1750            cached_model("claude-opus-4-6", "Anthropic", Some("2026-02-05")),
1751        ]);
1752        let mut diag = DiagnosticCollector::new();
1753        let resolved = models::resolve_one("opus", &merged, &models_cache, &mut diag).unwrap();
1754        assert_eq!(resolved.model_id, "claude-opus-4-6");
1755        assert!(diag.drain().is_empty());
1756    }
1757}