1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct SourceInfo {
29 #[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#[derive(Debug, Clone, Default, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct Selection {
46 #[serde(default)]
48 pub plugins: Option<Vec<String>>,
49 #[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 pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
68}
69
70impl Normalized {
71 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 pub id: String,
85 pub provenance: Provenance,
86 pub prepared: Prepared,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct Provenance {
91 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 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
127struct Origin<'a> {
129 entry: &'a MarketplaceEntry,
130}
131
132fn 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 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 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
295fn 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
370fn 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
385fn 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 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}