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
70#[derive(Debug, Clone, Serialize)]
71pub struct NormalizedPackage {
72 pub id: String,
74 pub provenance: Provenance,
75 pub prepared: Prepared,
76}
77
78#[derive(Debug, Clone, Serialize)]
79pub struct Provenance {
80 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 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
116struct Origin<'a> {
118 entry: &'a MarketplaceEntry,
119}
120
121fn 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 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 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
284fn 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
358fn 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
373fn 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 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}