Skip to main content

mars_schema/
inspect.rs

1//! `inspect`: what is in a repository, before anything is selected or converted.
2//!
3//! Pure over a file tree. Reads manifests, listings and the first lines of definitions, never
4//! payload bytes. A marketplace lists its plugins; a plugin it points at in another
5//! repository is fetched by the caller and inspected on its own, with the entry as the hint.
6
7use 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/// What the caller declares about a root; everything is optional.
22#[derive(Debug, Clone, Default, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Hint {
25    /// Overrides prediction, never a Mars manifest.
26    #[serde(default)]
27    pub dialect: Option<Dialect>,
28    /// The package root inside the tree.
29    #[serde(default)]
30    pub subpath: Option<String>,
31    /// Read the root as this plugin of a marketplace (a fetched remote plugin tree).
32    #[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    /// Whether the entry's source is the marketplace root, which narrows the default skills.
41    #[serde(default)]
42    pub at_marketplace_root: bool,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct Inspection {
47    pub kind: Kind,
48    /// The dialect read, with how it was chosen and the files that decided it.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub dialect: Option<DialectChoice>,
51    /// Other harnesses' copies of the package, found and not read.
52    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    /// A marketplace's plugins.
60    pub plugins: Vec<InspectedPlugin>,
61    /// The items of a plugin or skill tree.
62    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    /// The items of a plugin in the marketplace's own tree. A remote plugin has none until the
71    /// caller fetches it and inspects the tree.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub items: Option<Vec<InspectedItem>>,
74    /// Why the plugin cannot be imported at all.
75    #[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    /// Reported and not imported.
86    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    /// The definition file (`…/SKILL.md`, `agents/x.md`, `commands/x.md`).
103    pub source_path: String,
104    /// The skill's directory, whose files travel with it.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub skill_root: Option<String>,
107    /// Where the definition lands in schema v1; commands have none.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub output_path: Option<String>,
110    /// A skill's payload files, by source path.
111    #[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
176/// A marketplace plugin: refused when its source cannot be imported, listed with its items
177/// when it lives in the marketplace's own tree.
178fn 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        // The owner is the deepest skill root containing the file.
259        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}