1use serde_json::Value;
40use std::collections::BTreeMap;
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct Directive {
45 pub name: String,
46 pub attrs: BTreeMap<String, String>,
47 pub body: String,
48 pub line: usize,
50}
51
52#[derive(Debug, Default, PartialEq)]
54pub struct Extraction {
55 pub cleaned: String,
60 pub workflows: Vec<Value>,
62 pub skills: Vec<InlineSkill>,
64 pub config: Value,
70}
71
72#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
75pub struct InlineSkill {
76 pub name: String,
77 #[serde(default)]
78 pub description: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub when_to_use: Option<String>,
81 pub body: String,
82}
83
84const KNOWN: &[&str] = &[
87 "workflow", "skill", "context", "example", "config", "mcp", "stream", "tools",
88];
89
90pub fn parse(text: &str) -> Result<(Vec<Segment>, Vec<Directive>), Vec<String>> {
93 let mut errs = Vec::new();
94 let mut segments = Vec::new();
95 let mut directives = Vec::new();
96 let mut plain = String::new();
97 let lines: Vec<&str> = text.split('\n').collect();
98 let mut i = 0;
99 while i < lines.len() {
100 let line = lines[i];
101 if let Some((fence_len, name, attr_src)) = open_fence(line) {
102 if !KNOWN.contains(&name.as_str()) {
103 errs.push(format!(
104 "line {}: unknown directive :::{name} (known: {})",
105 i + 1,
106 KNOWN.join(", ")
107 ));
108 }
109 let attrs = match parse_attrs(&attr_src) {
110 Ok(a) => a,
111 Err(e) => {
112 errs.push(format!("line {}: :::{name}: {e}", i + 1));
113 BTreeMap::new()
114 }
115 };
116 let open_line = i + 1;
118 let mut body = String::new();
119 let mut closed = false;
120 i += 1;
121 while i < lines.len() {
122 let l = lines[i];
123 let t = l.trim_end();
124 if t.len() >= fence_len && t.chars().all(|c| c == ':') {
125 closed = true;
126 break;
127 }
128 if !body.is_empty() {
129 body.push('\n');
130 }
131 body.push_str(l);
132 i += 1;
133 }
134 if !closed {
135 errs.push(format!(
136 "line {open_line}: :::{name} is never closed (expected a line of {fence_len}+ colons)"
137 ));
138 }
139 if !plain.is_empty() {
140 segments.push(Segment::Text(std::mem::take(&mut plain)));
141 }
142 segments.push(Segment::Directive(directives.len()));
143 directives.push(Directive {
144 name,
145 attrs,
146 body,
147 line: open_line,
148 });
149 i += 1; } else {
151 if !plain.is_empty() {
152 plain.push('\n');
153 }
154 plain.push_str(line);
155 i += 1;
156 }
157 }
158 if !plain.is_empty() {
159 segments.push(Segment::Text(plain));
160 }
161 if errs.is_empty() {
162 Ok((segments, directives))
163 } else {
164 Err(errs)
165 }
166}
167
168#[derive(Debug, PartialEq)]
170pub enum Segment {
171 Text(String),
172 Directive(usize),
173}
174
175fn open_fence(line: &str) -> Option<(usize, String, String)> {
177 let t = line.trim_end();
178 let colons = t.chars().take_while(|c| *c == ':').count();
179 if colons < 3 {
180 return None;
181 }
182 let rest = &t[colons..];
183 if rest.is_empty() {
184 return None; }
186 let name: String = rest
187 .chars()
188 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
189 .collect();
190 if name.is_empty() {
191 return None;
192 }
193 let after = &rest[name.len()..];
194 let attrs = after.trim();
195 if !attrs.is_empty() && !(attrs.starts_with('{') && attrs.ends_with('}')) {
196 return None; }
198 let attr_src = attrs
199 .strip_prefix('{')
200 .and_then(|a| a.strip_suffix('}'))
201 .unwrap_or("")
202 .to_string();
203 Some((colons, name, attr_src))
204}
205
206fn parse_attrs(src: &str) -> Result<BTreeMap<String, String>, String> {
208 let mut out = BTreeMap::new();
209 let mut chars = src.chars().peekable();
210 loop {
211 while chars.peek().is_some_and(|c| c.is_whitespace()) {
212 chars.next();
213 }
214 let Some(&c0) = chars.peek() else { break };
215 if !(c0.is_ascii_alphanumeric() || c0 == '_' || c0 == '-') {
216 return Err(format!("unexpected {c0:?} in attributes"));
217 }
218 let mut key = String::new();
219 while chars
220 .peek()
221 .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
222 {
223 key.push(chars.next().unwrap());
224 }
225 if chars.peek() == Some(&'=') {
226 chars.next();
227 let mut val = String::new();
228 if chars.peek() == Some(&'"') {
229 chars.next();
230 let mut closed = false;
231 while let Some(c) = chars.next() {
232 match c {
233 '"' => {
234 closed = true;
235 break;
236 }
237 '\\' => {
238 if let Some(e) = chars.next() {
239 val.push(e);
240 }
241 }
242 _ => val.push(c),
243 }
244 }
245 if !closed {
246 return Err(format!("unterminated quote in {key}=\"…"));
247 }
248 } else {
249 while chars.peek().is_some_and(|c| !c.is_whitespace()) {
250 val.push(chars.next().unwrap());
251 }
252 }
253 out.insert(key, val);
254 } else {
255 out.insert(key, "true".to_string());
256 }
257 }
258 Ok(out)
259}
260
261fn frag(config: &mut Value) -> &mut serde_json::Map<String, Value> {
264 if !config.is_object() {
265 *config = Value::Object(Default::default());
266 }
267 config.as_object_mut().expect("just ensured")
268}
269
270fn attr_value(s: &str) -> Value {
273 match s {
274 "true" => Value::Bool(true),
275 "false" => Value::Bool(false),
276 _ => s
277 .parse::<i64>()
278 .map(Into::into)
279 .unwrap_or_else(|_| Value::String(s.to_string())),
280 }
281}
282
283fn merge_over(into: &mut serde_json::Map<String, Value>, add: serde_json::Map<String, Value>) {
287 for (k, v) in add {
288 match (into.get_mut(&k), v) {
289 (Some(Value::Object(dst)), Value::Object(src)) => merge_over(dst, src),
290 (Some(slot), v) => *slot = v,
291 (None, v) => {
292 into.insert(k, v);
293 }
294 }
295 }
296}
297
298pub fn merge_missing(
305 doc: &mut serde_json::Map<String, Value>,
306 frag: serde_json::Map<String, Value>,
307 at_mcp: bool,
308) {
309 for (k, v) in frag {
310 match (doc.get_mut(&k), v) {
311 (Some(Value::Array(have)), Value::Array(add)) if at_mcp && k == "servers" => {
312 let names: Vec<String> = have
313 .iter()
314 .filter_map(|s| s.get("name").and_then(Value::as_str))
315 .map(str::to_string)
316 .collect();
317 for entry in add {
318 let dup = entry
319 .get("name")
320 .and_then(Value::as_str)
321 .is_some_and(|n| names.iter().any(|h| h == n));
322 if !dup {
323 have.push(entry);
324 }
325 }
326 }
327 (Some(Value::Object(dst)), Value::Object(src)) => {
328 merge_missing(dst, src, k == "mcp");
329 }
330 (Some(_), _) => {}
331 (None, v) => {
332 doc.insert(k, v);
333 }
334 }
335 }
336}
337
338pub fn extract(text: &str) -> Result<Extraction, Vec<String>> {
341 if !text.lines().any(|l| l.starts_with(":::")) {
343 return Ok(Extraction {
344 cleaned: text.to_string(),
345 ..Default::default()
346 });
347 }
348 let (segments, directives) = parse(text)?;
349 let mut errs = Vec::new();
350 let mut out = Extraction::default();
351 for seg in &segments {
352 match seg {
353 Segment::Text(t) => out.cleaned.push_str(t),
354 Segment::Directive(ix) => {
355 let d = &directives[*ix];
356 match d.name.as_str() {
357 "workflow" => match crate::config::yaml::parse(&d.body) {
358 Ok(mut doc) => {
359 if let Some(n) = d.attrs.get("name")
360 && let Some(o) = doc.as_object_mut()
361 {
362 o.insert("name".into(), Value::String(n.clone()));
363 }
364 if let Some(armed) = d.attrs.get("armed")
365 && let Some(o) = doc.as_object_mut()
366 {
367 o.insert("armed".into(), Value::Bool(armed == "true"));
368 }
369 let name = doc
370 .get("name")
371 .and_then(Value::as_str)
372 .unwrap_or("?")
373 .to_string();
374 out.cleaned.push_str(&format!(
375 "[workflow \"{name}\" is loaded and runs autonomously]"
376 ));
377 out.workflows.push(doc);
378 }
379 Err(e) => errs.push(format!(
380 "line {}: :::workflow body is not valid YAML: {e}",
381 d.line
382 )),
383 },
384 "skill" => {
385 let Some(name) = d.attrs.get("name").cloned() else {
386 errs.push(format!(
387 "line {}: :::skill needs a name ({{name=…}})",
388 d.line
389 ));
390 continue;
391 };
392 out.cleaned.push_str(&format!(
393 "[skill \"{name}\" is available — reference it as @skill:{name}]"
394 ));
395 out.skills.push(InlineSkill {
396 name,
397 description: d.attrs.get("description").cloned().unwrap_or_default(),
398 when_to_use: d.attrs.get("when").cloned(),
399 body: d.body.clone(),
400 });
401 }
402 "config" => match crate::config::yaml::parse(&d.body) {
407 Ok(Value::Object(m)) => {
408 merge_over(frag(&mut out.config), m);
409 out.cleaned.push_str("[runtime configuration is applied]");
410 }
411 Ok(_) => errs.push(format!(
412 "line {}: :::config body must be a YAML mapping of config sections",
413 d.line
414 )),
415 Err(e) => errs.push(format!(
416 "line {}: :::config body is not valid YAML: {e}",
417 d.line
418 )),
419 },
420 "mcp" => {
421 let body = if d.body.trim().is_empty() {
422 Ok(Value::Object(serde_json::Map::new()))
423 } else {
424 crate::config::yaml::parse(&d.body)
425 };
426 match body {
427 Ok(Value::Object(mut m)) => {
428 for (k, v) in &d.attrs {
429 m.insert(k.clone(), attr_value(v));
430 }
431 let Some(name) =
432 m.get("name").and_then(Value::as_str).map(str::to_string)
433 else {
434 errs.push(format!(
435 "line {}: :::mcp needs a name ({{name=…}} or `name:` in the body)",
436 d.line
437 ));
438 continue;
439 };
440 out.cleaned.push_str(&format!(
441 "[mcp server \"{name}\" is connected; its tools are available]"
442 ));
443 let servers = frag(&mut out.config)
444 .entry("mcp")
445 .or_insert_with(|| Value::Object(Default::default()));
446 if let Some(o) = servers.as_object_mut() {
447 o.entry("servers")
448 .or_insert_with(|| Value::Array(Vec::new()))
449 .as_array_mut()
450 .expect("just made")
451 .push(Value::Object(m));
452 }
453 }
454 Ok(_) => errs.push(format!(
455 "line {}: :::mcp body must be a YAML mapping (the mcp.servers entry)",
456 d.line
457 )),
458 Err(e) => errs.push(format!(
459 "line {}: :::mcp body is not valid YAML: {e}",
460 d.line
461 )),
462 }
463 }
464 "stream" => {
465 let Some(name) = d.attrs.get("name").cloned() else {
466 errs.push(format!(
467 "line {}: :::stream needs a name ({{name=…}})",
468 d.line
469 ));
470 continue;
471 };
472 let body = if d.body.trim().is_empty() {
473 Ok(Value::Object(serde_json::Map::new()))
474 } else {
475 crate::config::yaml::parse(&d.body)
476 };
477 match body {
478 Ok(v @ Value::Object(_)) => {
479 out.cleaned
480 .push_str(&format!("[event stream \"{name}\" is declared]"));
481 let streams = frag(&mut out.config)
482 .entry("streams")
483 .or_insert_with(|| Value::Object(Default::default()));
484 if let Some(o) = streams.as_object_mut() {
485 o.insert(name, v);
486 }
487 }
488 Ok(_) => errs.push(format!(
489 "line {}: :::stream body must be a YAML mapping (retention: …)",
490 d.line
491 )),
492 Err(e) => errs.push(format!(
493 "line {}: :::stream body is not valid YAML: {e}",
494 d.line
495 )),
496 }
497 }
498 "tools" => match crate::config::yaml::parse(&d.body) {
499 Ok(Value::Object(m)) => {
500 out.cleaned.push_str("[tool policy is applied]");
501 let tools = frag(&mut out.config)
502 .entry("tools")
503 .or_insert_with(|| Value::Object(Default::default()));
504 if let Some(o) = tools.as_object_mut() {
505 merge_over(o, m);
506 }
507 }
508 Ok(_) => errs.push(format!(
509 "line {}: :::tools body must be a YAML mapping (disabled/overrides)",
510 d.line
511 )),
512 Err(e) => errs.push(format!(
513 "line {}: :::tools body is not valid YAML: {e}",
514 d.line
515 )),
516 },
517 "context" | "example" => {
520 let tag = if d.name == "context" {
521 "reference"
522 } else {
523 "example"
524 };
525 match d.attrs.get("title") {
526 Some(t) => out
527 .cleaned
528 .push_str(&format!("<{tag} title=\"{t}\">\n{}\n</{tag}>", d.body)),
529 None => out
530 .cleaned
531 .push_str(&format!("<{tag}>\n{}\n</{tag}>", d.body)),
532 }
533 }
534 _ => unreachable!("parse() rejects unknown names"),
535 }
536 }
537 }
538 }
539 if errs.is_empty() { Ok(out) } else { Err(errs) }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn plain_text_passes_through_untouched() {
548 let t = "just prose\nwith lines\nand a ::: mid-sentence is fine";
549 let e = extract(t).unwrap();
550 assert_eq!(e.cleaned, t);
551 assert!(e.workflows.is_empty() && e.skills.is_empty());
552 }
553
554 #[test]
555 fn a_workflow_block_becomes_a_document_and_a_note() {
556 let t = "Do the thing.\n\n:::workflow{armed=true}\nname: w\nsteps:\n s: {kind: once}\n f: {kind: finish, depends_on: [s]}\n:::\n\nBe nice.";
557 let e = extract(t).unwrap();
558 assert_eq!(e.workflows.len(), 1);
559 assert_eq!(e.workflows[0]["name"], "w");
560 assert_eq!(e.workflows[0]["armed"], true);
561 assert!(e.cleaned.contains("[workflow \"w\" is loaded"));
562 assert!(!e.cleaned.contains(":::"), "{}", e.cleaned);
563 assert!(e.cleaned.starts_with("Do the thing.") && e.cleaned.ends_with("Be nice."));
564 }
565
566 #[test]
567 fn name_attr_overrides_the_body_name() {
568 let t = ":::workflow{name=renamed}\nname: original\nsteps: {}\n:::";
569 let e = extract(t).unwrap();
570 assert_eq!(e.workflows[0]["name"], "renamed");
571 }
572
573 #[test]
574 fn a_skill_block_joins_the_catalogue_with_a_reference_note() {
575 let t = ":::skill{name=review description=\"how we review\" when=\"reviewing PRs\"}\nAlways check the tests first.\n:::";
576 let e = extract(t).unwrap();
577 assert_eq!(e.skills.len(), 1);
578 assert_eq!(e.skills[0].name, "review");
579 assert_eq!(e.skills[0].description, "how we review");
580 assert_eq!(e.skills[0].when_to_use.as_deref(), Some("reviewing PRs"));
581 assert_eq!(e.skills[0].body, "Always check the tests first.");
582 assert!(e.cleaned.contains("@skill:review"));
583 }
584
585 #[test]
586 fn context_and_example_keep_their_bodies_in_tags() {
587 let t = ":::context{title=\"API notes\"}\nrate limit is 10/s\n:::\n:::example\nQ: hi\nA: hello\n:::";
588 let e = extract(t).unwrap();
589 assert!(
590 e.cleaned
591 .contains("<reference title=\"API notes\">\nrate limit is 10/s\n</reference>")
592 );
593 assert!(e.cleaned.contains("<example>\nQ: hi\nA: hello\n</example>"));
594 }
595
596 #[test]
597 fn unknown_names_and_unclosed_fences_fail_closed_with_lines() {
598 let e = extract(":::worfklow\nx: 1\n:::").unwrap_err();
599 assert!(
600 e[0].contains("line 1") && e[0].contains("unknown directive"),
601 "{e:?}"
602 );
603 assert!(e[0].contains("workflow, skill, context, example"), "{e:?}");
604 let e = extract("intro\n:::workflow\nname: w").unwrap_err();
605 assert!(e.iter().any(|m| m.contains("never closed")), "{e:?}");
606 let e = extract(":::workflow\n[not yaml\n:::").unwrap_err();
607 assert!(e.iter().any(|m| m.contains("not valid YAML")), "{e:?}");
608 }
609
610 #[test]
611 fn longer_outer_fences_nest_literal_inner_ones() {
612 let t = "::::context\ninner literal:\n:::workflow\nnot parsed\n:::\ndone\n::::";
613 let e = extract(t).unwrap();
614 assert!(e.workflows.is_empty(), "inner fence is body text");
615 assert!(e.cleaned.contains(":::workflow"), "{}", e.cleaned);
616 }
617
618 #[test]
619 fn attributes_parse_quotes_escapes_and_flags() {
620 let a = parse_attrs(r#"name=x title="a \"b\" c" armed flag-2=7"#).unwrap();
621 assert_eq!(a["name"], "x");
622 assert_eq!(a["title"], "a \"b\" c");
623 assert_eq!(a["armed"], "true");
624 assert_eq!(a["flag-2"], "7");
625 assert!(parse_attrs("name=\"unterminated").is_err());
626 }
627
628 #[test]
629 fn config_blocks_fold_into_one_fragment_later_wins() {
630 let t = ":::config\nlimits: {max_runs: 5}\nstore: {kind: memory}\n:::\n\
631 prose between\n\
632 :::config\nlimits: {max_runs: 9}\n:::\n";
633 let e = extract(t).unwrap();
634 assert_eq!(e.config["limits"]["max_runs"], 9, "later block wins");
635 assert_eq!(e.config["store"]["kind"], "memory");
636 assert!(e.cleaned.contains("[runtime configuration is applied]"));
637 assert!(
638 !e.cleaned.contains("max_runs"),
639 "machinery never reaches the model"
640 );
641 }
642
643 #[test]
644 fn mcp_stream_and_tools_blocks_build_the_fragment() {
645 let t = ":::mcp{name=fs timeout=30s}\nendpoint: \"https://fs.internal/mcp\"\nallow: [\"read_*\", \"list_*\"]\nexclude: [\"read_secrets\"]\n:::\n\
646 :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
647 :::stream{name=alerts}\n:::\n\
648 :::tools\ndisabled: [\"exec\"]\n:::\n";
649 let e = extract(t).unwrap();
650 let srv = &e.config["mcp"]["servers"][0];
651 assert_eq!(srv["name"], "fs");
652 assert_eq!(srv["timeout"], "30s", "attrs merge over the body");
653 assert_eq!(srv["allow"][0], "read_*");
654 assert_eq!(srv["exclude"][0], "read_secrets");
655 assert_eq!(e.config["streams"]["orders"]["retention"]["max_events"], 50);
656 assert!(
657 e.config["streams"]["alerts"].is_object(),
658 "empty body = defaults"
659 );
660 assert_eq!(e.config["tools"]["disabled"][0], "exec");
661 assert!(e.cleaned.contains("mcp server \"fs\""), "{}", e.cleaned);
662 assert!(e.cleaned.contains("stream \"orders\""), "{}", e.cleaned);
663 }
664
665 #[test]
666 fn the_fragment_merges_under_the_explicit_doc() {
667 let mut doc = serde_json::json!({
668 "limits": {"max_runs": 3},
669 "mcp": {"servers": [{"name": "fs", "endpoint": "https://real"}]}
670 });
671 let frag = serde_json::json!({
672 "limits": {"max_runs": 9, "step_timeout": "10s"},
673 "mcp": {"servers": [
674 {"name": "fs", "endpoint": "https://SHADOW"},
675 {"name": "gh", "endpoint": "https://gh"}
676 ]},
677 "streams": {"orders": {}}
678 });
679 merge_missing(
680 doc.as_object_mut().unwrap(),
681 frag.as_object().unwrap().clone(),
682 false,
683 );
684 assert_eq!(doc["limits"]["max_runs"], 3, "explicit config wins");
685 assert_eq!(doc["limits"]["step_timeout"], "10s", "fragment fills gaps");
686 let servers = doc["mcp"]["servers"].as_array().unwrap();
687 assert_eq!(servers.len(), 2, "new server appends; same-name does not");
688 assert_eq!(
689 servers[0]["endpoint"], "https://real",
690 "no shadowing by name"
691 );
692 assert_eq!(servers[1]["name"], "gh");
693 assert!(doc["streams"]["orders"].is_object());
694 }
695
696 #[test]
697 fn a_nameless_mcp_or_stream_block_fails_closed() {
698 assert!(extract(":::mcp\nendpoint: \"https://x\"\n:::\n").is_err());
699 assert!(extract(":::stream\nretention: {}\n:::\n").is_err());
700 assert!(extract(":::config\n- a list\n:::\n").is_err());
701 }
702}