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::selection::{Definition, Request, SelectionFacts};
16use crate::tree::TreeSource;
17use crate::upgrade::{UpgradeError, UpgradeResult};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum PrepareContext {
22 Package,
25 AuthoredLocal,
28 Project,
31}
32
33pub const LOCAL_SOURCE_DIR: &str = ".mars-src";
35
36pub fn project_authored_root(project: &Path, manifest: Option<&[u8]>) -> (PathBuf, PrepareContext) {
41 match crate::package::classify_manifest(manifest) {
42 crate::package::ManifestClassification::ConsumerConfig => (
43 project.join(LOCAL_SOURCE_DIR),
44 PrepareContext::AuthoredLocal,
45 ),
46 _ => (project.to_path_buf(), PrepareContext::Package),
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52#[serde(tag = "kind", rename_all = "kebab-case")]
53pub enum Classification {
54 #[serde(rename = "schema-1")]
55 Schema1,
56 MarsLegacy,
57 SchemaNewer {
58 schema: u64,
59 },
60 Invalid,
61 Foreign(DialectChoice),
63 Marketplace,
65 Empty,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69#[serde(tag = "kind", rename_all = "kebab-case")]
70pub enum ChangeRecord {
71 None,
72 Generated,
74 Edited {
75 paths: Vec<Vec<PathSegment>>,
76 },
77 FullyRewritten,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct PreparedManifest {
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub source_path: Option<String>,
85 pub output_path: String,
86 #[serde(with = "serde_bytes")]
88 pub normalized_bytes: Vec<u8>,
89 pub reading: crate::manifest::ManifestRead,
90 pub change: ChangeRecord,
91}
92
93#[derive(Debug, Clone, Serialize)]
94pub struct PreparedDefinition {
95 pub kind: DefinitionKind,
96 pub slug: String,
97 pub source_path: String,
98 pub output_path: String,
99 #[serde(skip)]
101 pub skill_root: Option<String>,
102 #[serde(with = "serde_bytes")]
104 pub normalized_bytes: Vec<u8>,
105 pub reading: Reading,
107 pub change: ChangeRecord,
108}
109
110#[derive(Debug, Clone, Serialize)]
113pub struct PreparedMcpServer {
114 pub name: String,
115 pub source_path: String,
117 pub output_path: String,
118 #[serde(with = "serde_bytes")]
120 pub normalized_bytes: Vec<u8>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct PayloadEntry {
125 pub source_path: String,
126 pub output_path: String,
127}
128
129#[derive(Debug, Clone, Serialize)]
130pub struct Prepared {
131 pub classification: Classification,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub manifest: Option<PreparedManifest>,
134 pub definitions: Vec<PreparedDefinition>,
135 #[serde(skip_serializing_if = "Vec::is_empty")]
137 pub mcp_servers: Vec<PreparedMcpServer>,
138 pub payload: Vec<PayloadEntry>,
139 pub selection: SelectionFacts,
141 pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
145 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
149 pub changes: BTreeMap<String, Vec<SchemaDiagnostic>>,
150}
151
152pub(crate) fn selection_facts(
155 manifest: Option<&PreparedManifest>,
156 definitions: &[PreparedDefinition],
157) -> SelectionFacts {
158 let reading = manifest.map(|manifest| &manifest.reading);
159 SelectionFacts {
160 definitions: definitions
161 .iter()
162 .map(|definition| Definition {
163 kind: definition.kind,
164 slug: definition.slug.clone(),
165 output_path: definition.output_path.clone(),
166 skills: match &definition.reading {
167 Reading::Agent(read) => read
168 .fields
169 .as_ref()
170 .map(|profile| profile.skills.all())
171 .unwrap_or_default(),
172 _ => Vec::new(),
173 },
174 })
175 .collect(),
176 edges: reading
177 .map(|reading| {
178 reading
179 .dependencies
180 .dependencies
181 .iter()
182 .map(|(name, entry)| Request {
183 name: name.clone(),
184 filter: entry.filter.to_mode(),
185 })
186 .collect()
187 })
188 .unwrap_or_default(),
189 uses_providers: reading
190 .and_then(|reading| reading.package.fields.as_ref())
191 .map(|package| package.uses_providers.clone())
192 .unwrap_or_default(),
193 }
194}
195
196impl Prepared {
197 pub fn lost_restrictions(&self) -> impl Iterator<Item = (&str, &SchemaDiagnostic)> {
201 self.changes.iter().flat_map(|(path, changes)| {
202 changes
203 .iter()
204 .filter(|change| change.code == crate::set_aside::RESTRICTION_LOST)
205 .map(move |change| (path.as_str(), change))
206 })
207 }
208
209 pub fn changed_files(&self) -> usize {
213 self.manifest
214 .iter()
215 .map(|manifest| &manifest.change)
216 .chain(self.definitions.iter().map(|definition| &definition.change))
217 .filter(|change| !matches!(change, ChangeRecord::None | ChangeRecord::Generated))
218 .count()
219 }
220
221 pub fn all_diagnostics(&self) -> BTreeMap<&str, Vec<&SchemaDiagnostic>> {
223 let mut all: BTreeMap<&str, Vec<&SchemaDiagnostic>> = BTreeMap::new();
224 for (path, diagnostics) in &self.diagnostics {
225 all.entry(path).or_default().extend(diagnostics);
226 }
227 if let Some(manifest) = &self.manifest
228 && let Some(path) = &manifest.source_path
229 {
230 all.entry(path)
231 .or_default()
232 .extend(&manifest.reading.package.diagnostics);
233 }
234 for definition in &self.definitions {
235 all.entry(&definition.source_path)
236 .or_default()
237 .extend(definition.reading.diagnostics());
238 }
239 all.retain(|_, diagnostics| !diagnostics.is_empty());
240 all
241 }
242}
243
244#[derive(Default, Clone, Copy)]
247pub(crate) struct RootOptions<'a> {
248 pub as_plugin: Option<AsPlugin<'a>>,
249 pub selection: Option<&'a BTreeSet<String>>,
251 pub fallback_name: Option<&'a str>,
253 pub migrate: bool,
258}
259
260pub(crate) struct Rooted {
262 pub prepared: Prepared,
263 pub dispatched: Dispatched,
264}
265
266pub fn prepare(
268 source: &impl TreeSource,
269 context: PrepareContext,
270 declared_dialect: Option<Dialect>,
271) -> Prepared {
272 prepare_root(source, context, declared_dialect, RootOptions::default()).prepared
273}
274
275pub fn prepare_for_migration(
278 source: &impl TreeSource,
279 context: PrepareContext,
280 declared_dialect: Option<Dialect>,
281) -> Prepared {
282 let options = RootOptions {
283 migrate: true,
284 ..RootOptions::default()
285 };
286 prepare_root(source, context, declared_dialect, options).prepared
287}
288
289pub(crate) fn prepare_root(
290 source: &impl TreeSource,
291 context: PrepareContext,
292 declared_dialect: Option<Dialect>,
293 options: RootOptions<'_>,
294) -> Rooted {
295 let mut dispatched = dispatch(
296 source,
297 context,
298 declared_dialect,
299 options.as_plugin,
300 options.fallback_name,
301 );
302 let classification = dispatched.classification.clone();
303 let manifest_bytes = dispatched.manifest.take();
304 let mut diagnostics = std::mem::take(&mut dispatched.discovery.diagnostics);
305 let mut changes = BTreeMap::new();
306
307 let mut manifest = manifest_bytes.as_deref().map(|bytes| {
308 prepare_manifest(
309 bytes,
310 classification == Classification::MarsLegacy,
311 &mut diagnostics,
312 &mut changes,
313 )
314 });
315
316 let can_read_definitions = !matches!(
317 classification,
318 Classification::Invalid
319 | Classification::SchemaNewer { .. }
320 | Classification::Empty
321 | Classification::Marketplace
322 );
323 let all = &dispatched.discovery.definitions;
324 let selected = all
325 .iter()
326 .filter(|definition| {
327 options
328 .selection
329 .is_none_or(|selection| selection.contains(&definition.source_path))
330 })
331 .collect::<Vec<_>>();
332 let shaping = Shaping::of(&classification, options.migrate);
333 let definitions = if can_read_definitions {
334 selected
335 .iter()
336 .map(|definition| {
337 prepare_definition(source, definition, shaping, &mut diagnostics, &mut changes)
338 })
339 .collect::<Vec<_>>()
340 } else {
341 Vec::new()
342 };
343 let payload = if can_read_definitions {
344 payload_paths(
345 source,
346 &dispatched.discovery,
347 matches!(classification, Classification::Foreign(_)),
348 selected.iter().copied(),
349 &mut diagnostics,
350 )
351 .into_iter()
352 .map(|(source_path, output_path)| PayloadEntry {
353 source_path,
354 output_path,
355 })
356 .collect()
357 } else {
358 Vec::new()
359 };
360
361 let mut mcp_servers = Vec::new();
362 if can_read_definitions {
363 report_unconverted(source, &dispatched, &mut diagnostics);
364 if let Layout::Claude(spec) = &dispatched.layout
366 && !spec.is_bare_tree()
367 {
368 let imported = crate::claude::mcp::import(source, spec);
369 for (path, diagnostic) in imported.diagnostics {
370 push(&mut diagnostics, &path, diagnostic);
371 }
372 mcp_servers = imported
373 .servers
374 .into_iter()
375 .map(|server| PreparedMcpServer {
376 output_path: format!("mcp/{}/mcp.toml", server.name),
377 normalized_bytes: crate::mcp_server::render_mcp_toml(&server.def).into_bytes(),
378 name: server.name,
379 source_path: server.source_path,
380 })
381 .collect();
382 }
383 }
384 diagnostics.retain(|_, diagnostics| !diagnostics.is_empty());
385 changes.retain(|_, diagnostics| !diagnostics.is_empty());
386 let manifest = manifest.take();
387 let prepared = Prepared {
388 classification,
389 selection: selection_facts(manifest.as_ref(), &definitions),
390 manifest,
391 definitions,
392 mcp_servers,
393 payload,
394 diagnostics,
395 changes,
396 };
397 Rooted {
398 prepared,
399 dispatched,
400 }
401}
402
403fn report_unconverted(
406 source: &impl TreeSource,
407 dispatched: &Dispatched,
408 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
409) {
410 let Layout::Claude(spec) = &dispatched.layout else {
411 return;
412 };
413 let mut warn = |path: &str, code: &str, message: String| {
414 diagnostics
415 .entry(path.to_owned())
416 .or_default()
417 .push(SchemaDiagnostic::new(
418 code,
419 Severity::Warning,
420 Vec::new(),
421 message,
422 ));
423 };
424 if !dispatched.discovery.dropped.is_empty() {
425 warn(
426 "commands",
427 "import.commands-dropped",
428 format!(
429 "commands are not imported (convert one to a skill): {}",
430 dispatched
431 .discovery
432 .dropped
433 .iter()
434 .map(|command| command.source_path.as_str())
435 .collect::<Vec<_>>()
436 .join(", ")
437 ),
438 );
439 }
440 if spec.is_bare_tree() && source.is_file(".mcp.json") {
441 warn(
442 ".mcp.json",
443 "import.mcp-not-imported",
444 "this is a skill tree, not a Claude plugin (it has no `.claude-plugin/plugin.json`), so its MCP servers were not imported".into(),
445 );
446 }
447 if source.is_file("hooks/hooks.json") || spec.hooks {
448 let path = if source.is_file("hooks/hooks.json") {
449 "hooks/hooks.json"
450 } else {
451 crate::claude::manifest::PLUGIN_MANIFEST
452 };
453 warn(
454 path,
455 "import.hooks-not-converted",
456 "hooks are not converted yet".into(),
457 );
458 }
459}
460
461fn prepare_manifest(
462 bytes: &[u8],
463 upgrade: bool,
464 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
465 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
466) -> PreparedManifest {
467 let invalid_utf8 = std::str::from_utf8(bytes).err().map(|error| {
468 SchemaDiagnostic::new(
469 "text.invalid-utf8",
470 Severity::Error,
471 Vec::new(),
472 format!("mars.toml is not UTF-8: {error}"),
473 )
474 });
475 let (normalized_bytes, change, upgraded) = match std::str::from_utf8(bytes) {
476 Ok(text) if upgrade => match crate::upgrade::upgrade_manifest(text) {
477 Ok(result) => upgrade_result(text, result),
478 Err(error) => {
479 push(
480 diagnostics,
481 "mars.toml",
482 SchemaDiagnostic::new(
483 "manifest.upgrade-failed",
484 Severity::Error,
485 Vec::new(),
486 error.to_string(),
487 ),
488 );
489 (bytes.to_vec(), ChangeRecord::None, Vec::new())
490 }
491 },
492 Ok(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
493 Err(_) => (bytes.to_vec(), ChangeRecord::None, Vec::new()),
494 };
495 changes
496 .entry("mars.toml".to_string())
497 .or_default()
498 .extend(upgraded);
499 let reading = std::str::from_utf8(&normalized_bytes).map_or_else(
500 |_| crate::manifest::ManifestRead::unread(invalid_utf8.into_iter().collect()),
501 crate::manifest::read,
502 );
503 PreparedManifest {
504 source_path: Some("mars.toml".to_string()),
505 output_path: "mars.toml".to_string(),
506 normalized_bytes,
507 reading,
508 change,
509 }
510}
511
512type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
514
515#[derive(Clone, Copy)]
517enum Shaping {
518 None,
520 Rewrite(Rewrite),
522 Foreign(Dialect),
524}
525
526impl Shaping {
527 fn of(classification: &Classification, migrate: bool) -> Self {
528 match classification {
529 Classification::MarsLegacy => Self::Rewrite(crate::upgrade::upgrade_definition),
530 Classification::Schema1 if migrate => {
532 Self::Rewrite(crate::upgrade::relocate_removed_fields)
533 }
534 Classification::Foreign(choice) => Self::Foreign(choice.dialect),
535 _ => Self::None,
536 }
537 }
538}
539
540fn prepare_definition(
541 source: &impl TreeSource,
542 selected: &SelectedDefinition,
543 shaping: Shaping,
544 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
545 changes: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
546) -> PreparedDefinition {
547 let Some(bytes) = source.read(&selected.source_path) else {
548 return PreparedDefinition {
549 kind: selected.kind,
550 slug: selected.slug.clone(),
551 source_path: selected.source_path.clone(),
552 output_path: selected.output_path.clone(),
553 skill_root: selected.skill_root.clone(),
554 normalized_bytes: Vec::new(),
555 reading: unreadable(
556 selected.kind,
557 SchemaDiagnostic::new(
558 "definition.read-missing",
559 Severity::Error,
560 Vec::new(),
561 "selected definition could not be read",
562 ),
563 ),
564 change: ChangeRecord::None,
565 };
566 };
567 let (mut notes, mut findings) = (Vec::new(), Vec::new());
568 let (normalized_bytes, change) = match std::str::from_utf8(&bytes) {
569 Ok(text) => match shaping {
570 Shaping::Rewrite(rewrite) => match rewrite(selected.kind, text) {
571 Ok(result) => {
572 let (rewritten, change, upgrade_notes) = upgrade_result(text, result);
573 notes = upgrade_notes;
574 (rewritten, change)
575 }
576 Err(UpgradeError::Frontmatter(_)) => (bytes.clone(), ChangeRecord::None),
579 Err(error) => {
580 push(
581 diagnostics,
582 &selected.source_path,
583 SchemaDiagnostic::new(
584 "definition.upgrade-failed",
585 Severity::Error,
586 Vec::new(),
587 error.to_string(),
588 ),
589 );
590 (bytes.clone(), ChangeRecord::None)
591 }
592 },
593 Shaping::Foreign(dialect) => {
594 let translated = translate_foreign(text, dialect, selected, &bytes);
595 notes = translated.changes;
596 findings = translated.findings;
597 (translated.bytes, translated.change)
598 }
599 Shaping::None => (bytes.clone(), ChangeRecord::None),
600 },
601 Err(_) => (bytes.clone(), ChangeRecord::None),
602 };
603 changes
604 .entry(selected.source_path.clone())
605 .or_default()
606 .extend(notes);
607 diagnostics
608 .entry(selected.source_path.clone())
609 .or_default()
610 .extend(findings);
611
612 let reading = match std::str::from_utf8(&normalized_bytes) {
614 Ok(text) => read_definition(selected.kind, text),
615 Err(error) => unreadable(
616 selected.kind,
617 SchemaDiagnostic::new(
618 "text.invalid-utf8",
619 Severity::Error,
620 Vec::new(),
621 format!("definition is not UTF-8: {error}"),
622 ),
623 ),
624 };
625
626 PreparedDefinition {
627 kind: selected.kind,
628 slug: selected.slug.clone(),
629 source_path: selected.source_path.clone(),
630 output_path: selected.output_path.clone(),
631 skill_root: selected.skill_root.clone(),
632 normalized_bytes,
633 reading,
634 change,
635 }
636}
637
638struct Translated {
640 bytes: Vec<u8>,
641 change: ChangeRecord,
642 changes: Vec<SchemaDiagnostic>,
644 findings: Vec<SchemaDiagnostic>,
646}
647
648impl Translated {
649 fn untouched(bytes: &[u8]) -> Self {
650 Self {
651 bytes: bytes.to_vec(),
652 change: ChangeRecord::None,
653 changes: Vec::new(),
654 findings: Vec::new(),
655 }
656 }
657}
658
659fn translate_foreign(
663 text: &str,
664 dialect: Dialect,
665 selected: &SelectedDefinition,
666 bytes: &[u8],
667) -> Translated {
668 let claude = matches!(dialect, Dialect::Claude | Dialect::AgentSkills);
669 let mut changes = Vec::new();
670 let mut repaired = false;
671 let frontmatter = match Frontmatter::parse(text) {
672 Ok(frontmatter) => frontmatter,
673 Err(_) if claude && selected.kind == DefinitionKind::Skill => {
674 match crate::claude::translate::repair_description(text)
675 .and_then(|fixed| Frontmatter::parse(&fixed).ok())
676 {
677 Some(frontmatter) => {
678 repaired = true;
679 changes.push(crate::claude::translate::repaired_diagnostic());
680 frontmatter
681 }
682 None => return Translated::untouched(bytes),
683 }
684 }
685 Err(_) => return Translated::untouched(bytes),
686 };
687 let lifted = crate::lift::lift_frontmatter(dialect, selected.kind, &frontmatter);
688 changes.extend(lifted.changes);
689 let mut findings = lifted.findings;
690 if claude && selected.kind == DefinitionKind::Skill {
691 findings.extend(crate::claude::checks::skill_checks(
692 selected,
693 &lifted.frontmatter,
694 ));
695 }
696 let (bytes, change) = if lifted.changed || repaired {
697 (
698 lifted.frontmatter.render().into_bytes(),
699 ChangeRecord::FullyRewritten,
700 )
701 } else {
702 (bytes.to_vec(), ChangeRecord::None)
703 };
704 Translated {
705 bytes,
706 change,
707 changes,
708 findings,
709 }
710}
711
712fn unreadable(kind: DefinitionKind, diagnostic: SchemaDiagnostic) -> Reading {
714 match kind {
715 DefinitionKind::Agent => Reading::Agent(DefinitionRead::unread(vec![diagnostic])),
716 DefinitionKind::Skill => Reading::Skill(DefinitionRead::unread(vec![diagnostic])),
717 }
718}
719
720fn upgrade_result(
721 original: &str,
722 result: UpgradeResult,
723) -> (Vec<u8>, ChangeRecord, Vec<SchemaDiagnostic>) {
724 let change = if !result.changed_from(original) {
725 ChangeRecord::None
726 } else if result.fully_rewritten {
727 ChangeRecord::FullyRewritten
728 } else {
729 ChangeRecord::Edited {
730 paths: result.edited_paths,
731 }
732 };
733 (result.text.into_bytes(), change, result.diagnostics)
734}
735
736fn push(
737 diagnostics: &mut BTreeMap<String, Vec<SchemaDiagnostic>>,
738 path: &str,
739 diagnostic: SchemaDiagnostic,
740) {
741 diagnostics
742 .entry(path.to_owned())
743 .or_default()
744 .push(diagnostic);
745}