1use serde::Serialize;
4
5use crate::diagnostic::SchemaDiagnostic;
6use crate::frontmatter::Frontmatter;
7use crate::item::DefinitionKind;
8use crate::ordered::OrderedValue;
9
10#[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#[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 pub fn code(&self) -> &'static str {
42 "read.unrecognized-path"
43 }
44}
45
46pub fn installed_agent(text: &str) -> DefinitionRead<crate::agent::AgentProfile> {
57 installed(text, DefinitionKind::Agent, crate::agent::read)
58}
59
60pub 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 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 Err(_) => reading,
86 }
87}
88
89pub 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
108pub(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
119pub(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
133pub(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 pub frontmatter: Vec<(String, OrderedValue)>,
162 pub fields: Option<T>,
163 pub body: Option<String>,
164 pub diagnostics: Vec<SchemaDiagnostic>,
165}
166
167#[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 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 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 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}