Skip to main content

mur_common/skill/
parser.rs

1//! Dual-format parser. Canonical YAML is the source of truth; markdown
2//! frontmatter is the human-authoring surface that round-trips via
3//! `canonical_from_markdown()` / `markdown_from_canonical()`.
4
5use 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
35/// Parse canonical `skill.yaml`.
36pub fn parse_canonical(yaml: &str) -> Result<SkillManifest, ParseError> {
37    let m: SkillManifest = serde_yaml_ng::from_str(yaml)?;
38    Ok(m)
39}
40
41/// Serialise a `SkillManifest` to canonical YAML. Deterministic field order
42/// matches the struct definition.
43pub fn serialize_canonical(m: &SkillManifest) -> Result<String, ParseError> {
44    Ok(serde_yaml_ng::to_string(m)?)
45}
46
47/// Parse markdown-frontmatter skill source. Frontmatter (between two `---`
48/// fences) is YAML; the body becomes `content.abstract` plus — if it has a
49/// `## Steps` heading — a synthesised `content.procedure`, or otherwise a
50/// `content.context`. This is the human-authoring surface; canonical YAML
51/// remains source of truth on disk.
52pub 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    // Non-MUR skills (Claude Code plugins etc.) carry only `name` +
56    // `description`; default the MUR-specific fields so external SKILL.md
57    // files import seamlessly. `name`/`description` stay required.
58    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
64/// Default the MUR-specific manifest fields that external (Claude-style)
65/// SKILL.md frontmatter omits.
66fn 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(()); // frontmatter already supplied content
105        }
106        let mut content = serde_yaml_ng::Mapping::new();
107
108        if body.contains("## Steps") {
109            // Workflow mode: abstract is the prose before `## Steps`, with the
110            // optional leading `# Title` H1 stripped.
111            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            // Context mode: drop the optional `# Title` H1, take the first
125            // non-empty paragraph as the abstract, and the remainder (trimmed)
126            // as the context body. This is the inverse of `serialize_markdown`.
127            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
143/// Drop a single leading `# Title` H1 line (and the blank line that follows it)
144/// from a markdown body, returning the remainder. If there is no leading H1 the
145/// body is returned unchanged.
146fn strip_leading_h1(body: &str) -> &str {
147    let trimmed = body.trim_start_matches(['\n', '\r']);
148    if let Some(rest) = trimmed.strip_prefix("# ") {
149        // Skip to the end of the title line.
150        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
159/// Split a context-mode markdown body into `(abstract, context)`:
160/// drop an optional leading `# Title` H1, take the first non-empty paragraph as
161/// the abstract, and everything after the blank line that follows it as the
162/// context body. The inverse of the `serialize_markdown` context layout.
163fn split_abstract_and_context(body: &str) -> (String, String) {
164    let rest = strip_leading_h1(body);
165    // The abstract is the first paragraph: text up to the first blank line.
166    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            // No blank line: the whole remainder is the abstract, no context.
174            (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
213/// Render a `SkillManifest` back to markdown frontmatter form. The body is
214/// derived from the populated content mode: `context` → context body,
215/// `procedure` → "## Steps" list, `command` → fenced block.
216pub 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    // Single H1 title, then the abstract paragraph, then the body. The abstract
223    // is emitted verbatim (trimmed) followed by a blank line so the parser can
224    // recover it as the first paragraph. Do NOT emit a duplicate H1.
225    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
245/// Frontmatter is the manifest serialised *without* the `content` field —
246/// the content moves into the markdown body.
247fn 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
255/// Parse a legacy skill file — pre-M0 markdown with minimal frontmatter
256/// (just `name` + `description`). Fills in defaults so the file can be
257/// loaded by the new pipeline without rewriting it.
258pub 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
273/// Convenience: parse canonical YAML, serialise back to markdown.
274/// Used by `ensure_mur_skill` so built-in yaml skills produce
275/// AI-tool-consumable markdown at `SKILL.md`.
276pub fn yaml_to_markdown(yaml: &str) -> Result<String, ParseError> {
277    let m = parse_canonical(yaml)?;
278    serialize_markdown(&m)
279}
280
281/// Round-trip integrity guard: serialise the manifest to markdown, parse it
282/// back, and confirm the content survived. Used by `mur skill validate` to make
283/// silent abstract/context corruption visible. Only the `context` content mode
284/// is checked verbatim — procedure/command/note bodies are reconstructed
285/// structurally and compared by mode. Returns `Err(message)` describing the
286/// drift if the round-trip would alter content.
287pub 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    // Context is stored verbatim; compare trimmed to ignore trailing-newline noise.
299    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        // Claude Code plugin SKILL.md: only name + description.
396        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    /// A deliberate multi-sentence abstract plus a multi-paragraph context (with
465    /// a code fence and a `## Heading`) must survive yaml→md→yaml without loss.
466    #[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        // Other manifest fields survive via frontmatter.
510        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    /// A hand-authored markdown (title + abstract paragraph + body) parses, then
518    /// serializing and parsing again yields an identical manifest (idempotent).
519    #[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        // The abstract must NOT be the bare H1 title (the old truncation bug).
557        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}