1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::diagnostic::{PathSegment, SchemaDiagnostic, Severity};
9use crate::dialect::{Dialect, DialectChoice};
10use crate::discover::{SelectedDefinition, payload_paths};
11use crate::dispatch::{AsPlugin, Dispatched, Layout, dispatch};
12use crate::frontmatter::Frontmatter;
13use crate::item::DefinitionKind;
14use crate::read::{DefinitionRead, Reading, read_definition};
15use crate::tree::TreeSource;
16use crate::upgrade::{UpgradeError, UpgradeResult};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum PrepareContext {
21 Package,
24 AuthoredLocal,
27 Project,
30}
31
32pub const LOCAL_SOURCE_DIR: &str = ".mars-src";
34
35pub fn project_authored_root(project: &Path, manifest: Option<&[u8]>) -> (PathBuf, PrepareContext) {
40 match crate::package::classify_manifest(manifest) {
41 crate::package::ManifestClassification::ConsumerConfig => (
42 project.join(LOCAL_SOURCE_DIR),
43 PrepareContext::AuthoredLocal,
44 ),
45 _ => (project.to_path_buf(), PrepareContext::Package),
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51#[serde(tag = "kind", rename_all = "kebab-case")]
52pub enum Classification {
53 #[serde(rename = "schema-1")]
54 Schema1,
55 MarsLegacy,
56 SchemaNewer {
57 schema: u64,
58 },
59 Invalid,
60 Foreign(DialectChoice),
62 Marketplace,
64 Empty,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(tag = "kind", rename_all = "kebab-case")]
69pub enum ChangeRecord {
70 None,
71 Generated,
73 Edited {
74 paths: Vec<Vec<PathSegment>>,
75 },
76 FullyRewritten,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct PreparedManifest {
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub source_path: Option<String>,
84 pub output_path: String,
85 #[serde(with = "serde_bytes")]
87 pub normalized_bytes: Vec<u8>,
88 pub reading: DefinitionRead<crate::package::PackageMetadata>,
89 pub change: ChangeRecord,
90}
91
92#[derive(Debug, Clone, Serialize)]
93pub struct PreparedDefinition {
94 pub kind: DefinitionKind,
95 pub slug: String,
96 pub source_path: String,
97 pub output_path: String,
98 #[serde(with = "serde_bytes")]
100 pub normalized_bytes: Vec<u8>,
101 pub reading: Reading,
103 pub change: ChangeRecord,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct PayloadEntry {
108 pub source_path: String,
109 pub output_path: String,
110}
111
112#[derive(Debug, Clone, Serialize)]
113pub struct Prepared {
114 pub classification: Classification,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub manifest: Option<PreparedManifest>,
117 pub definitions: Vec<PreparedDefinition>,
118 pub payload: Vec<PayloadEntry>,
119 pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
123 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
127 pub changes: BTreeMap<String, Vec<SchemaDiagnostic>>,
128}
129
130impl Prepared {
131 pub fn lost_restrictions(&self) -> impl Iterator<Item = (&str, &SchemaDiagnostic)> {
135 self.changes.iter().flat_map(|(path, changes)| {
136 changes
137 .iter()
138 .filter(|change| change.code == crate::set_aside::RESTRICTION_LOST)
139 .map(move |change| (path.as_str(), change))
140 })
141 }
142
143 pub fn changed_files(&self) -> usize {
147 self.manifest
148 .iter()
149 .map(|manifest| &manifest.change)
150 .chain(self.definitions.iter().map(|definition| &definition.change))
151 .filter(|change| !matches!(change, ChangeRecord::None | ChangeRecord::Generated))
152 .count()
153 }
154
155 pub fn all_diagnostics(&self) -> BTreeMap<&str, Vec<&SchemaDiagnostic>> {
157 let mut all: BTreeMap<&str, Vec<&SchemaDiagnostic>> = BTreeMap::new();
158 for (path, diagnostics) in &self.diagnostics {
159 all.entry(path).or_default().extend(diagnostics);
160 }
161 if let Some(manifest) = &self.manifest
162 && let Some(path) = &manifest.source_path
163 {
164 all.entry(path)
165 .or_default()
166 .extend(&manifest.reading.diagnostics);
167 }
168 for definition in &self.definitions {
169 all.entry(&definition.source_path)
170 .or_default()
171 .extend(definition.reading.diagnostics());
172 }
173 all.retain(|_, diagnostics| !diagnostics.is_empty());
174 all
175 }
176}
177
178#[derive(Default, Clone, Copy)]
181pub(crate) struct RootOptions<'a> {
182 pub as_plugin: Option<AsPlugin<'a>>,
183 pub selection: Option<&'a BTreeSet<String>>,
185 pub fallback_name: Option<&'a str>,
187 pub migrate: bool,
192}
193
194pub(crate) struct Rooted {
196 pub prepared: Prepared,
197 pub dispatched: Dispatched,
198}
199
200pub fn prepare(
202 source: &impl TreeSource,
203 context: PrepareContext,
204 declared_dialect: Option<Dialect>,
205) -> Prepared {
206 prepare_root(source, context, declared_dialect, RootOptions::default()).prepared
207}
208
209pub fn prepare_for_migration(
212 source: &impl TreeSource,
213 context: PrepareContext,
214 declared_dialect: Option<Dialect>,
215) -> Prepared {
216 let options = RootOptions {
217 migrate: true,
218 ..RootOptions::default()
219 };
220 prepare_root(source, context, declared_dialect, options).prepared
221}
222
223pub(crate) fn prepare_root(
224 source: &impl TreeSource,
225 context: PrepareContext,
226 declared_dialect: Option<Dialect>,
227 options: RootOptions<'_>,
228) -> Rooted {
229 let mut dispatched = dispatch(
230 source,
231 context,
232 declared_dialect,
233 options.as_plugin,
234 options.fallback_name,
235 );
236 let classification = dispatched.classification.clone();
237 let manifest_bytes = dispatched.manifest.take();
238 let mut diagnostics = std::mem::take(&mut dispatched.discovery.diagnostics);
239 let mut changes = BTreeMap::new();
240
241 let mut manifest = manifest_bytes.as_deref().map(|bytes| {
242 prepare_manifest(
243 bytes,
244 classification == Classification::MarsLegacy,
245 &mut diagnostics,
246 &mut changes,
247 )
248 });
249
250 let can_read_definitions = !matches!(
251 classification,
252 Classification::Invalid
253 | Classification::SchemaNewer { .. }
254 | Classification::Empty
255 | Classification::Marketplace
256 );
257 let all = &dispatched.discovery.definitions;
258 let selected = all
259 .iter()
260 .filter(|definition| {
261 options
262 .selection
263 .is_none_or(|selection| selection.contains(&definition.source_path))
264 })
265 .collect::<Vec<_>>();
266 let selected_paths = selected
267 .iter()
268 .map(|definition| definition.source_path.as_str())
269 .collect::<BTreeSet<_>>();
270 let shaping = Shaping::of(&classification, options.migrate);
271 let definitions = if can_read_definitions {
272 selected
273 .iter()
274 .map(|definition| {
275 prepare_definition(
276 source,
277 definition,
278 shaping,
279 all,
280 &selected_paths,
281 &mut diagnostics,
282 &mut changes,
283 )
284 })
285 .collect::<Vec<_>>()
286 } else {
287 Vec::new()
288 };
289
290 let payload = if can_read_definitions {
291 payload_paths(
292 source,
293 &dispatched.discovery,
294 matches!(classification, Classification::Foreign(_)),
295 selected.iter().copied(),
296 &mut diagnostics,
297 )
298 .into_iter()
299 .map(|(source_path, output_path)| PayloadEntry {
300 source_path,
301 output_path,
302 })
303 .collect()
304 } else {
305 Vec::new()
306 };
307
308 if can_read_definitions {
309 report_unconverted(source, &dispatched, &mut diagnostics);
310 }
311 diagnostics.retain(|_, diagnostics| !diagnostics.is_empty());
312 changes.retain(|_, diagnostics| !diagnostics.is_empty());
313 let prepared = Prepared {
314 classification,
315 manifest: manifest.take(),
316 definitions,
317 payload,
318 diagnostics,
319 changes,
320 };
321 Rooted {
322 prepared,
323 dispatched,
324 }
325}
326
327fn report_unconverted(
329 source: &impl TreeSource,
330 dispatched: &Dispatched,
331 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
332) {
333 let Layout::Claude(spec) = &dispatched.layout else {
334 return;
335 };
336 let mut warn = |path: &str, code: &str, message: String| {
337 diagnostics
338 .entry(path.to_owned())
339 .or_default()
340 .push(SchemaDiagnostic::new(
341 code,
342 Severity::Warning,
343 Vec::new(),
344 message,
345 ));
346 };
347 if !dispatched.discovery.dropped.is_empty() {
348 warn(
349 "commands",
350 "import.commands-dropped",
351 format!(
352 "commands are not imported (convert one to a skill): {}",
353 dispatched
354 .discovery
355 .dropped
356 .iter()
357 .map(|command| command.source_path.as_str())
358 .collect::<Vec<_>>()
359 .join(", ")
360 ),
361 );
362 }
363 if source.is_file("hooks/hooks.json") || spec.hooks {
364 let path = if source.is_file("hooks/hooks.json") {
365 "hooks/hooks.json"
366 } else {
367 crate::claude::manifest::PLUGIN_MANIFEST
368 };
369 warn(
370 path,
371 "import.hooks-not-converted",
372 "hooks are not converted yet".into(),
373 );
374 }
375 if source.is_file(".mcp.json") || spec.mcp {
376 let path = if source.is_file(".mcp.json") {
377 ".mcp.json"
378 } else {
379 crate::claude::manifest::PLUGIN_MANIFEST
380 };
381 warn(
382 path,
383 "import.mcp-not-converted",
384 "MCP servers are not converted yet".into(),
385 );
386 }
387}
388
389fn prepare_manifest(
390 bytes: &[u8],
391 upgrade: bool,
392 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
393 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
394) -> PreparedManifest {
395 let invalid_utf8 = std::str::from_utf8(bytes).err().map(|error| {
396 SchemaDiagnostic::new(
397 "text.invalid-utf8",
398 Severity::Error,
399 Vec::new(),
400 format!("mars.toml is not UTF-8: {error}"),
401 )
402 });
403 let (normalized_bytes, change, upgraded) = match std::str::from_utf8(bytes) {
404 Ok(text) if upgrade => match crate::upgrade::upgrade_manifest(text) {
405 Ok(result) => upgrade_result(text, result),
406 Err(error) => {
407 push(
408 diagnostics,
409 "mars.toml",
410 SchemaDiagnostic::new(
411 "manifest.upgrade-failed",
412 Severity::Error,
413 Vec::new(),
414 error.to_string(),
415 ),
416 );
417 (bytes.to_vec(), ChangeRecord::None, Vec::new())
418 }
419 },
420 Ok(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
421 Err(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
422 };
423 changes
424 .entry("mars.toml".to_string())
425 .or_default()
426 .extend(upgraded);
427 let reading = std::str::from_utf8(&normalized_bytes).map_or_else(
428 |_| DefinitionRead::unread(invalid_utf8.into_iter().collect()),
429 crate::manifest::read,
430 );
431 PreparedManifest {
432 source_path: Some("mars.toml".to_string()),
433 output_path: "mars.toml".to_string(),
434 normalized_bytes,
435 reading,
436 change,
437 }
438}
439
440type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
442
443#[derive(Clone, Copy)]
445enum Shaping {
446 None,
448 Rewrite(Rewrite),
450 Foreign(Dialect),
452}
453
454impl Shaping {
455 fn of(classification: &Classification, migrate: bool) -> Self {
456 match classification {
457 Classification::MarsLegacy => Self::Rewrite(crate::upgrade::upgrade_definition),
458 Classification::Schema1 if migrate => {
460 Self::Rewrite(crate::upgrade::relocate_removed_fields)
461 }
462 Classification::Foreign(choice) => Self::Foreign(choice.dialect),
463 _ => Self::None,
464 }
465 }
466}
467
468fn prepare_definition(
469 source: &impl TreeSource,
470 selected: &SelectedDefinition,
471 shaping: Shaping,
472 all: &[SelectedDefinition],
473 selected_paths: &BTreeSet<&str>,
474 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
475 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
476) -> PreparedDefinition {
477 let Some(bytes) = source.read(&selected.source_path) else {
478 return PreparedDefinition {
479 kind: selected.kind,
480 slug: selected.slug.clone(),
481 source_path: selected.source_path.clone(),
482 output_path: selected.output_path.clone(),
483 normalized_bytes: Vec::new(),
484 reading: unreadable(
485 selected.kind,
486 SchemaDiagnostic::new(
487 "definition.read-missing",
488 Severity::Error,
489 Vec::new(),
490 "selected definition could not be read",
491 ),
492 ),
493 change: ChangeRecord::None,
494 };
495 };
496 let (mut notes, mut findings) = (Vec::new(), Vec::new());
497 let (normalized_bytes, change) = match std::str::from_utf8(&bytes) {
498 Ok(text) => match shaping {
499 Shaping::Rewrite(rewrite) => match rewrite(selected.kind, text) {
500 Ok(result) => {
501 let (rewritten, change, upgrade_notes) = upgrade_result(text, result);
502 notes = upgrade_notes;
503 (rewritten, change)
504 }
505 Err(UpgradeError::Frontmatter(_)) => (bytes.clone(), ChangeRecord::None),
508 Err(error) => {
509 push(
510 diagnostics,
511 &selected.source_path,
512 SchemaDiagnostic::new(
513 "definition.upgrade-failed",
514 Severity::Error,
515 Vec::new(),
516 error.to_string(),
517 ),
518 );
519 (bytes.clone(), ChangeRecord::None)
520 }
521 },
522 Shaping::Foreign(dialect) => {
523 let translated =
524 translate_foreign(text, dialect, selected, all, selected_paths, &bytes);
525 notes = translated.changes;
526 findings = translated.findings;
527 (translated.bytes, translated.change)
528 }
529 Shaping::None => (bytes.clone(), ChangeRecord::None),
530 },
531 Err(_) => (bytes.clone(), ChangeRecord::None),
532 };
533 changes
534 .entry(selected.source_path.clone())
535 .or_default()
536 .extend(notes);
537 diagnostics
538 .entry(selected.source_path.clone())
539 .or_default()
540 .extend(findings);
541
542 let reading = match std::str::from_utf8(&normalized_bytes) {
544 Ok(text) => read_definition(selected.kind, text),
545 Err(error) => unreadable(
546 selected.kind,
547 SchemaDiagnostic::new(
548 "text.invalid-utf8",
549 Severity::Error,
550 Vec::new(),
551 format!("definition is not UTF-8: {error}"),
552 ),
553 ),
554 };
555
556 PreparedDefinition {
557 kind: selected.kind,
558 slug: selected.slug.clone(),
559 source_path: selected.source_path.clone(),
560 output_path: selected.output_path.clone(),
561 normalized_bytes,
562 reading,
563 change,
564 }
565}
566
567struct Translated {
569 bytes: Vec<u8>,
570 change: ChangeRecord,
571 changes: Vec<SchemaDiagnostic>,
573 findings: Vec<SchemaDiagnostic>,
575}
576
577impl Translated {
578 fn untouched(bytes: &[u8]) -> Self {
579 Self {
580 bytes: bytes.to_vec(),
581 change: ChangeRecord::None,
582 changes: Vec::new(),
583 findings: Vec::new(),
584 }
585 }
586}
587
588fn translate_foreign(
592 text: &str,
593 dialect: Dialect,
594 selected: &SelectedDefinition,
595 all: &[SelectedDefinition],
596 selected_paths: &BTreeSet<&str>,
597 bytes: &[u8],
598) -> Translated {
599 let claude = matches!(dialect, Dialect::Claude | Dialect::AgentSkills);
600 let mut changes = Vec::new();
601 let mut repaired = false;
602 let frontmatter = match Frontmatter::parse(text) {
603 Ok(frontmatter) => frontmatter,
604 Err(_) if claude && selected.kind == DefinitionKind::Skill => {
605 match crate::claude::translate::repair_description(text)
606 .and_then(|fixed| Frontmatter::parse(&fixed).ok())
607 {
608 Some(frontmatter) => {
609 repaired = true;
610 changes.push(crate::claude::translate::repaired_diagnostic());
611 frontmatter
612 }
613 None => return Translated::untouched(bytes),
614 }
615 }
616 Err(_) => return Translated::untouched(bytes),
617 };
618 let lifted = crate::lift::lift_frontmatter(dialect, selected.kind, &frontmatter);
619 changes.extend(lifted.changes);
620 let mut findings = lifted.findings;
621 if claude && selected.kind == DefinitionKind::Skill {
622 findings.extend(crate::claude::checks::skill_checks(
623 selected,
624 &lifted.frontmatter,
625 all,
626 selected_paths,
627 ));
628 }
629 let (bytes, change) = if lifted.changed || repaired {
630 (
631 lifted.frontmatter.render().into_bytes(),
632 ChangeRecord::FullyRewritten,
633 )
634 } else {
635 (bytes.to_vec(), ChangeRecord::None)
636 };
637 Translated {
638 bytes,
639 change,
640 changes,
641 findings,
642 }
643}
644
645fn unreadable(kind: DefinitionKind, diagnostic: SchemaDiagnostic) -> Reading {
647 match kind {
648 DefinitionKind::Agent => Reading::Agent(DefinitionRead::unread(vec![diagnostic])),
649 DefinitionKind::Skill => Reading::Skill(DefinitionRead::unread(vec![diagnostic])),
650 }
651}
652
653fn upgrade_result(
654 original: &str,
655 result: UpgradeResult,
656) -> (Vec<u8>, ChangeRecord, Vec<SchemaDiagnostic>) {
657 let change = if !result.changed_from(original) {
658 ChangeRecord::None
659 } else if result.fully_rewritten {
660 ChangeRecord::FullyRewritten
661 } else {
662 ChangeRecord::Edited {
663 paths: result.edited_paths,
664 }
665 };
666 (result.text.into_bytes(), change, result.diagnostics)
667}
668
669fn push(
670 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
671 path: &str,
672 diagnostic: SchemaDiagnostic,
673) {
674 diagnostics
675 .entry(path.to_owned())
676 .or_default()
677 .push(diagnostic);
678}