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 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
338/// Commands are dropped, and hooks and MCP servers are not converted yet.
339fn 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
451/// A definition's authored keys, made schema v1.
452type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
453
454/// How the root's definitions are made schema v1, decided once for the root.
455#[derive(Clone, Copy)]
456enum Shaping {
457    /// Nothing to convert: each file is read as written.
458    None,
459    /// The pre-v1 upgrade, or `mars migrate`'s repair of a schema 1 file.
460    Rewrite(Rewrite),
461    /// A foreign dialect, translated into schema v1.
462    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            // Only `mars migrate` writes the result back, so only it repairs a schema 1 file.
470            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                // The reader reports a file that does not parse; the upgrade only met its
515                // error, so a second diagnostic would say the same thing twice.
516                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    // Only text is upgraded or translated, so these bytes are the file's when they are not text.
551    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
575/// What translating one foreign definition produced.
576struct Translated {
577    bytes: Vec<u8>,
578    change: ChangeRecord,
579    /// What the translation dropped, moved or repaired.
580    changes: Vec<SchemaDiagnostic>,
581    /// What it met and could not convert.
582    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
596/// Lift a foreign definition's frontmatter into schema v1. Frontmatter that cannot be read
597/// stays as written, with the reader's error; only a Claude skill's unquoted `description`
598/// colon is repaired first.
599fn 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
649/// A definition with no fields to read, only why.
650fn 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}