1use super::manifest::SkillManifest;
6use std::fmt;
7
8#[derive(Debug)]
9pub enum ParseError {
10 Yaml(serde_yaml_ng::Error),
11 MissingFrontmatter,
12 MalformedFrontmatter(String),
13 LegacyMarkdown(String),
14}
15
16impl fmt::Display for ParseError {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 ParseError::Yaml(e) => write!(f, "yaml parse: {e}"),
20 ParseError::MissingFrontmatter => write!(f, "missing `---` frontmatter delimiters"),
21 ParseError::MalformedFrontmatter(s) => write!(f, "malformed frontmatter: {s}"),
22 ParseError::LegacyMarkdown(s) => write!(f, "legacy markdown: {s}"),
23 }
24 }
25}
26
27impl std::error::Error for ParseError {}
28
29impl From<serde_yaml_ng::Error> for ParseError {
30 fn from(e: serde_yaml_ng::Error) -> Self {
31 ParseError::Yaml(e)
32 }
33}
34
35pub fn parse_canonical(yaml: &str) -> Result<SkillManifest, ParseError> {
37 let m: SkillManifest = serde_yaml_ng::from_str(yaml)?;
38 Ok(m)
39}
40
41pub fn serialize_canonical(m: &SkillManifest) -> Result<String, ParseError> {
44 Ok(serde_yaml_ng::to_string(m)?)
45}
46
47pub fn parse_markdown(input: &str) -> Result<SkillManifest, ParseError> {
53 let (frontmatter, body) = split_frontmatter(input)?;
54 let mut value: serde_yaml_ng::Value = serde_yaml_ng::from_str(frontmatter)?;
55 fill_manifest_defaults(&mut value)?;
59 inject_content_from_body(&mut value, body)?;
60 let m: SkillManifest = serde_yaml_ng::from_value(value)?;
61 Ok(m)
62}
63
64fn fill_manifest_defaults(value: &mut serde_yaml_ng::Value) -> Result<(), ParseError> {
67 use serde_yaml_ng::Value;
68 let map = value
69 .as_mapping_mut()
70 .ok_or_else(|| ParseError::MalformedFrontmatter("frontmatter is not a mapping".into()))?;
71 let key = |k: &str| Value::String(k.into());
72 map.entry(key("version"))
73 .or_insert(Value::String("0.0.0".into()));
74 map.entry(key("publisher"))
75 .or_insert(Value::String("human:import".into()));
76 map.entry(key("category"))
77 .or_insert(Value::String("context".into()));
78 Ok(())
79}
80
81fn split_frontmatter(input: &str) -> Result<(&str, &str), ParseError> {
82 let trimmed = input.trim_start_matches('\u{feff}');
83 let trimmed = trimmed
84 .strip_prefix("---")
85 .ok_or(ParseError::MissingFrontmatter)?;
86 let trimmed = trimmed.strip_prefix('\n').unwrap_or(trimmed);
87 let end = trimmed
88 .find("\n---")
89 .ok_or_else(|| ParseError::MalformedFrontmatter("missing closing `---`".into()))?;
90 let frontmatter = &trimmed[..end];
91 let after = &trimmed[end + 4..];
92 let body = after.strip_prefix('\n').unwrap_or(after);
93 Ok((frontmatter, body))
94}
95
96fn inject_content_from_body(
97 value: &mut serde_yaml_ng::Value,
98 body: &str,
99) -> Result<(), ParseError> {
100 use serde_yaml_ng::Value;
101
102 if let Some(map) = value.as_mapping_mut() {
103 if map.contains_key(Value::String("content".into())) {
104 return Ok(()); }
106 let mut content = serde_yaml_ng::Mapping::new();
107
108 if body.contains("## Steps") {
109 let abstract_text = strip_leading_h1(body)
112 .split("## Steps")
113 .next()
114 .unwrap_or("")
115 .trim()
116 .to_string();
117 content.insert(
118 Value::String("abstract".into()),
119 Value::String(abstract_text),
120 );
121 let proc = build_procedure_from_steps(body);
122 content.insert(Value::String("procedure".into()), proc);
123 } else {
124 let (abstract_text, context_text) = split_abstract_and_context(body);
128 content.insert(
129 Value::String("abstract".into()),
130 Value::String(abstract_text),
131 );
132 content.insert(Value::String("context".into()), Value::String(context_text));
133 }
134 map.insert(Value::String("content".into()), Value::Mapping(content));
135 } else {
136 return Err(ParseError::MalformedFrontmatter(
137 "frontmatter is not a mapping".into(),
138 ));
139 }
140 Ok(())
141}
142
143fn strip_leading_h1(body: &str) -> &str {
147 let trimmed = body.trim_start_matches(['\n', '\r']);
148 if let Some(rest) = trimmed.strip_prefix("# ") {
149 match rest.find('\n') {
151 Some(nl) => rest[nl + 1..].trim_start_matches(['\n', '\r']),
152 None => "",
153 }
154 } else {
155 trimmed
156 }
157}
158
159fn split_abstract_and_context(body: &str) -> (String, String) {
164 let rest = strip_leading_h1(body);
165 match rest.find("\n\n") {
167 Some(idx) => {
168 let abstract_text = rest[..idx].trim().to_string();
169 let context_text = rest[idx + 2..].trim().to_string();
170 (abstract_text, context_text)
171 }
172 None => {
173 (rest.trim().to_string(), String::new())
175 }
176 }
177}
178
179fn build_procedure_from_steps(body: &str) -> serde_yaml_ng::Value {
180 use serde_yaml_ng::{Mapping, Value};
181 let mut steps = Vec::new();
182 let mut in_steps = false;
183 for line in body.lines() {
184 if line.trim_start().starts_with("## Steps") {
185 in_steps = true;
186 continue;
187 }
188 if in_steps && line.starts_with("## ") {
189 break;
190 }
191 if in_steps {
192 let trimmed = line.trim();
193 if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| {
194 trimmed.find(". ").and_then(|i| {
195 let (n, r) = trimmed.split_at(i);
196 n.chars().all(|c| c.is_ascii_digit()).then(|| &r[2..])
197 })
198 }) {
199 let mut step = Mapping::new();
200 step.insert(
201 Value::String("description".into()),
202 Value::String(rest.to_string()),
203 );
204 steps.push(Value::Mapping(step));
205 }
206 }
207 }
208 let mut procedure = Mapping::new();
209 procedure.insert(Value::String("steps".into()), Value::Sequence(steps));
210 Value::Mapping(procedure)
211}
212
213pub fn serialize_markdown(m: &SkillManifest) -> Result<String, ParseError> {
217 let frontmatter = serialize_canonical_frontmatter(m)?;
218 let mut out = String::new();
219 out.push_str("---\n");
220 out.push_str(&frontmatter);
221 out.push_str("---\n\n");
222 out.push_str(&format!("# {}\n\n", m.name));
226 out.push_str(m.content.r#abstract.trim());
227 out.push('\n');
228 if let Some(ctx) = &m.content.context {
229 out.push('\n');
230 out.push_str(ctx.trim_end());
231 out.push('\n');
232 } else if let Some(proc) = &m.content.procedure {
233 out.push_str("\n## Steps\n");
234 for (i, s) in proc.steps.iter().enumerate() {
235 out.push_str(&format!("{}. {}\n", i + 1, s.description));
236 }
237 } else if let Some(cmd) = &m.content.command {
238 out.push_str("\n## Command\n\n```\n");
239 out.push_str(cmd);
240 out.push_str("\n```\n");
241 }
242 Ok(out)
243}
244
245fn serialize_canonical_frontmatter(m: &SkillManifest) -> Result<String, ParseError> {
248 let mut value = serde_yaml_ng::to_value(m)?;
249 if let Some(map) = value.as_mapping_mut() {
250 map.remove(serde_yaml_ng::Value::String("content".into()));
251 }
252 Ok(serde_yaml_ng::to_string(&value)?)
253}
254
255pub fn parse_legacy_markdown(input: &str) -> Result<SkillManifest, ParseError> {
259 let (frontmatter, body) = split_frontmatter(input)?;
260 let mut value: serde_yaml_ng::Value = serde_yaml_ng::from_str(frontmatter)?;
261 let map = value
262 .as_mapping_mut()
263 .ok_or_else(|| ParseError::LegacyMarkdown("frontmatter is not a mapping".into()))?;
264 use serde_yaml_ng::Value;
265 map.entry(Value::String("publisher".into()))
266 .or_insert(Value::String("human:mur".into()));
267 fill_manifest_defaults(&mut value)?;
268 inject_content_from_body(&mut value, body)?;
269 let m: SkillManifest = serde_yaml_ng::from_value(value)?;
270 Ok(m)
271}
272
273pub fn yaml_to_markdown(yaml: &str) -> Result<String, ParseError> {
277 let m = parse_canonical(yaml)?;
278 serialize_markdown(&m)
279}
280
281pub fn roundtrip_check(m: &SkillManifest) -> Result<(), String> {
288 let md = serialize_markdown(m).map_err(|e| format!("serialize_markdown: {e}"))?;
289 let reparsed = parse_markdown(&md).map_err(|e| format!("parse_markdown: {e}"))?;
290
291 if reparsed.content.r#abstract.trim() != m.content.r#abstract.trim() {
292 return Err(format!(
293 "abstract differs after round-trip\n original: {:?}\n roundtrip: {:?}",
294 m.content.r#abstract, reparsed.content.r#abstract
295 ));
296 }
297
298 let orig_ctx = m.content.context.as_deref().map(str::trim_end);
300 let rt_ctx = reparsed.content.context.as_deref().map(str::trim_end);
301 if orig_ctx != rt_ctx {
302 return Err(format!(
303 "context differs after round-trip\n original: {orig_ctx:?}\n roundtrip: {rt_ctx:?}"
304 ));
305 }
306
307 Ok(())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 const SAMPLE: &str = r#"
315name: demo-skill
316version: 0.1.0
317publisher: human:test
318description: Demo
319category: context
320content:
321 abstract: hello
322 context: |
323 body
324"#;
325
326 #[test]
327 fn parses_canonical_yaml() {
328 let m = parse_canonical(SAMPLE).unwrap();
329 assert_eq!(m.name, "demo-skill");
330 assert_eq!(m.content.context.as_deref(), Some("body\n"));
331 }
332
333 #[test]
334 fn serialize_then_reparse_is_identity() {
335 let m = parse_canonical(SAMPLE).unwrap();
336 let yaml = serialize_canonical(&m).unwrap();
337 let m2 = parse_canonical(&yaml).unwrap();
338 assert_eq!(m.name, m2.name);
339 assert_eq!(m.content.context, m2.content.context);
340 }
341
342 #[test]
343 fn rejects_non_yaml_input() {
344 let r = parse_canonical("this is not yaml ::: {{");
345 assert!(r.is_err());
346 }
347
348 #[test]
349 fn parses_markdown_frontmatter_to_context_mode() {
350 let md = r#"---
351name: simple-md
352version: 1.0.0
353publisher: human:test
354description: A markdown skill
355category: context
356---
357
358# simple-md
359
360Some context content here.
361"#;
362 let m = parse_markdown(md).unwrap();
363 assert_eq!(m.name, "simple-md");
364 assert!(m.content.context.is_some());
365 assert!(m.content.procedure.is_none());
366 }
367
368 #[test]
369 fn parses_markdown_with_steps_to_workflow_mode() {
370 let md = r#"---
371name: with-steps
372version: 1.0.0
373publisher: human:test
374description: A workflow
375category: workflow
376---
377
378# with-steps
379
380Does a thing.
381
382## Steps
3831. Navigate somewhere
3842. Click the button
385- Final extraction step
386"#;
387 let m = parse_markdown(md).unwrap();
388 let proc = m.content.procedure.expect("procedure populated");
389 assert_eq!(proc.steps.len(), 3);
390 assert_eq!(proc.steps[0].description, "Navigate somewhere");
391 }
392
393 #[test]
394 fn external_claude_style_frontmatter_defaults_mur_fields() {
395 let md = "---\nname: ponytail\ndescription: Lazy senior dev mode.\n---\n\nBe lazy.\n";
397 let m = parse_markdown(md).unwrap();
398 assert_eq!(m.version, "0.0.0");
399 assert_eq!(m.publisher, "human:import");
400 assert!(super::super::validate::validate(&m).is_ok());
401 }
402
403 #[test]
404 fn markdown_without_frontmatter_fails() {
405 let md = "# just a heading\n";
406 assert!(matches!(
407 parse_markdown(md),
408 Err(ParseError::MissingFrontmatter)
409 ));
410 }
411
412 #[test]
413 fn canonical_to_markdown_roundtrips_context() {
414 let m = parse_canonical(SAMPLE).unwrap();
415 let md = serialize_markdown(&m).unwrap();
416 let m2 = parse_markdown(&md).unwrap();
417 assert_eq!(m.name, m2.name);
418 assert_eq!(m.content.context.is_some(), m2.content.context.is_some());
419 }
420
421 #[test]
422 fn canonical_to_markdown_roundtrips_workflow() {
423 let yaml = r#"
424name: w
425version: 1.0.0
426publisher: human:test
427description: d
428category: workflow
429content:
430 abstract: a
431 procedure:
432 steps:
433 - description: First
434 - description: Second
435"#;
436 let m = parse_canonical(yaml).unwrap();
437 let md = serialize_markdown(&m).unwrap();
438 let m2 = parse_markdown(&md).unwrap();
439 let p2 = m2.content.procedure.unwrap();
440 assert_eq!(p2.steps.len(), 2);
441 assert_eq!(p2.steps[0].description, "First");
442 }
443
444 #[test]
445 fn legacy_minimal_frontmatter_loads() {
446 let md =
447 "---\nname: mur-context\ndescription: Background context\n---\n\n# MUR\n\nSome body.\n";
448 let m = parse_legacy_markdown(md).unwrap();
449 assert_eq!(m.name, "mur-context");
450 assert_eq!(m.publisher, "human:mur");
451 assert_eq!(m.version, "0.0.0");
452 assert!(m.content.context.is_some());
453 }
454
455 #[test]
456 fn yaml_to_markdown_yields_consumable_md() {
457 let md = yaml_to_markdown(SAMPLE).unwrap();
458 assert!(md.starts_with("---"), "should start with frontmatter fence");
459 assert!(md.contains("# demo-skill"), "should contain heading");
460 assert!(md.contains("hello"), "should contain abstract");
461 assert!(md.contains("body"), "should contain context body");
462 }
463
464 #[test]
467 fn yaml_md_yaml_roundtrip_preserves_abstract_and_context() {
468 let yaml = r#"
469name: roundtrip-demo
470version: 2.3.1
471publisher: human:alan
472description: A skill exercising lossless round-trip.
473category: context
474tags: [alpha, beta]
475triggers:
476 - type: keyword
477 pattern: roundtrip
478content:
479 abstract: |-
480 This skill does one specific thing. It is described in two full sentences so
481 the truncation bug would be obvious.
482 context: |-
483 First context paragraph with real prose that should survive verbatim.
484
485 ## A Heading
486
487 Some explanation under the heading.
488
489 ```rust
490 fn demo() {
491 println!("code fence must survive");
492 }
493 ```
494
495 Final closing paragraph.
496"#;
497 let m = parse_canonical(yaml).unwrap();
498 let md = serialize_markdown(&m).unwrap();
499 let m2 = parse_markdown(&md).unwrap();
500
501 assert_eq!(
502 m2.content.r#abstract, m.content.r#abstract,
503 "abstract must round-trip exactly"
504 );
505 assert_eq!(
506 m2.content.context, m.content.context,
507 "context must round-trip exactly"
508 );
509 assert_eq!(m2.version, m.version);
511 assert_eq!(m2.publisher, m.publisher);
512 assert_eq!(m2.tags, m.tags);
513 assert_eq!(m2.triggers.len(), m.triggers.len());
514 assert_eq!(m2.triggers[0].pattern, m.triggers[0].pattern);
515 }
516
517 #[test]
520 fn md_yaml_md_roundtrip_stable() {
521 let md = r#"---
522name: handauthored
523version: 1.2.0
524publisher: human:alan
525description: Hand-authored markdown skill.
526category: context
527---
528
529# handauthored
530
531This is the abstract. It spans two sentences on purpose.
532
533This is the first body paragraph.
534
535## Details
536
537More body content here, including a list:
538
539- one
540- two
541"#;
542 let m1 = parse_markdown(md).unwrap();
543 let md2 = serialize_markdown(&m1).unwrap();
544 let m2 = parse_markdown(&md2).unwrap();
545
546 assert_eq!(
547 m1.content.r#abstract, m2.content.r#abstract,
548 "abstract must be stable across md→yaml→md"
549 );
550 assert_eq!(
551 m1.content.context, m2.content.context,
552 "context must be stable across md→yaml→md"
553 );
554 assert_eq!(m1.name, m2.name);
555 assert_eq!(m1.version, m2.version);
556 assert_ne!(m1.content.r#abstract.trim(), "# handauthored");
558 assert!(
559 m1.content.r#abstract.contains("This is the abstract."),
560 "abstract should be the first paragraph, got: {:?}",
561 m1.content.r#abstract
562 );
563 }
564
565 #[test]
566 fn roundtrip_check_passes_for_faithful_context_skill() {
567 let m = parse_canonical(SAMPLE).unwrap();
568 assert!(roundtrip_check(&m).is_ok());
569 }
570
571 #[test]
572 fn roundtrip_check_passes_for_multiparagraph_abstract_and_context() {
573 let yaml = r#"
574name: rc-demo
575version: 1.0.0
576publisher: human:test
577description: d
578category: context
579content:
580 abstract: |-
581 Sentence one of the abstract. Sentence two of the abstract.
582 context: |-
583 Para one.
584
585 Para two with a fence:
586
587 ```
588 code
589 ```
590"#;
591 let m = parse_canonical(yaml).unwrap();
592 assert!(
593 roundtrip_check(&m).is_ok(),
594 "expected faithful round-trip, got: {:?}",
595 roundtrip_check(&m)
596 );
597 }
598}