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