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::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    /// A package root (a dependency, or the package being published): its `mars.toml` must
23    /// declare `[package]`, and a root with no manifest is read as the dialect it looks like.
24    Package,
25    /// A consumer's own authored directory (`.mars-src`): content without a manifest is Mars
26    /// convention, and a `mars.toml` without `[package]` is consumer configuration, not read.
27    AuthoredLocal,
28    /// A directory that is either: `Package` unless its `mars.toml` has no `[package]`, which
29    /// makes it a consumer project whose tree is read as `AuthoredLocal` content.
30    Project,
31}
32
33/// The directory below a consumer project that holds its own authored content.
34pub const LOCAL_SOURCE_DIR: &str = ".mars-src";
35
36/// Where a project directory's authored content lives and how it is read, from its `mars.toml`
37/// bytes: the project root as a `Package`, or, when the manifest is consumer configuration with
38/// no `[package]`, its `.mars-src` as `AuthoredLocal`. A caller that writes to the authored
39/// files, as `mars migrate` does, asks here instead of parsing `mars.toml` itself.
40pub 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/// What a package root is, once its manifest, declared dialect and contents have been weighed.
51#[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    /// Another harness's layout, read as the dialect chosen.
62    Foreign(DialectChoice),
63    /// A Claude marketplace: it lists plugins and holds no definitions of its own.
64    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    /// The file has no source: `normalize` wrote it (a package's generated `mars.toml`).
73    Generated,
74    Edited {
75        paths: Vec<Vec<PathSegment>>,
76    },
77    FullyRewritten,
78}
79
80#[derive(Debug, Clone, Serialize)]
81pub struct PreparedManifest {
82    /// `None` for a generated manifest.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub source_path: Option<String>,
85    pub output_path: String,
86    /// The normalized file; crosses to JavaScript as a `Uint8Array`.
87    #[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    /// A skill's folder in the package tree (empty for a root skill); `None` for an agent.
100    #[serde(skip)]
101    pub skill_root: Option<String>,
102    /// The normalized file; crosses to JavaScript as a `Uint8Array`.
103    #[serde(with = "serde_bytes")]
104    pub normalized_bytes: Vec<u8>,
105    /// The Agent or skill reading `kind` names.
106    pub reading: Reading,
107    pub change: ChangeRecord,
108}
109
110/// An MCP server translated from another dialect's declaration, as the `mcp/<name>/mcp.toml` it
111/// is written to. The same shape as a definition's output so a caller writes it the same way.
112#[derive(Debug, Clone, Serialize)]
113pub struct PreparedMcpServer {
114    pub name: String,
115    /// The file that declared the server (`.mcp.json`, `plugin.json`).
116    pub source_path: String,
117    pub output_path: String,
118    /// The rendered `mcp.toml`; crosses to JavaScript as a `Uint8Array`.
119    #[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    /// The MCP servers a foreign plugin declares, translated. Selection does not apply to them.
136    #[serde(skip_serializing_if = "Vec::is_empty")]
137    pub mcp_servers: Vec<PreparedMcpServer>,
138    pub payload: Vec<PayloadEntry>,
139    /// What item selection reads of this package (see [`selection_facts`]).
140    pub selection: SelectionFacts,
141    /// Package-level findings by source path: what discovery and translation met and could not
142    /// convert. A definition's own reading diagnostics live on its `reading` only; see
143    /// [`Prepared::all_diagnostics`].
144    pub diagnostics: BTreeMap<String, Vec<SchemaDiagnostic>>,
145    /// What preparing changed in each file, by source path: the keys a pre-v1 upgrade or a
146    /// foreign translation dropped, moved or repaired. A caller that only reads the package
147    /// reports [`Prepared::changed_files`]; `mars migrate` lists these.
148    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
149    pub changes: BTreeMap<String, Vec<SchemaDiagnostic>>,
150}
151
152/// What item selection reads of a prepared package: its Agents' and skills' skill refs, and the
153/// `[dependencies]` entries and `uses-providers` its manifest reads.
154pub(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    /// The changes that lost a restriction (a `deny` or `ask` tool entry, `permission`,
198    /// `sandbox`, `approval`), by source path. Unlike the rest of `changes`, a caller reports
199    /// these on every run: each leaves an agent less restricted than its file says.
200    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    /// How many authored files preparation changed: the prepared bytes differ from the file, and
210    /// for a pre-v1 package `mars migrate` writes them back. A generated `mars.toml` has no file
211    /// to change and is not counted.
212    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    /// Every diagnostic by source path: package-level first, then the file's reading.
222    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/// What a caller adds when it reads a root as one plugin of a marketplace, or imports only some
245/// of the items.
246#[derive(Default, Clone, Copy)]
247pub(crate) struct RootOptions<'a> {
248    pub as_plugin: Option<AsPlugin<'a>>,
249    /// Source paths of the definitions to import; `None` imports every one found.
250    pub selection: Option<&'a BTreeSet<String>>,
251    /// Names a root `SKILL.md` when its own `name` cannot.
252    pub fallback_name: Option<&'a str>,
253    /// Repair what a schema 1 definition can still need: the replacement of an Agent key schema 1
254    /// removed in favour of a `harness-overrides` key. Only `mars migrate` asks, because it
255    /// writes the result back; a reader leaves it off, because an authored schema 1 file is read
256    /// exactly as written.
257    pub migrate: bool,
258}
259
260/// `prepare` plus what was discovered, for `inspect` and `normalize`.
261pub(crate) struct Rooted {
262    pub prepared: Prepared,
263    pub dispatched: Dispatched,
264}
265
266/// Prepare one rooted package without reading payload bytes.
267pub 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
275/// `prepare` as `mars migrate` runs it: a definition it could repair is repaired in
276/// `normalized_bytes`, for the migration to write back to the authored file.
277pub 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        // A bare skill tree's `.mcp.json` is usually its contributors' own, not a plugin's.
365        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
403/// Commands are dropped and hooks are not converted yet. MCP servers are translated, not
404/// reported here (`claude::mcp`).
405fn 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
512/// A definition's authored keys, made schema v1.
513type Rewrite = fn(DefinitionKind, &str) -> Result<UpgradeResult, UpgradeError>;
514
515/// How the root's definitions are made schema v1, decided once for the root.
516#[derive(Clone, Copy)]
517enum Shaping {
518    /// Nothing to convert: each file is read as written.
519    None,
520    /// The pre-v1 upgrade, or `mars migrate`'s repair of a schema 1 file.
521    Rewrite(Rewrite),
522    /// A foreign dialect, translated into schema v1.
523    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            // Only `mars migrate` writes the result back, so only it repairs a schema 1 file.
531            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                // The reader reports a file that does not parse; the upgrade only met its
577                // error, so a second diagnostic would say the same thing twice.
578                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    // Only text is upgraded or translated, so these bytes are the file's when they are not text.
613    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
638/// What translating one foreign definition produced.
639struct Translated {
640    bytes: Vec<u8>,
641    change: ChangeRecord,
642    /// What the translation dropped, moved or repaired.
643    changes: Vec<SchemaDiagnostic>,
644    /// What it met and could not convert.
645    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
659/// Lift a foreign definition's frontmatter into schema v1. Frontmatter that cannot be read
660/// stays as written, with the reader's error; only a Claude skill's unquoted `description`
661/// colon is repaired first.
662fn 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
712/// A definition with no fields to read, only why.
713fn 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}