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