Skip to main content

mars_schema/
read.rs

1//! The serialized v1 reading shared by native and WASM callers.
2
3use serde::Serialize;
4
5use crate::diagnostic::SchemaDiagnostic;
6use crate::frontmatter::Frontmatter;
7use crate::item::DefinitionKind;
8use crate::ordered::OrderedValue;
9
10/// The serialized v1 reading of one schema-owned file.
11///
12/// Serializes as the bare [`DefinitionRead`]; the caller knows the kind from the path (or, in
13/// a prepared package, from the definition's `kind`).
14#[derive(Debug, Clone, Serialize)]
15#[serde(untagged)]
16pub enum Reading {
17    Manifest(DefinitionRead<crate::package::PackageMetadata>),
18    Agent(DefinitionRead<crate::agent::AgentProfile>),
19    Skill(DefinitionRead<crate::skill::SkillProfile>),
20}
21
22impl Reading {
23    pub fn diagnostics(&self) -> &[SchemaDiagnostic] {
24        match self {
25            Self::Manifest(reading) => &reading.diagnostics,
26            Self::Agent(reading) => &reading.diagnostics,
27            Self::Skill(reading) => &reading.diagnostics,
28        }
29    }
30}
31
32/// `read` was given a path that is not a schema-owned file.
33#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34#[error(
35    "`{0}` is not a schema file: expected `mars.toml`, `agents/<slug>.md` or `skills/<slug>/SKILL.md`"
36)]
37pub struct UnrecognizedPath(pub String);
38
39/// Read an Agent from an installed `.mars` tree.
40///
41/// The bytes are what the Mars that installed them wrote, not an authored schema v1 file: a tree
42/// an older Mars wrote carries the pre-v1 spellings (`type`, `when_to_use`, a `tools` list,
43/// `permission`, ...) that the strict reader refuses. When the strict reading reports one, the
44/// file is upgraded in memory with the same conversion `prepare` applies to a pre-v1 package and
45/// read again. A file that reports any other error is simply invalid, and keeps that error: only
46/// a spelling the upgrade can cure says the bytes predate schema v1. The upgrade's notes are
47/// about the authored file, which `mars sync` and `mars migrate` report, so only the reading is
48/// returned. An authored package's own Agent is read strictly with [`crate::agent::read`].
49pub fn installed_agent(text: &str) -> DefinitionRead<crate::agent::AgentProfile> {
50    installed(text, DefinitionKind::Agent, crate::agent::read)
51}
52
53/// Read a skill from an installed `.mars` tree; the skill counterpart of [`installed_agent`].
54pub fn installed_skill(text: &str) -> DefinitionRead<crate::skill::SkillProfile> {
55    installed(text, DefinitionKind::Skill, crate::skill::read)
56}
57
58fn installed<T>(
59    text: &str,
60    kind: DefinitionKind,
61    strict: impl Fn(&str) -> DefinitionRead<T>,
62) -> DefinitionRead<T> {
63    let reading = strict(text);
64    // `field.removed` is a key or value the reader names, and `tools.list-form` is the one pre-v1
65    // shape Mars rewrote without removing a key. Both are what `upgrade_definition` converts.
66    let pre_v1 = reading.diagnostics.iter().any(|diagnostic| {
67        matches!(
68            diagnostic.code.as_str(),
69            "field.removed" | "tools.list-form"
70        )
71    });
72    if !pre_v1 {
73        return reading;
74    }
75    match crate::upgrade::upgrade_definition(kind, text) {
76        Ok(upgraded) => strict(&upgraded.text),
77        // A file the upgrade cannot parse is one the strict reader reports on as well.
78        Err(_) => reading,
79    }
80}
81
82/// Read one schema-owned file, choosing the reader from its package-relative path.
83pub fn read(path: &str, text: &str) -> Result<Reading, UnrecognizedPath> {
84    let parts: Vec<&str> = path.split('/').collect();
85    match parts.as_slice() {
86        ["mars.toml"] => Ok(Reading::Manifest(crate::manifest::read(text))),
87        ["agents", file]
88            if file
89                .strip_suffix(".md")
90                .is_some_and(|slug| !slug.is_empty()) =>
91        {
92            Ok(read_definition(DefinitionKind::Agent, text))
93        }
94        ["skills", slug, "SKILL.md"] if !slug.is_empty() => {
95            Ok(read_definition(DefinitionKind::Skill, text))
96        }
97        _ => Err(UnrecognizedPath(path.to_owned())),
98    }
99}
100
101/// Read one Agent or skill file, the one way: the structure-preserving parse (an anchor, a
102/// document-end marker and the like are diagnostics beside the fields, never a refusal to read
103/// them), then the kind's field reader. `agent::read`, `skill::read`, `read` and `prepare`
104/// are this function.
105pub(crate) fn read_definition(kind: DefinitionKind, text: &str) -> Reading {
106    match kind {
107        DefinitionKind::Agent => Reading::Agent(crate::agent::read(text)),
108        DefinitionKind::Skill => Reading::Skill(crate::skill::read(text)),
109    }
110}
111
112/// What the strict field reader says about `fm`, without the structure parse.
113pub(crate) fn field_diagnostics(kind: DefinitionKind, fm: &Frontmatter) -> Vec<SchemaDiagnostic> {
114    let mut diagnostics = Vec::new();
115    match kind {
116        DefinitionKind::Agent => {
117            crate::agent::read_fields(fm, &mut diagnostics);
118        }
119        DefinitionKind::Skill => {
120            crate::skill::read_fields(fm, &mut diagnostics);
121        }
122    }
123    diagnostics
124}
125
126/// The structure-preserving parse and ordered frontmatter shared by the Agent and skill
127/// readers; `fields` is the kind's field reader. Diagnostics come in the order structure,
128/// fields, then values with no JSON form.
129pub(crate) fn read_with<T>(
130    text: &str,
131    fields: impl FnOnce(&Frontmatter, &mut Vec<SchemaDiagnostic>) -> T,
132) -> DefinitionRead<T> {
133    match Frontmatter::parse_preserving_structure(text) {
134        Ok((frontmatter, structural_errors)) => {
135            let mut diagnostics = structural_errors
136                .iter()
137                .map(crate::diagnostic::frontmatter_error)
138                .collect::<Vec<_>>();
139            let fields = fields(&frontmatter, &mut diagnostics);
140            DefinitionRead {
141                frontmatter: frontmatter_json(frontmatter.mapping(), &mut diagnostics),
142                fields: Some(fields),
143                body: Some(frontmatter.body().to_string()),
144                diagnostics,
145            }
146        }
147        Err(error) => DefinitionRead::unread(vec![crate::diagnostic::frontmatter_error(&error)]),
148    }
149}
150
151#[derive(Debug, Clone, Serialize)]
152pub struct DefinitionRead<T> {
153    /// Ordered top-level mapping, retaining unknown authored keys.
154    pub frontmatter: Vec<(String, OrderedValue)>,
155    pub fields: Option<T>,
156    pub body: Option<String>,
157    pub diagnostics: Vec<SchemaDiagnostic>,
158}
159
160/// A definition with no errors: see [`DefinitionRead::into_valid`].
161#[derive(Debug, Clone)]
162pub struct Valid<T> {
163    pub fields: T,
164    pub body: String,
165    pub warnings: Vec<SchemaDiagnostic>,
166}
167
168impl<T> DefinitionRead<T> {
169    /// A file that could not be read: no fields, only why.
170    pub(crate) fn unread(diagnostics: Vec<SchemaDiagnostic>) -> Self {
171        Self {
172            frontmatter: Vec::new(),
173            fields: None,
174            body: None,
175            diagnostics,
176        }
177    }
178
179    /// The typed fields, or why there are none: the frontmatter cannot be parsed at all.
180    pub fn into_fields(self) -> Result<T, String> {
181        self.fields.ok_or_else(|| {
182            self.diagnostics
183                .first()
184                .map_or("unreadable".to_owned(), |diagnostic| {
185                    diagnostic.message.clone()
186                })
187        })
188    }
189
190    /// What a caller that acts on the file needs: the fields, the body and the warnings that
191    /// came with them, or the first error. A file with any error is refused whole.
192    pub fn into_valid(self) -> Result<Valid<T>, SchemaDiagnostic> {
193        if let Some(error) = self
194            .diagnostics
195            .iter()
196            .find(|diagnostic| diagnostic.is_error())
197        {
198            return Err(error.clone());
199        }
200        match (self.fields, self.body) {
201            (Some(fields), Some(body)) => Ok(Valid {
202                fields,
203                body,
204                warnings: self.diagnostics,
205            }),
206            _ => unreachable!("a reading with no error has its fields and body"),
207        }
208    }
209}
210
211pub(crate) fn frontmatter_json(
212    mapping: &serde_yaml::Mapping,
213    diagnostics: &mut Vec<SchemaDiagnostic>,
214) -> Vec<(String, OrderedValue)> {
215    mapping
216        .iter()
217        .filter_map(|(key, value)| {
218            let Some(key) = key.as_str() else {
219                diagnostics.push(SchemaDiagnostic::new(
220                    "yaml.non-string-key",
221                    crate::diagnostic::Severity::Error,
222                    Vec::new(),
223                    "frontmatter keys must be strings",
224                ));
225                return None;
226            };
227            match OrderedValue::from_yaml(value) {
228                Ok(value) => Some((key.to_owned(), value)),
229                Err(message) => {
230                    diagnostics.push(SchemaDiagnostic::new(
231                        "yaml.non-json-value",
232                        crate::diagnostic::Severity::Error,
233                        vec![crate::diagnostic::PathSegment::Key(key.to_owned())],
234                        message,
235                    ));
236                    None
237                }
238            }
239        })
240        .collect()
241}