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