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 shaping = Shaping::of(&classification, options.migrate);
267 let definitions = if can_read_definitions {
268 selected
269 .iter()
270 .map(|definition| {
271 prepare_definition(source, definition, shaping, &mut diagnostics, &mut changes)
272 })
273 .collect::<Vec<_>>()
274 } else {
275 Vec::new()
276 };
277 if let Shaping::Foreign(Dialect::Claude | Dialect::AgentSkills) = shaping {
278 let selected_paths = selected
279 .iter()
280 .map(|definition| definition.source_path.as_str())
281 .collect::<BTreeSet<_>>();
282 for (definition, prepared) in selected.iter().zip(&definitions) {
283 let Reading::Skill(DefinitionRead {
284 body: Some(body), ..
285 }) = &prepared.reading
286 else {
287 continue;
288 };
289 diagnostics
290 .entry(definition.source_path.clone())
291 .or_default()
292 .extend(crate::claude::checks::reference_diagnostics(
293 definition,
294 body,
295 all,
296 &selected_paths,
297 ));
298 }
299 }
300
301 let payload = if can_read_definitions {
302 payload_paths(
303 source,
304 &dispatched.discovery,
305 matches!(classification, Classification::Foreign(_)),
306 selected.iter().copied(),
307 &mut diagnostics,
308 )
309 .into_iter()
310 .map(|(source_path, output_path)| PayloadEntry {
311 source_path,
312 output_path,
313 })
314 .collect()
315 } else {
316 Vec::new()
317 };
318
319 if can_read_definitions {
320 report_unconverted(source, &dispatched, &mut diagnostics);
321 }
322 diagnostics.retain(|_, diagnostics| !diagnostics.is_empty());
323 changes.retain(|_, diagnostics| !diagnostics.is_empty());
324 let prepared = Prepared {
325 classification,
326 manifest: manifest.take(),
327 definitions,
328 payload,
329 diagnostics,
330 changes,
331 };
332 Rooted {
333 prepared,
334 dispatched,
335 }
336}
337
338fn report_unconverted(
340 source: &impl TreeSource,
341 dispatched: &Dispatched,
342 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
343) {
344 let Layout::Claude(spec) = &dispatched.layout else {
345 return;
346 };
347 let mut warn = |path: &str, code: &str, message: String| {
348 diagnostics
349 .entry(path.to_owned())
350 .or_default()
351 .push(SchemaDiagnostic::new(
352 code,
353 Severity::Warning,
354 Vec::new(),
355 message,
356 ));
357 };
358 if !dispatched.discovery.dropped.is_empty() {
359 warn(
360 "commands",
361 "import.commands-dropped",
362 format!(
363 "commands are not imported (convert one to a skill): {}",
364 dispatched
365 .discovery
366 .dropped
367 .iter()
368 .map(|command| command.source_path.as_str())
369 .collect::<Vec<_>>()
370 .join(", ")
371 ),
372 );
373 }
374 if source.is_file("hooks/hooks.json") || spec.hooks {
375 let path = if source.is_file("hooks/hooks.json") {
376 "hooks/hooks.json"
377 } else {
378 crate::claude::manifest::PLUGIN_MANIFEST
379 };
380 warn(
381 path,
382 "import.hooks-not-converted",
383 "hooks are not converted yet".into(),
384 );
385 }
386 if source.is_file(".mcp.json") || spec.mcp {
387 let path = if source.is_file(".mcp.json") {
388 ".mcp.json"
389 } else {
390 crate::claude::manifest::PLUGIN_MANIFEST
391 };
392 warn(
393 path,
394 "import.mcp-not-converted",
395 "MCP servers are not converted yet".into(),
396 );
397 }
398}
399
400fn prepare_manifest(
401 bytes: &[u8],
402 upgrade: bool,
403 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
404 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
405) -> PreparedManifest {
406 let invalid_utf8 = std::str::from_utf8(bytes).err().map(|error| {
407 SchemaDiagnostic::new(
408 "text.invalid-utf8",
409 Severity::Error,
410 Vec::new(),
411 format!("mars.toml is not UTF-8: {error}"),
412 )
413 });
414 let (normalized_bytes, change, upgraded) = match std::str::from_utf8(bytes) {
415 Ok(text) if upgrade => match crate::upgrade::upgrade_manifest(text) {
416 Ok(result) => upgrade_result(text, result),
417 Err(error) => {
418 push(
419 diagnostics,
420 "mars.toml",
421 SchemaDiagnostic::new(
422 "manifest.upgrade-failed",
423 Severity::Error,
424 Vec::new(),
425 error.to_string(),
426 ),
427 );
428 (bytes.to_vec(), ChangeRecord::None, Vec::new())
429 }
430 },
431 Ok(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
432 Err(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
433 };
434 changes
435 .entry("mars.toml".to_string())
436 .or_default()
437 .extend(upgraded);
438 let reading = std::str::from_utf8(&normalized_bytes).map_or_else(
439 |_| DefinitionRead::unread(invalid_utf8.into_iter().collect()),
440 crate::manifest::read,
441 );
442 PreparedManifest {
443 source_path: Some("mars.toml".to_string()),
444 output_path: "mars.toml".to_string(),
445 normalized_bytes,
446 reading,
447 change,
448 }
449}
450
451type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
453
454#[derive(Clone, Copy)]
456enum Shaping {
457 None,
459 Rewrite(Rewrite),
461 Foreign(Dialect),
463}
464
465impl Shaping {
466 fn of(classification: &Classification, migrate: bool) -> Self {
467 match classification {
468 Classification::MarsLegacy => Self::Rewrite(crate::upgrade::upgrade_definition),
469 Classification::Schema1 if migrate => {
471 Self::Rewrite(crate::upgrade::relocate_removed_fields)
472 }
473 Classification::Foreign(choice) => Self::Foreign(choice.dialect),
474 _ => Self::None,
475 }
476 }
477}
478
479fn prepare_definition(
480 source: &impl TreeSource,
481 selected: &SelectedDefinition,
482 shaping: Shaping,
483 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
484 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
485) -> PreparedDefinition {
486 let Some(bytes) = source.read(&selected.source_path) else {
487 return PreparedDefinition {
488 kind: selected.kind,
489 slug: selected.slug.clone(),
490 source_path: selected.source_path.clone(),
491 output_path: selected.output_path.clone(),
492 normalized_bytes: Vec::new(),
493 reading: unreadable(
494 selected.kind,
495 SchemaDiagnostic::new(
496 "definition.read-missing",
497 Severity::Error,
498 Vec::new(),
499 "selected definition could not be read",
500 ),
501 ),
502 change: ChangeRecord::None,
503 };
504 };
505 let (mut notes, mut findings) = (Vec::new(), Vec::new());
506 let (normalized_bytes, change) = match std::str::from_utf8(&bytes) {
507 Ok(text) => match shaping {
508 Shaping::Rewrite(rewrite) => match rewrite(selected.kind, text) {
509 Ok(result) => {
510 let (rewritten, change, upgrade_notes) = upgrade_result(text, result);
511 notes = upgrade_notes;
512 (rewritten, change)
513 }
514 Err(UpgradeError::Frontmatter(_)) => (bytes.clone(), ChangeRecord::None),
517 Err(error) => {
518 push(
519 diagnostics,
520 &selected.source_path,
521 SchemaDiagnostic::new(
522 "definition.upgrade-failed",
523 Severity::Error,
524 Vec::new(),
525 error.to_string(),
526 ),
527 );
528 (bytes.clone(), ChangeRecord::None)
529 }
530 },
531 Shaping::Foreign(dialect) => {
532 let translated = translate_foreign(text, dialect, selected, &bytes);
533 notes = translated.changes;
534 findings = translated.findings;
535 (translated.bytes, translated.change)
536 }
537 Shaping::None => (bytes.clone(), ChangeRecord::None),
538 },
539 Err(_) => (bytes.clone(), ChangeRecord::None),
540 };
541 changes
542 .entry(selected.source_path.clone())
543 .or_default()
544 .extend(notes);
545 diagnostics
546 .entry(selected.source_path.clone())
547 .or_default()
548 .extend(findings);
549
550 let reading = match std::str::from_utf8(&normalized_bytes) {
552 Ok(text) => read_definition(selected.kind, text),
553 Err(error) => unreadable(
554 selected.kind,
555 SchemaDiagnostic::new(
556 "text.invalid-utf8",
557 Severity::Error,
558 Vec::new(),
559 format!("definition is not UTF-8: {error}"),
560 ),
561 ),
562 };
563
564 PreparedDefinition {
565 kind: selected.kind,
566 slug: selected.slug.clone(),
567 source_path: selected.source_path.clone(),
568 output_path: selected.output_path.clone(),
569 normalized_bytes,
570 reading,
571 change,
572 }
573}
574
575struct Translated {
577 bytes: Vec<u8>,
578 change: ChangeRecord,
579 changes: Vec<SchemaDiagnostic>,
581 findings: Vec<SchemaDiagnostic>,
583}
584
585impl Translated {
586 fn untouched(bytes: &[u8]) -> Self {
587 Self {
588 bytes: bytes.to_vec(),
589 change: ChangeRecord::None,
590 changes: Vec::new(),
591 findings: Vec::new(),
592 }
593 }
594}
595
596fn translate_foreign(
600 text: &str,
601 dialect: Dialect,
602 selected: &SelectedDefinition,
603 bytes: &[u8],
604) -> Translated {
605 let claude = matches!(dialect, Dialect::Claude | Dialect::AgentSkills);
606 let mut changes = Vec::new();
607 let mut repaired = false;
608 let frontmatter = match Frontmatter::parse(text) {
609 Ok(frontmatter) => frontmatter,
610 Err(_) if claude && selected.kind == DefinitionKind::Skill => {
611 match crate::claude::translate::repair_description(text)
612 .and_then(|fixed| Frontmatter::parse(&fixed).ok())
613 {
614 Some(frontmatter) => {
615 repaired = true;
616 changes.push(crate::claude::translate::repaired_diagnostic());
617 frontmatter
618 }
619 None => return Translated::untouched(bytes),
620 }
621 }
622 Err(_) => return Translated::untouched(bytes),
623 };
624 let lifted = crate::lift::lift_frontmatter(dialect, selected.kind, &frontmatter);
625 changes.extend(lifted.changes);
626 let mut findings = lifted.findings;
627 if claude && selected.kind == DefinitionKind::Skill {
628 findings.extend(crate::claude::checks::skill_checks(
629 selected,
630 &lifted.frontmatter,
631 ));
632 }
633 let (bytes, change) = if lifted.changed || repaired {
634 (
635 lifted.frontmatter.render().into_bytes(),
636 ChangeRecord::FullyRewritten,
637 )
638 } else {
639 (bytes.to_vec(), ChangeRecord::None)
640 };
641 Translated {
642 bytes,
643 change,
644 changes,
645 findings,
646 }
647}
648
649fn unreadable(kind: DefinitionKind, diagnostic: SchemaDiagnostic) -> Reading {
651 match kind {
652 DefinitionKind::Agent => Reading::Agent(DefinitionRead::unread(vec![diagnostic])),
653 DefinitionKind::Skill => Reading::Skill(DefinitionRead::unread(vec![diagnostic])),
654 }
655}
656
657fn upgrade_result(
658 original: &str,
659 result: UpgradeResult,
660) -> (Vec<u8>, ChangeRecord, Vec<SchemaDiagnostic>) {
661 let change = if !result.changed_from(original) {
662 ChangeRecord::None
663 } else if result.fully_rewritten {
664 ChangeRecord::FullyRewritten
665 } else {
666 ChangeRecord::Edited {
667 paths: result.edited_paths,
668 }
669 };
670 (result.text.into_bytes(), change, result.diagnostics)
671}
672
673fn push(
674 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
675 path: &str,
676 diagnostic: SchemaDiagnostic,
677) {
678 diagnostics
679 .entry(path.to_owned())
680 .or_default()
681 .push(diagnostic);
682}