Skip to main content

mars_schema/
normalize.rs

1//! `normalize`: turn a selection of a repository into schema v1 packages.
2//!
3//! Each package is the [`Prepared`] value that `prepare` returns for one root, with the root
4//! read under Claude's rules (relocated, translated), a generated `mars.toml`, and provenance.
5//! A Mars package is the identity case: its own manifest, its own paths.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use crate::claude::manifest::{
12    MARKETPLACE_MANIFEST, MarketplaceEntry, PLUGIN_MANIFEST, PluginSource,
13};
14use crate::diagnostic::{SchemaDiagnostic, Severity};
15use crate::dialect::Dialect;
16use crate::discover::enclosing_generated_dir;
17use crate::dispatch::{AsPlugin, Layout};
18use crate::inspect::{Hint, Kind};
19use crate::prepare::{
20    ChangeRecord, Classification, PrepareContext, Prepared, PreparedManifest, RootOptions, Rooted,
21    prepare_root,
22};
23use crate::tree::{SubTree, TreeSource};
24
25/// Where a tree came from; names and provenance are derived from it.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct SourceInfo {
29    /// The repository's name, used when nothing in the package names it.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub name: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub url: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub requested_ref: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub resolved_commit: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub subpath: Option<String>,
40}
41
42/// What the caller chose from an inspection. An absent choice takes everything found.
43#[derive(Debug, Clone, Default, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Selection {
46    /// Marketplace plugin names.
47    #[serde(default)]
48    pub plugins: Option<Vec<String>>,
49    /// Definition source paths by plugin name; the key `""` is a root that is not a
50    /// marketplace plugin.
51    #[serde(default)]
52    pub items: BTreeMap<String, Vec<String>>,
53}
54
55pub struct NormalizeRequest<'a, T: TreeSource> {
56    pub tree: &'a T,
57    pub source: SourceInfo,
58    pub hint: Hint,
59    pub selection: Selection,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct Normalized {
64    pub kind: Kind,
65    pub packages: Vec<NormalizedPackage>,
66    /// Marketplace-level findings and the reasons a package was left out, by path.
67    pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
68}
69
70impl Normalized {
71    /// Add each package's link verdict ([`crate::links::package`]). Normalizing leaves it out
72    /// because the binary judges a dependency's links against what the consumer installs; the
73    /// WASM export, which has no consumer, adds it.
74    pub fn report_links(&mut self) {
75        for package in &mut self.packages {
76            crate::links::package(&mut package.prepared);
77        }
78    }
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct NormalizedPackage {
83    /// The plugin name for a marketplace plugin, else the package name.
84    pub id: String,
85    pub provenance: Provenance,
86    pub prepared: Prepared,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct Provenance {
91    /// The version of the rules that produced these files.
92    pub translator: &'static str,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub dialect: Option<Dialect>,
95    pub source: SourceInfo,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub marketplace: Option<MarketplaceProvenance>,
98    /// The definition source paths imported.
99    pub selected_items: Vec<String>,
100}
101
102#[derive(Debug, Clone, Serialize)]
103pub struct MarketplaceProvenance {
104    pub plugin: String,
105    pub plugin_source: PluginSource,
106}
107
108pub fn normalize<T: TreeSource>(request: &NormalizeRequest<'_, T>) -> Normalized {
109    match request
110        .hint
111        .subpath
112        .as_deref()
113        .filter(|path| !path.is_empty())
114    {
115        Some(subpath) => normalize_root(&SubTree::new(request.tree, subpath), request),
116        None => normalize_root(request.tree, request),
117    }
118}
119
120fn item_set(selection: &Selection, plugin: &str) -> Option<BTreeSet<String>> {
121    selection
122        .items
123        .get(plugin)
124        .map(|paths| paths.iter().cloned().collect())
125}
126
127/// Where a package sits in a marketplace, when it does.
128struct Origin<'a> {
129    entry: &'a MarketplaceEntry,
130}
131
132/// A repository that names plugins but is no plugin itself. A remote plugin source must not be
133/// one: its plugins would be a second marketplace the dependency never chose from.
134fn is_nested_marketplace(tree: &impl TreeSource) -> bool {
135    tree.is_file(MARKETPLACE_MANIFEST) && !tree.is_file(PLUGIN_MANIFEST)
136}
137
138const NESTED_MARKETPLACE: &str = "is itself a marketplace, which cannot be imported as a plugin";
139
140fn normalize_root<T: TreeSource>(
141    root: &impl TreeSource,
142    request: &NormalizeRequest<'_, T>,
143) -> Normalized {
144    let hint_plugin = request.hint.plugin.as_ref();
145    if let Some(plugin) = hint_plugin
146        && matches!(plugin.entry.source, PluginSource::Remote { .. })
147        && is_nested_marketplace(root)
148    {
149        return Normalized {
150            kind: Kind::Marketplace,
151            packages: Vec::new(),
152            diagnostics: BTreeMap::from([(
153                MARKETPLACE_MANIFEST.to_owned(),
154                vec![SchemaDiagnostic::new(
155                    "marketplace.nested-marketplace",
156                    Severity::Error,
157                    Vec::new(),
158                    format!("plugin `{}` {NESTED_MARKETPLACE}", plugin.entry.name),
159                )],
160            )]),
161        };
162    }
163    let items = item_set(&request.selection, "");
164    let rooted = prepare_root(
165        root,
166        PrepareContext::Package,
167        request.hint.dialect,
168        RootOptions {
169            as_plugin: hint_plugin.map(|plugin| AsPlugin {
170                entry: &plugin.entry,
171                at_marketplace_root: plugin.at_marketplace_root,
172            }),
173            selection: items.as_ref(),
174            fallback_name: request.source.name.as_deref(),
175            ..RootOptions::default()
176        },
177    );
178    let mut normalized = Normalized {
179        kind: rooted.dispatched.kind(),
180        packages: Vec::new(),
181        diagnostics: BTreeMap::new(),
182    };
183    if let Layout::Marketplace(marketplace) = &rooted.dispatched.layout {
184        normalized.diagnostics = rooted.prepared.diagnostics.clone();
185        for entry in &marketplace.plugins {
186            let named = request
187                .selection
188                .plugins
189                .as_ref()
190                .map(|names| names.contains(&entry.name));
191            if named != Some(false) {
192                normalize_plugin(root, request, entry, named == Some(true), &mut normalized);
193            }
194        }
195        return normalized;
196    }
197    let origin = hint_plugin.map(|plugin| Origin {
198        entry: &plugin.entry,
199    });
200    finish_package(rooted, request, origin, &mut normalized);
201    normalized
202}
203
204fn normalize_plugin<T: TreeSource>(
205    marketplace_root: &impl TreeSource,
206    request: &NormalizeRequest<'_, T>,
207    entry: &MarketplaceEntry,
208    named: bool,
209    normalized: &mut Normalized,
210) {
211    // A plugin the caller asked for by name must work; one taken along with all of them need not.
212    let severity = if named {
213        Severity::Error
214    } else {
215        Severity::Warning
216    };
217    let mut report = |severity, code: &str, message: String| {
218        normalized
219            .diagnostics
220            .entry(MARKETPLACE_MANIFEST.to_owned())
221            .or_default()
222            .push(SchemaDiagnostic::new(
223                code,
224                severity,
225                Vec::new(),
226                format!("plugin `{}`: {message}", entry.name),
227            ));
228    };
229    let items = item_set(&request.selection, &entry.name);
230    let options = |at_marketplace_root| RootOptions {
231        as_plugin: Some(AsPlugin {
232            entry,
233            at_marketplace_root,
234        }),
235        selection: items.as_ref(),
236        fallback_name: Some(&entry.name),
237        ..RootOptions::default()
238    };
239    let rooted = match &entry.source {
240        PluginSource::Unsupported { reason } => {
241            report(
242                severity,
243                "marketplace.plugin-unsupported",
244                format!("can't import from this source: {reason}"),
245            );
246            return;
247        }
248        PluginSource::Local { path } => {
249            if let Some(generated) = enclosing_generated_dir(marketplace_root, path) {
250                report(
251                    Severity::Warning,
252                    "marketplace.plugin-generated",
253                    format!(
254                        "`{path}` is inside `{}`, plugin output Mars built, which is never input",
255                        if generated.is_empty() {
256                            "."
257                        } else {
258                            &generated
259                        }
260                    ),
261                );
262                return;
263            }
264            if !path.is_empty() && !marketplace_root.is_dir(path) {
265                report(
266                    Severity::Error,
267                    "marketplace.plugin-missing",
268                    format!("`{path}` does not exist in the marketplace"),
269                );
270                return;
271            }
272            let root = SubTree::new(marketplace_root, path.clone());
273            prepare_root(
274                &root,
275                PrepareContext::Package,
276                request.hint.dialect,
277                options(path.is_empty()),
278            )
279        }
280        // The caller fetches it and normalizes the fetched tree with `hint.plugin` set to the
281        // entry: the one protocol for a plugin whose files are not in this tree.
282        PluginSource::Remote { .. } => {
283            report(
284                severity,
285                "marketplace.remote-not-fetched",
286                "lives in another repository: fetch it and normalize that tree with `hint.plugin`"
287                    .into(),
288            );
289            return;
290        }
291    };
292    finish_package(rooted, request, Some(Origin { entry }), normalized);
293}
294
295/// Add the package. A package-level error (two skills with one slug, conflicting manifests,
296/// an unreadable manifest) is reported on the package, which then holds nothing to write; a
297/// definition that cannot be read does not do that, because it imports unavailable with its
298/// error.
299fn finish_package<T: TreeSource>(
300    rooted: Rooted,
301    request: &NormalizeRequest<'_, T>,
302    origin: Option<Origin<'_>>,
303    normalized: &mut Normalized,
304) {
305    let Rooted {
306        mut prepared,
307        dispatched,
308    } = rooted;
309    let refused = prepared
310        .diagnostics
311        .values()
312        .flatten()
313        .any(SchemaDiagnostic::is_error);
314    if refused {
315        prepared.definitions.clear();
316        prepared.mcp_servers.clear();
317        prepared.payload.clear();
318    }
319
320    let spec = match &dispatched.layout {
321        Layout::Claude(spec) => Some(spec),
322        _ => None,
323    };
324    if prepared.manifest.is_none()
325        && !refused
326        && matches!(
327            prepared.classification,
328            Classification::Foreign(_) | Classification::MarsLegacy
329        )
330    {
331        let name = spec
332            .and_then(|spec| spec.name.clone())
333            .or_else(|| origin.as_ref().map(|origin| origin.entry.name.clone()))
334            .unwrap_or_else(|| repository_name(request));
335        prepared.manifest = Some(generated_manifest(
336            &name,
337            spec.and_then(|spec| spec.version.as_deref()),
338            spec.and_then(|spec| spec.description.as_deref()),
339        ));
340    }
341    let manifest_name = prepared
342        .manifest
343        .as_ref()
344        .and_then(|manifest| manifest.reading.package.fields.as_ref())
345        .map(|package| package.name.clone())
346        .unwrap_or_else(|| repository_name(request));
347    let selected_items = prepared
348        .definitions
349        .iter()
350        .map(|definition| definition.source_path.clone())
351        .collect();
352    normalized.packages.push(NormalizedPackage {
353        id: origin
354            .as_ref()
355            .map_or(manifest_name, |origin| origin.entry.name.clone()),
356        provenance: Provenance {
357            translator: crate::VERSION,
358            dialect: dispatched.dialect().map(|choice| choice.dialect),
359            source: request.source.clone(),
360            marketplace: origin.as_ref().map(|origin| MarketplaceProvenance {
361                plugin: origin.entry.name.clone(),
362                plugin_source: origin.entry.source.clone(),
363            }),
364            selected_items,
365        },
366        prepared,
367    });
368}
369
370/// The repository's name, plus `/<path>` for a subpath import.
371fn repository_name<T: TreeSource>(request: &NormalizeRequest<'_, T>) -> String {
372    let base = request.source.name.as_deref().unwrap_or("package");
373    match request
374        .source
375        .subpath
376        .as_deref()
377        .or(request.hint.subpath.as_deref())
378        .filter(|path| !path.is_empty())
379    {
380        Some(subpath) => format!("{base}/{subpath}"),
381        None => base.to_owned(),
382    }
383}
384
385/// The `mars.toml` a manifest-free package is stored with.
386fn generated_manifest(
387    name: &str,
388    version: Option<&str>,
389    description: Option<&str>,
390) -> PreparedManifest {
391    use toml_edit::{DocumentMut, Item, Table, value};
392    let mut package = Table::new();
393    package.insert("schema", value(1));
394    package.insert("name", value(name));
395    if let Some(version) = version {
396        package.insert("version", value(version));
397    }
398    if let Some(description) = description {
399        package.insert("description", value(description));
400    }
401    let mut document = DocumentMut::new();
402    document.insert("package", Item::Table(package));
403    let text = document.to_string();
404    let mut reading = crate::manifest::read(&text);
405    // A package with no declared version is not a defect of the generated file.
406    reading
407        .package
408        .diagnostics
409        .retain(|diagnostic| diagnostic.code != "manifest.version-missing");
410    PreparedManifest {
411        source_path: None,
412        output_path: "mars.toml".to_owned(),
413        normalized_bytes: text.into_bytes(),
414        reading,
415        change: ChangeRecord::Generated,
416    }
417}