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