1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::claude::manifest::{MarketplaceEntry, PluginSource};
12use crate::diagnostic::SchemaDiagnostic;
13use crate::dialect::{Dialect, DialectChoice};
14use crate::discover::{enclosing_generated_dir, payload_paths};
15use crate::dispatch::{AsPlugin, Dispatched, Layout, dispatch};
16pub use crate::dispatch::{Kind, Mirror};
17use crate::item::DefinitionKind;
18use crate::prepare::{Classification, PrepareContext};
19use crate::tree::{SubTree, TreeSource};
20
21#[derive(Debug, Clone, Default, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Hint {
25 #[serde(default)]
27 pub dialect: Option<Dialect>,
28 #[serde(default)]
30 pub subpath: Option<String>,
31 #[serde(default)]
33 pub plugin: Option<PluginHint>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct PluginHint {
39 pub entry: MarketplaceEntry,
40 #[serde(default)]
42 pub at_marketplace_root: bool,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct Inspection {
47 pub kind: Kind,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub dialect: Option<DialectChoice>,
51 pub also_found: Vec<Mirror>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub name: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub version: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub description: Option<String>,
59 pub plugins: Vec<InspectedPlugin>,
61 pub items: Vec<InspectedItem>,
63 pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct InspectedPlugin {
68 pub entry: MarketplaceEntry,
69 pub at_marketplace_root: bool,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub items: Option<Vec<InspectedItem>>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub refused: Option<String>,
77 pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
81#[serde(rename_all = "lowercase")]
82pub enum InspectedKind {
83 Skill,
84 Agent,
85 Command,
87}
88
89impl From<DefinitionKind> for InspectedKind {
90 fn from(kind: DefinitionKind) -> Self {
91 match kind {
92 DefinitionKind::Skill => Self::Skill,
93 DefinitionKind::Agent => Self::Agent,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct InspectedItem {
100 pub kind: InspectedKind,
101 pub slug: String,
102 pub source_path: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub skill_root: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub output_path: Option<String>,
110 #[serde(skip_serializing_if = "Vec::is_empty")]
112 pub payload: Vec<String>,
113}
114
115pub fn inspect(source: &impl TreeSource, hint: &Hint) -> Inspection {
116 match hint.subpath.as_deref().filter(|path| !path.is_empty()) {
117 Some(subpath) => inspect_root(&SubTree::new(source, subpath), hint),
118 None => inspect_root(source, hint),
119 }
120}
121
122fn inspect_root(source: &impl TreeSource, hint: &Hint) -> Inspection {
123 let dispatched = dispatch(
124 source,
125 PrepareContext::Package,
126 hint.dialect,
127 hint.plugin.as_ref().map(as_plugin),
128 None,
129 );
130 describe(source, hint.dialect, dispatched)
131}
132
133fn as_plugin(hint: &PluginHint) -> AsPlugin<'_> {
134 AsPlugin {
135 entry: &hint.entry,
136 at_marketplace_root: hint.at_marketplace_root,
137 }
138}
139
140fn describe(
141 source: &impl TreeSource,
142 declared: Option<Dialect>,
143 dispatched: Dispatched,
144) -> Inspection {
145 let mut diagnostics = dispatched.discovery.diagnostics.clone();
146 let items = list_items(source, &dispatched, &mut diagnostics);
147 let (name, version, description) = match &dispatched.layout {
148 Layout::Claude(spec) => (
149 spec.name.clone(),
150 spec.version.clone(),
151 spec.description.clone(),
152 ),
153 _ => (None, None, None),
154 };
155 let plugins = match &dispatched.layout {
156 Layout::Marketplace(marketplace) => marketplace
157 .plugins
158 .iter()
159 .map(|entry| inspect_plugin(source, declared, entry))
160 .collect(),
161 _ => Vec::new(),
162 };
163 Inspection {
164 kind: dispatched.kind(),
165 dialect: dispatched.dialect().cloned(),
166 also_found: dispatched.also_found,
167 name,
168 version,
169 description,
170 plugins,
171 items,
172 diagnostics,
173 }
174}
175
176fn inspect_plugin(
179 source: &impl TreeSource,
180 declared: Option<Dialect>,
181 entry: &MarketplaceEntry,
182) -> InspectedPlugin {
183 let mut plugin = InspectedPlugin {
184 entry: entry.clone(),
185 at_marketplace_root: false,
186 items: None,
187 refused: None,
188 diagnostics: BTreeMap::new(),
189 };
190 match &entry.source {
191 PluginSource::Unsupported { reason } => {
192 plugin.refused = Some(format!("can't import from this source: {reason}"));
193 }
194 PluginSource::Remote { .. } => {}
195 PluginSource::Local { path } => {
196 plugin.at_marketplace_root = path.is_empty();
197 let root = SubTree::new(source, path.clone());
198 if let Some(generated) = enclosing_generated_dir(source, path) {
199 plugin.refused = Some(format!(
200 "`{path}` is inside `{}`, plugin output Mars built, which is never input",
201 if generated.is_empty() {
202 "."
203 } else {
204 &generated
205 }
206 ));
207 return plugin;
208 }
209 if !path.is_empty() && !source.is_dir(path) {
210 plugin.refused = Some(format!("`{path}` does not exist in the marketplace"));
211 return plugin;
212 }
213 let dispatched = dispatch(
214 &root,
215 PrepareContext::Package,
216 declared,
217 Some(AsPlugin {
218 entry,
219 at_marketplace_root: plugin.at_marketplace_root,
220 }),
221 Some(&entry.name),
222 );
223 if dispatched.kind() == Kind::Invalid {
224 plugin.refused = dispatched
225 .discovery
226 .diagnostics
227 .values()
228 .flatten()
229 .find(|diagnostic| diagnostic.is_error())
230 .map(|diagnostic| diagnostic.message.clone())
231 .or_else(|| Some("the plugin's manifest cannot be read".to_owned()));
232 }
233 plugin.items = Some(list_items(&root, &dispatched, &mut plugin.diagnostics));
234 for (path, found) in dispatched.discovery.diagnostics {
235 plugin.diagnostics.entry(path).or_default().extend(found);
236 }
237 }
238 }
239 plugin
240}
241
242pub(crate) fn list_items(
243 source: &impl TreeSource,
244 dispatched: &Dispatched,
245 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
246) -> Vec<InspectedItem> {
247 let discovery = &dispatched.discovery;
248 let mut payload = BTreeMap::<String, Vec<String>>::new();
249 let foreign = matches!(dispatched.classification, Classification::Foreign(_));
250 let by_owner = payload_paths(
251 source,
252 discovery,
253 foreign,
254 discovery.definitions.iter(),
255 diagnostics,
256 );
257 for (path, _) in by_owner {
258 if let Some(owner) = discovery
260 .definitions
261 .iter()
262 .filter_map(|definition| definition.skill_root.as_deref())
263 .filter(|root| root.is_empty() || path.starts_with(&format!("{root}/")))
264 .max_by_key(|root| root.len())
265 {
266 payload.entry(owner.to_owned()).or_default().push(path);
267 }
268 }
269 let mut items = discovery
270 .definitions
271 .iter()
272 .map(|definition| InspectedItem {
273 kind: definition.kind.into(),
274 slug: definition.slug.clone(),
275 source_path: definition.source_path.clone(),
276 skill_root: definition.skill_root.clone(),
277 output_path: Some(definition.output_path.clone()),
278 payload: definition
279 .skill_root
280 .as_ref()
281 .and_then(|root| payload.remove(root))
282 .unwrap_or_default(),
283 })
284 .collect::<Vec<_>>();
285 items.extend(discovery.dropped.iter().map(|command| InspectedItem {
286 kind: InspectedKind::Command,
287 slug: command.slug.clone(),
288 source_path: command.source_path.clone(),
289 skill_root: None,
290 output_path: None,
291 payload: Vec::new(),
292 }));
293 items
294}