1#![allow(clippy::print_literal)]
3
4use clap::{Parser, Subcommand};
5use indexmap::IndexMap;
6
7use crate::error::MarsError;
8use crate::models::{self, HarnessSource, ModelAlias, ModelSpec};
9use crate::types::MarsContext;
10
11#[derive(Debug, Parser)]
13pub struct ModelsArgs {
14 #[command(subcommand)]
15 pub command: ModelsCommand,
16}
17
18#[derive(Debug, Subcommand)]
19pub enum ModelsCommand {
20 Refresh,
22 List(ListArgs),
24 Resolve(ResolveAliasArgs),
26 Alias(AddAliasArgs),
28}
29
30#[derive(Debug, Parser)]
31pub struct ListArgs {
32 #[arg(long)]
34 all: bool,
35 #[arg(long)]
37 no_refresh_models: bool,
38 #[arg(long, value_delimiter = ',', conflicts_with = "exclude")]
40 include: Option<Vec<String>>,
41 #[arg(long, value_delimiter = ',', conflicts_with = "include")]
43 exclude: Option<Vec<String>>,
44}
45
46#[derive(Debug, Parser)]
47pub struct ResolveAliasArgs {
48 pub name: String,
50 #[arg(long)]
52 no_refresh_models: bool,
53}
54
55#[derive(Debug, Parser)]
56pub struct AddAliasArgs {
57 pub name: String,
59 pub model_id: String,
61 #[arg(long, default_value = "claude")]
63 pub harness: String,
64 #[arg(long)]
66 pub description: Option<String>,
67}
68
69pub fn run(args: &ModelsArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
70 match &args.command {
71 ModelsCommand::Refresh => run_refresh(ctx, json),
72 ModelsCommand::List(args) => run_list(args, ctx, json),
73 ModelsCommand::Resolve(a) => run_resolve(a, ctx, json),
74 ModelsCommand::Alias(a) => run_alias(a, ctx, json),
75 }
76}
77
78fn mars_dir(ctx: &MarsContext) -> std::path::PathBuf {
79 ctx.project_root.join(".mars")
80}
81
82fn run_refresh(ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
83 let mars = mars_dir(ctx);
84 let ttl = models::load_models_cache_ttl(ctx);
85 eprint!("Fetching models catalog... ");
86
87 let (cache, outcome) = models::ensure_fresh(&mars, ttl, models::RefreshMode::Force)?;
88 let count = cache.models.len();
89 let cache_warning = cache_warning(&outcome);
90
91 if let Some(warning) = cache_warning.as_deref() {
92 eprintln!("warning: {warning}");
93 } else if !json {
94 eprintln!("done.");
95 }
96
97 if json {
98 let out = serde_json::json!({
99 "status": "ok",
100 "models_count": count,
101 "fetched_at": cache.fetched_at,
102 });
103 let mut out = out;
104 if let Some(warning) = cache_warning.as_deref() {
105 out["cache_warning"] = serde_json::json!(warning);
106 }
107 println!("{}", serde_json::to_string_pretty(&out).unwrap());
108 } else {
109 if cache_warning.is_some() {
110 println!(
111 "Using stale models cache with {} models in .mars/models-cache.json",
112 count
113 );
114 } else {
115 println!("Cached {} models in .mars/models-cache.json", count);
116 }
117 }
118
119 Ok(0)
120}
121
122fn run_list(args: &ListArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
123 let mars = mars_dir(ctx);
124 let ttl = models::load_models_cache_ttl(ctx);
125 let mode = models::resolve_refresh_mode(args.no_refresh_models);
126 let (cache, outcome) = match models::ensure_fresh(&mars, ttl, mode) {
127 Ok(ok) => ok,
128 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
129 println!(
130 "{}",
131 serde_json::to_string_pretty(&serde_json::json!({
132 "error": format!("{err}"),
133 }))
134 .unwrap()
135 );
136 return Ok(1);
137 }
138 Err(err) => return Err(err),
139 };
140 let cache_warning = cache_warning(&outcome);
141
142 let merged = load_merged_aliases(ctx)?;
144 let resolved = models::resolve_all(&merged, &cache);
145
146 let config_visibility = crate::config::load(&ctx.project_root)
148 .map(|c| c.settings.model_visibility)
149 .unwrap_or_default();
150
151 let visibility = if args.include.is_some() || args.exclude.is_some() {
152 crate::config::ModelVisibility {
153 include: args.include.clone(),
154 exclude: args.exclude.clone(),
155 }
156 } else {
157 config_visibility
158 };
159
160 let resolved = models::filter_by_visibility(resolved, &visibility);
161
162 if json {
163 let entries: Vec<serde_json::Value> = resolved
164 .values()
165 .map(|r| {
166 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
167 let mut obj = serde_json::json!({
168 "name": r.name,
169 "harness": r.harness,
170 "harness_source": r.harness_source,
171 "harness_candidates": r.harness_candidates,
172 "provider": r.provider,
173 "mode": mode,
174 "model_id": r.model_id,
175 "resolved_model": r.model_id,
176 "description": r.description,
177 });
178 if let Some(error) = unavailable_harness_error(r) {
179 obj["error"] = serde_json::json!(error);
180 }
181 obj
182 })
183 .collect();
184 let mut out = serde_json::json!({
185 "aliases": entries,
186 "cache_available": cache.fetched_at.is_some(),
187 });
188 if let Some(warning) = cache_warning.as_deref() {
189 out["cache_warning"] = serde_json::json!(warning);
190 }
191 println!("{}", serde_json::to_string_pretty(&out).unwrap());
192 } else {
193 if let Some(warning) = cache_warning.as_deref() {
194 eprintln!("warning: {warning}");
195 }
196 println!(
198 "{:<12} {:<10} {:<14} {:<30} {}",
199 "ALIAS", "HARNESS", "MODE", "RESOLVED", "DESCRIPTION"
200 );
201 for r in resolved.values() {
202 if !args.all && r.harness_source == HarnessSource::Unavailable {
203 continue;
204 }
205 let harness = r.harness.as_deref().unwrap_or("—");
206 let mode = mode_for_alias(merged.get(&r.name).map(|a| &a.spec));
207 let desc = if r.harness_source == HarnessSource::Unavailable {
208 format!("(install: {})", r.harness_candidates.join(", "))
209 } else {
210 r.description.clone().unwrap_or_default()
211 };
212 println!(
213 "{:<12} {:<10} {:<14} {:<30} {}",
214 r.name, harness, mode, r.model_id, desc
215 );
216 }
217 }
218
219 Ok(0)
220}
221
222fn run_resolve(args: &ResolveAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
223 let merged = load_merged_aliases(ctx)?;
224 let Some(alias) = merged.get(&args.name) else {
225 if json {
226 println!(
227 "{}",
228 serde_json::to_string_pretty(&serde_json::json!({
229 "error": format!("unknown alias: {}", args.name),
230 }))
231 .unwrap()
232 );
233 } else {
234 eprintln!("error: unknown alias `{}`", args.name);
235 }
236 return Ok(1);
237 };
238
239 let mars = mars_dir(ctx);
240 let ttl = models::load_models_cache_ttl(ctx);
241 let mode = models::resolve_refresh_mode(args.no_refresh_models);
242 let (cache, outcome) = match models::ensure_fresh(&mars, ttl, mode) {
243 Ok(ok) => ok,
244 Err(err @ MarsError::ModelCacheUnavailable { .. }) if json => {
245 println!(
246 "{}",
247 serde_json::to_string_pretty(&serde_json::json!({
248 "error": format!("{err}"),
249 }))
250 .unwrap()
251 );
252 return Ok(1);
253 }
254 Err(err) => return Err(err),
255 };
256 let cache_warning = cache_warning(&outcome);
257
258 if let Some(warning) = cache_warning.as_deref()
259 && !json
260 {
261 eprintln!("warning: {warning}");
262 }
263
264 let source = determine_source(&args.name, ctx)?;
266 let resolved_map = models::resolve_all(&merged, &cache);
267 let resolved_entry = resolved_map.get(&args.name);
268
269 if json {
270 if let Some(r) = resolved_entry {
271 let mut out = serde_json::json!({
272 "name": r.name,
273 "source": source,
274 "provider": r.provider,
275 "harness": r.harness,
276 "harness_source": r.harness_source,
277 "harness_candidates": r.harness_candidates,
278 "model_id": r.model_id,
279 "resolved_model": r.model_id,
280 "spec": format_spec(&alias.spec),
281 "description": r.description,
282 });
283 if let Some(error) = unavailable_harness_error(r) {
284 out["error"] = serde_json::json!(error);
285 }
286 if let Some(warning) = cache_warning.as_deref() {
287 out["cache_warning"] = serde_json::json!(warning);
288 }
289 println!("{}", serde_json::to_string_pretty(&out).unwrap());
290 } else {
291 let mut out = serde_json::json!({
292 "error": format!("alias `{}` did not resolve to a model ID", args.name),
293 });
294 if let Some(warning) = cache_warning.as_deref() {
295 out["cache_warning"] = serde_json::json!(warning);
296 }
297 println!("{}", serde_json::to_string_pretty(&out).unwrap());
298 return Ok(1);
299 }
300 } else {
301 let Some(r) = resolved_entry else {
302 eprintln!("error: alias `{}` did not resolve to a model ID", args.name);
303 return Ok(1);
304 };
305 let harness = r.harness.as_deref().unwrap_or("—");
306 println!("Alias: {}", args.name);
307 println!("Source: {}", source);
308 println!(
309 "Harness: {} ({})",
310 harness,
311 harness_source_label(&r.harness_source)
312 );
313 println!("Provider: {}", r.provider);
314 match &alias.spec {
315 ModelSpec::Pinned { model, provider: _ } => {
316 println!("Mode: pinned");
317 println!("Model: {}", model);
318 }
319 ModelSpec::AutoResolve {
320 provider: _,
321 match_patterns,
322 exclude_patterns,
323 } => {
324 println!("Mode: auto-resolve");
325 println!("Match: {}", match_patterns.join(", "));
326 if !exclude_patterns.is_empty() {
327 println!("Exclude: {}", exclude_patterns.join(", "));
328 }
329 println!("Resolved: {}", r.model_id);
330 }
331 }
332 if let Some(error) = unavailable_harness_error(r) {
333 println!("Error: {}", error);
334 }
335 if let Some(desc) = &r.description {
336 println!("Desc: {}", desc);
337 }
338 }
339
340 Ok(0)
341}
342
343fn run_alias(args: &AddAliasArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
344 let mut config = crate::config::load(&ctx.project_root)?;
345 config.models.insert(
346 args.name.clone(),
347 ModelAlias {
348 harness: Some(args.harness.clone()),
349 description: args.description.clone(),
350 spec: ModelSpec::Pinned {
351 model: args.model_id.clone(),
352 provider: None,
353 },
354 },
355 );
356 crate::config::save(&ctx.project_root, &config)?;
357
358 if json {
359 println!(
360 "{}",
361 serde_json::to_string_pretty(&serde_json::json!({
362 "status": "ok",
363 "alias": args.name,
364 "model": args.model_id,
365 "harness": args.harness,
366 }))
367 .unwrap()
368 );
369 } else {
370 println!(
371 "Added alias `{}` → {} (harness: {})",
372 args.name, args.model_id, args.harness
373 );
374 }
375
376 Ok(0)
377}
378
379fn load_merged_aliases(
385 ctx: &MarsContext,
386) -> Result<indexmap::IndexMap<String, ModelAlias>, MarsError> {
387 let mut merged = models::builtin_aliases();
389
390 let mars_dir = ctx.project_root.join(".mars");
392 let merged_path = mars_dir.join("models-merged.json");
393 if let Ok(content) = std::fs::read_to_string(&merged_path)
394 && let Ok(cached) = serde_json::from_str::<IndexMap<String, ModelAlias>>(&content)
395 {
396 for (name, alias) in cached {
397 merged.insert(name, alias);
398 }
399 }
400
401 if let Ok(config) = crate::config::load(&ctx.project_root) {
403 for (name, alias) in &config.models {
404 merged.insert(name.clone(), alias.clone());
405 }
406 }
407
408 Ok(merged)
409}
410
411fn determine_source(name: &str, ctx: &MarsContext) -> Result<String, MarsError> {
413 let config = match crate::config::load(&ctx.project_root) {
414 Ok(c) => c,
415 Err(_) => return Ok("unknown".to_string()),
416 };
417
418 if config.models.contains_key(name) {
419 return Ok("consumer (mars.toml)".to_string());
420 }
421
422 Ok("dependency".to_string())
423}
424
425fn format_spec(spec: &ModelSpec) -> serde_json::Value {
426 match spec {
427 ModelSpec::Pinned { model, provider } => {
428 let mut out = serde_json::json!({ "mode": "pinned", "model": model });
429 if let Some(provider) = provider {
430 out["provider"] = serde_json::json!(provider);
431 }
432 out
433 }
434 ModelSpec::AutoResolve {
435 provider,
436 match_patterns,
437 exclude_patterns,
438 } => serde_json::json!({
439 "mode": "auto-resolve",
440 "provider": provider,
441 "match": match_patterns,
442 "exclude": exclude_patterns,
443 }),
444 }
445}
446
447fn mode_for_alias(spec: Option<&ModelSpec>) -> &'static str {
448 match spec {
449 Some(ModelSpec::Pinned { .. }) => "pinned",
450 Some(ModelSpec::AutoResolve { .. }) => "auto-resolve",
451 None => "unknown",
452 }
453}
454
455fn harness_source_label(source: &HarnessSource) -> &'static str {
456 match source {
457 HarnessSource::Explicit => "explicit",
458 HarnessSource::AutoDetected => "auto-detected",
459 HarnessSource::Unavailable => "unavailable",
460 }
461}
462
463fn unavailable_harness_error(resolved: &models::ResolvedAlias) -> Option<String> {
464 if resolved.harness_source != HarnessSource::Unavailable {
465 return None;
466 }
467 if let Some(h) = &resolved.harness {
468 Some(format!("Harness '{}' is not installed", h))
469 } else {
470 Some(format!(
471 "No installed harness for provider '{}'. Install one of: {}",
472 resolved.provider,
473 resolved.harness_candidates.join(", ")
474 ))
475 }
476}
477
478fn stale_warning(reason: &str) -> String {
479 format!("models cache refresh failed: {reason}; using stale cache")
480}
481
482fn cache_warning(outcome: &models::RefreshOutcome) -> Option<String> {
483 match outcome {
484 models::RefreshOutcome::StaleFallback { reason } => Some(stale_warning(reason)),
485 _ => None,
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use clap::Parser;
493 use tempfile::TempDir;
494
495 fn write_mars_toml(temp: &TempDir, contents: &str) {
496 std::fs::write(temp.path().join("mars.toml"), contents).unwrap();
497 }
498
499 fn normalized_exit_code(result: Result<i32, MarsError>) -> i32 {
500 match result {
501 Ok(code) => code,
502 Err(err) => err.exit_code(),
503 }
504 }
505
506 #[test]
507 fn list_args_parses_no_refresh_models() {
508 let args = ListArgs::try_parse_from(["mars", "--no-refresh-models"]).unwrap();
509 assert!(args.no_refresh_models);
510 }
511
512 #[test]
513 fn resolve_alias_args_parses_no_refresh_models() {
514 let args =
515 ResolveAliasArgs::try_parse_from(["mars", "opus", "--no-refresh-models"]).unwrap();
516 assert!(args.no_refresh_models);
517 }
518
519 #[test]
520 fn list_no_refresh_without_cache_is_non_zero() {
521 let temp = TempDir::new().unwrap();
522 write_mars_toml(&temp, "[settings]\n");
523 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
524 let args = ModelsArgs::try_parse_from(["mars", "list", "--no-refresh-models"]).unwrap();
525
526 let exit = normalized_exit_code(run(&args, &ctx, false));
527 assert_ne!(exit, 0);
528 }
529
530 #[test]
531 fn resolve_no_refresh_without_cache_is_non_zero() {
532 let temp = TempDir::new().unwrap();
533 write_mars_toml(
534 &temp,
535 r#"[settings]
536
537[models.opus]
538harness = "claude"
539model = "claude-opus-4-6"
540"#,
541 );
542 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
543 let args =
544 ModelsArgs::try_parse_from(["mars", "resolve", "opus", "--no-refresh-models"]).unwrap();
545
546 let exit = normalized_exit_code(run(&args, &ctx, false));
547 assert_ne!(exit, 0);
548 }
549
550 #[test]
551 fn alias_updates_existing_model_entry() {
552 let temp = TempDir::new().unwrap();
553 write_mars_toml(
554 &temp,
555 r#"[settings]
556
557[models.fast]
558harness = "claude"
559model = "claude-3-5-sonnet"
560description = "Old alias"
561"#,
562 );
563 let ctx = MarsContext::new(temp.path().to_path_buf()).unwrap();
564
565 let args = AddAliasArgs {
566 name: "fast".to_string(),
567 model_id: "gpt-5.3-codex".to_string(),
568 harness: "codex".to_string(),
569 description: Some("Updated alias".to_string()),
570 };
571
572 let exit = run_alias(&args, &ctx, false).unwrap();
573 assert_eq!(exit, 0);
574
575 let config = crate::config::load(temp.path()).unwrap();
576 assert_eq!(config.models.len(), 1);
577
578 let alias = config.models.get("fast").unwrap();
579 assert_eq!(alias.harness.as_deref(), Some("codex"));
580 assert_eq!(alias.description.as_deref(), Some("Updated alias"));
581 match &alias.spec {
582 ModelSpec::Pinned { model, provider } => {
583 assert_eq!(model, "gpt-5.3-codex");
584 assert_eq!(provider, &None);
585 }
586 _ => panic!("expected pinned alias"),
587 }
588 }
589}