Skip to main content

mars_schema/
prepare.rs

1//! One-pass package classification, normalization, selection, and strict reading.
2
3use 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    /// A package root (a dependency, or the package being published): its `mars.toml` must
22    /// declare `[package]`, and a root with no manifest is read as the dialect it looks like.
23    Package,
24    /// A consumer's own authored directory (`.mars-src`): content without a manifest is Mars
25    /// convention, and a `mars.toml` without `[package]` is consumer configuration, not read.
26    AuthoredLocal,
27    /// A directory that is either: `Package` unless its `mars.toml` has no `[package]`, which
28    /// makes it a consumer project whose tree is read as `AuthoredLocal` content.
29    Project,
30}
31
32/// The directory below a consumer project that holds its own authored content.
33pub const LOCAL_SOURCE_DIR: &str = ".mars-src";
34
35/// Where a project directory's authored content lives and how it is read, from its `mars.toml`
36/// bytes: the project root as a `Package`, or, when the manifest is consumer configuration with
37/// no `[package]`, its `.mars-src` as `AuthoredLocal`. A caller that writes to the authored
38/// files, as `mars migrate` does, asks here instead of parsing `mars.toml` itself.
39pub 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/// What a package root is, once its manifest, declared dialect and contents have been weighed.
50#[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    /// Another harness's layout, read as the dialect chosen.
61    Foreign(DialectChoice),
62    /// A Claude marketplace: it lists plugins and holds no definitions of its own.
63    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    /// The file has no source: `normalize` wrote it (a package's generated `mars.toml`).
72    Generated,
73    Edited {
74        paths: Vec<Vec<PathSegment>>,
75    },
76    FullyRewritten,
77}
78
79#[derive(Debug, Clone, Serialize)]
80pub struct PreparedManifest {
81    /// `None` for a generated manifest.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub source_path: Option<String>,
84    pub output_path: String,
85    /// The normalized file; crosses to JavaScript as a `Uint8Array`.
86    #[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    /// The normalized file; crosses to JavaScript as a `Uint8Array`.
99    #[serde(with = "serde_bytes")]
100    pub normalized_bytes: Vec<u8>,
101    /// The Agent or skill reading `kind` names.
102    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    /// Package-level findings by source path: what discovery and translation met and could not
120    /// convert. A definition's own reading diagnostics live on its `reading` only; see
121    /// [`Prepared::all_diagnostics`].
122    pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
123    /// What preparing changed in each file, by source path: the keys a pre-v1 upgrade or a
124    /// foreign translation dropped, moved or repaired. A caller that only reads the package
125    /// reports [`Prepared::changed_files`]; `mars migrate` lists these.
126    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
127    pub changes: BTreeMap<String, Vec<SchemaDiagnostic>>,
128}
129
130impl Prepared {
131    /// The changes that lost a restriction (a `deny` or `ask` tool entry, `permission`,
132    /// `sandbox`, `approval`), by source path. Unlike the rest of `changes`, a caller reports
133    /// these on every run: each leaves an agent less restricted than its file says.
134    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    /// How many authored files preparation changed: the prepared bytes differ from the file, and
144    /// for a pre-v1 package `mars migrate` writes them back. A generated `mars.toml` has no file
145    /// to change and is not counted.
146    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    /// Every diagnostic by source path: package-level first, then the file's reading.
156    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/// What a caller adds when it reads a root as one plugin of a marketplace, or imports only some
179/// of the items.
180#[derive(Default, Clone, Copy)]
181pub(crate) struct RootOptions<'a> {
182    pub as_plugin: Option<AsPlugin<'a>>,
183    /// Source paths of the definitions to import; `None` imports every one found.
184    pub selection: Option<&'a BTreeSet<String>>,
185    /// Names a root `SKILL.md` when its own `name` cannot.
186    pub fallback_name: Option<&'a str>,
187    /// Repair what a schema 1 definition can still need: the replacement of an Agent key schema 1
188    /// removed in favour of a `harness-overrides` key. Only `mars migrate` asks, because it
189    /// writes the result back; a reader leaves it off, because an authored schema 1 file is read
190    /// exactly as written.
191    pub migrate: bool,
192}
193
194/// `prepare` plus what was discovered, for `inspect` and `normalize`.
195pub(crate) struct Rooted {
196    pub prepared: Prepared,
197    pub dispatched: Dispatched,
198}
199
200/// Prepare one rooted package without reading payload bytes.
201pub 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
209/// `prepare` as `mars migrate` runs it: a definition it could repair is repaired in
210/// `normalized_bytes`, for the migration to write back to the authored file.
211pub 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
327/// Commands are dropped, and hooks and MCP servers are not converted yet.
328fn 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
440/// A definition's authored keys, made schema v1.
441type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
442
443/// How the root's definitions are made schema v1, decided once for the root.
444#[derive(Clone, Copy)]
445enum Shaping {
446    /// Nothing to convert: each file is read as written.
447    None,
448    /// The pre-v1 upgrade, or `mars migrate`'s repair of a schema 1 file.
449    Rewrite(Rewrite),
450    /// A foreign dialect, translated into schema v1.
451    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            // Only `mars migrate` writes the result back, so only it repairs a schema 1 file.
459            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                // The reader reports a file that does not parse; the upgrade only met its
506                // error, so a second diagnostic would say the same thing twice.
507                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    // Only text is upgraded or translated, so these bytes are the file's when they are not text.
543    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
567/// What translating one foreign definition produced.
568struct Translated {
569    bytes: Vec<u8>,
570    change: ChangeRecord,
571    /// What the translation dropped, moved or repaired.
572    changes: Vec<SchemaDiagnostic>,
573    /// What it met and could not convert.
574    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
588/// Lift a foreign definition's frontmatter into schema v1. Frontmatter that cannot be read
589/// stays as written, with the reader's error; only a Claude skill's unquoted `description`
590/// colon is repaired first.
591fn 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
645/// A definition with no fields to read, only why.
646fn 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}