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        },
165    );
166    let mut normalized = Normalized {
167        kind: rooted.dispatched.kind(),
168        packages: Vec::new(),
169        diagnostics: BTreeMap::new(),
170    };
171    if let Layout::Marketplace(marketplace) = &rooted.dispatched.layout {
172        normalized.diagnostics = rooted.prepared.diagnostics.clone();
173        for entry in &marketplace.plugins {
174            let named = request
175                .selection
176                .plugins
177                .as_ref()
178                .map(|names| names.contains(&entry.name));
179            if named != Some(false) {
180                normalize_plugin(root, request, entry, named == Some(true), &mut normalized);
181            }
182        }
183        return normalized;
184    }
185    let origin = hint_plugin.map(|plugin| Origin {
186        entry: &plugin.entry,
187    });
188    finish_package(rooted, request, origin, &mut normalized);
189    normalized
190}
191
192fn normalize_plugin<T: TreeSource>(
193    marketplace_root: &impl TreeSource,
194    request: &NormalizeRequest<'_, T>,
195    entry: &MarketplaceEntry,
196    named: bool,
197    normalized: &mut Normalized,
198) {
199    // A plugin the caller asked for by name must work; one taken along with all of them need not.
200    let severity = if named {
201        Severity::Error
202    } else {
203        Severity::Warning
204    };
205    let mut report = |severity, code: &str, message: String| {
206        normalized
207            .diagnostics
208            .entry(MARKETPLACE_MANIFEST.to_owned())
209            .or_default()
210            .push(SchemaDiagnostic::new(
211                code,
212                severity,
213                Vec::new(),
214                format!("plugin `{}`: {message}", entry.name),
215            ));
216    };
217    let items = item_set(&request.selection, &entry.name);
218    let options = |at_marketplace_root| RootOptions {
219        as_plugin: Some(AsPlugin {
220            entry,
221            at_marketplace_root,
222        }),
223        selection: items.as_ref(),
224        fallback_name: Some(&entry.name),
225    };
226    let rooted = match &entry.source {
227        PluginSource::Unsupported { reason } => {
228            report(
229                severity,
230                "marketplace.plugin-unsupported",
231                format!("can't import from this source: {reason}"),
232            );
233            return;
234        }
235        PluginSource::Local { path } => {
236            if let Some(generated) = enclosing_generated_dir(marketplace_root, path) {
237                report(
238                    Severity::Warning,
239                    "marketplace.plugin-generated",
240                    format!(
241                        "`{path}` is inside `{}`, plugin output Mars built, which is never input",
242                        if generated.is_empty() {
243                            "."
244                        } else {
245                            &generated
246                        }
247                    ),
248                );
249                return;
250            }
251            if !path.is_empty() && !marketplace_root.is_dir(path) {
252                report(
253                    Severity::Error,
254                    "marketplace.plugin-missing",
255                    format!("`{path}` does not exist in the marketplace"),
256                );
257                return;
258            }
259            let root = SubTree::new(marketplace_root, path.clone());
260            prepare_root(
261                &root,
262                PrepareContext::Package,
263                request.hint.dialect,
264                options(path.is_empty()),
265            )
266        }
267        // The caller fetches it and normalizes the fetched tree with `hint.plugin` set to the
268        // entry: the one protocol for a plugin whose files are not in this tree.
269        PluginSource::Remote { .. } => {
270            report(
271                severity,
272                "marketplace.remote-not-fetched",
273                "lives in another repository: fetch it and normalize that tree with `hint.plugin`"
274                    .into(),
275            );
276            return;
277        }
278    };
279    finish_package(rooted, request, Some(Origin { entry }), normalized);
280}
281
282/// Add the package. A package-level error (two skills with one slug, conflicting manifests,
283/// an unreadable manifest) is reported on the package, which then holds nothing to write; a
284/// definition that cannot be read does not do that, because it imports unavailable with its
285/// error.
286fn finish_package<T: TreeSource>(
287    rooted: Rooted,
288    request: &NormalizeRequest<'_, T>,
289    origin: Option<Origin<'_>>,
290    normalized: &mut Normalized,
291) {
292    let Rooted {
293        mut prepared,
294        dispatched,
295    } = rooted;
296    let refused = prepared
297        .diagnostics
298        .values()
299        .flatten()
300        .any(SchemaDiagnostic::is_error);
301    if refused {
302        prepared.definitions.clear();
303        prepared.payload.clear();
304    }
305
306    let spec = match &dispatched.layout {
307        Layout::Claude(spec) => Some(spec),
308        _ => None,
309    };
310    if prepared.manifest.is_none()
311        && !refused
312        && matches!(
313            prepared.classification,
314            Classification::Foreign(_) | Classification::MarsLegacy
315        )
316    {
317        let name = spec
318            .and_then(|spec| spec.name.clone())
319            .or_else(|| origin.as_ref().map(|origin| origin.entry.name.clone()))
320            .unwrap_or_else(|| repository_name(request));
321        prepared.manifest = Some(generated_manifest(
322            &name,
323            spec.and_then(|spec| spec.version.as_deref()),
324            spec.and_then(|spec| spec.description.as_deref()),
325        ));
326    }
327    let manifest_name = prepared
328        .manifest
329        .as_ref()
330        .and_then(|manifest| manifest.reading.fields.as_ref())
331        .map(|package| package.name.clone())
332        .unwrap_or_else(|| repository_name(request));
333    let selected_items = prepared
334        .definitions
335        .iter()
336        .map(|definition| definition.source_path.clone())
337        .collect();
338    normalized.packages.push(NormalizedPackage {
339        id: origin
340            .as_ref()
341            .map_or(manifest_name, |origin| origin.entry.name.clone()),
342        provenance: Provenance {
343            translator: crate::VERSION,
344            dialect: dispatched.dialect().map(|choice| choice.dialect),
345            source: request.source.clone(),
346            marketplace: origin.as_ref().map(|origin| MarketplaceProvenance {
347                plugin: origin.entry.name.clone(),
348                plugin_source: origin.entry.source.clone(),
349            }),
350            selected_items,
351        },
352        prepared,
353    });
354}
355
356/// The repository's name, plus `/<path>` for a subpath import.
357fn repository_name<T: TreeSource>(request: &NormalizeRequest<'_, T>) -> String {
358    let base = request.source.name.as_deref().unwrap_or("package");
359    match request
360        .source
361        .subpath
362        .as_deref()
363        .or(request.hint.subpath.as_deref())
364        .filter(|path| !path.is_empty())
365    {
366        Some(subpath) => format!("{base}/{subpath}"),
367        None => base.to_owned(),
368    }
369}
370
371/// The `mars.toml` a manifest-free package is stored with.
372fn generated_manifest(
373    name: &str,
374    version: Option<&str>,
375    description: Option<&str>,
376) -> PreparedManifest {
377    use toml_edit::{DocumentMut, Item, Table, value};
378    let mut package = Table::new();
379    package.insert("schema", value(1));
380    package.insert("name", value(name));
381    if let Some(version) = version {
382        package.insert("version", value(version));
383    }
384    if let Some(description) = description {
385        package.insert("description", value(description));
386    }
387    let mut document = DocumentMut::new();
388    document.insert("package", Item::Table(package));
389    let text = document.to_string();
390    let mut reading = crate::manifest::read(&text);
391    // A package with no declared version is not a defect of the generated file.
392    reading
393        .diagnostics
394        .retain(|diagnostic| diagnostic.code != "manifest.version-missing");
395    PreparedManifest {
396        source_path: None,
397        output_path: "mars.toml".to_owned(),
398        normalized_bytes: text.into_bytes(),
399        reading,
400        change: ChangeRecord::Generated,
401    }
402}