Skip to main content

mars_agents/cli/
why.rs

1//! `mars why <name>` — explain why an item is installed.
2
3use std::path::Path;
4
5use serde::Serialize;
6
7use crate::error::MarsError;
8use crate::lock::ItemKind;
9
10use super::output;
11
12/// Arguments for `mars why`.
13#[derive(Debug, clap::Args)]
14pub struct WhyArgs {
15    /// Item name to explain (e.g., "frontend-design" or "coder").
16    pub name: String,
17}
18
19#[derive(Debug, Serialize)]
20struct WhyResult {
21    name: String,
22    kind: String,
23    source: String,
24    version: String,
25    dest_path: String,
26    required_by: Vec<String>,
27}
28
29/// Run `mars why`.
30pub fn run(args: &WhyArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
31    let lock = crate::lock::load(&ctx.managed_root)?;
32
33    // Find the item by name (try matching dest_path, name stem, or skill dir name)
34    let mut found = None;
35    for (dest_path, item) in &lock.items {
36        let name_matches = match item.kind {
37            ItemKind::Agent => {
38                let stem = dest_path
39                    .as_path()
40                    .file_stem()
41                    .map(|s| s.to_string_lossy().to_string())
42                    .unwrap_or_default();
43                stem == args.name || dest_path.to_string() == args.name
44            }
45            ItemKind::Skill => {
46                let dir_name = dest_path
47                    .as_path()
48                    .file_name()
49                    .map(|s| s.to_string_lossy().to_string())
50                    .unwrap_or_default();
51                dir_name == args.name || dest_path.to_string() == args.name
52            }
53        };
54
55        if name_matches {
56            found = Some((dest_path.clone(), item.clone()));
57            break;
58        }
59    }
60
61    let (dest_path, item) = match found {
62        Some(f) => f,
63        None => {
64            return Err(MarsError::Source {
65                source_name: "why".to_string(),
66                message: format!("item `{}` not found in lock file", args.name),
67            });
68        }
69    };
70
71    // Find which agents reference this item (if it's a skill)
72    let required_by = if item.kind == ItemKind::Skill {
73        find_referencing_agents(&ctx.managed_root, &lock, &args.name)
74    } else {
75        Vec::new()
76    };
77
78    let result = WhyResult {
79        name: args.name.clone(),
80        kind: item.kind.to_string(),
81        source: item.source.to_string(),
82        version: item.version.clone().unwrap_or_else(|| "-".to_string()),
83        dest_path: dest_path.to_string(),
84        required_by: required_by.clone(),
85    };
86
87    if json {
88        output::print_json(&result);
89    } else {
90        println!("{} ({})", args.name, item.kind);
91        println!(
92            "  provided by: {}@{}",
93            item.source,
94            item.version.as_deref().unwrap_or("-")
95        );
96        println!("  installed at: {dest_path}");
97        if required_by.is_empty() {
98            println!("  required by: (no dependents)");
99        } else {
100            println!("  required by:");
101            for agent in &required_by {
102                println!("    {agent}");
103            }
104        }
105    }
106
107    Ok(0)
108}
109
110/// Find agents that reference a skill name in their frontmatter.
111fn find_referencing_agents(
112    root: &Path,
113    lock: &crate::lock::LockFile,
114    skill_name: &str,
115) -> Vec<String> {
116    let mut refs = Vec::new();
117
118    for (dest_path, item) in &lock.items {
119        if item.kind != ItemKind::Agent {
120            continue;
121        }
122
123        let agent_path = root.join(dest_path);
124        if let Ok(skills) = crate::validate::parse_agent_skills(&agent_path)
125            && skills.iter().any(|s| s == skill_name)
126        {
127            refs.push(dest_path.to_string());
128        }
129    }
130
131    refs.sort();
132    refs
133}