1use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use std::collections::BTreeMap;
14
15pub const DEFAULT_PREFIX: &str = "@skill:";
17pub const SKILL_SCHEME: &str = "skill://";
19pub const SKILL_MIME: &str = "text/x-skill+markdown";
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SkillMeta {
25 pub name: String,
26 #[serde(default)]
27 pub description: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub when_to_use: Option<String>,
30 #[serde(default, skip_serializing_if = "Vec::is_empty")]
31 pub arguments: Vec<Value>,
32 pub source: SkillSourceRef,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillSourceRef {
38 pub server: String,
39 #[serde(rename = "kind")]
40 pub kind: SkillSourceKind,
41 #[serde(rename = "ref")]
43 pub reference: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub body: Option<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillSourceKind {
52 Prompt,
53 Resource,
54 Inline,
57}
58
59#[derive(Debug, Clone, PartialEq)]
61pub struct SkillBody {
62 pub name: String,
63 pub hash: String,
64 pub body: String,
65}
66
67pub trait SkillServer {
70 fn server_name(&self) -> String;
71 fn supports_prompts(&self) -> bool;
72 fn supports_resources(&self) -> bool;
73 fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String>;
74 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String>;
75 fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String>;
76 fn read_resource(&self, uri: &str) -> Result<String, String>;
77}
78
79impl SkillServer for crate::mcp::client::McpClient {
80 fn server_name(&self) -> String {
81 self.name().to_string()
82 }
83 fn supports_prompts(&self) -> bool {
84 self.capabilities().supports_prompts()
85 }
86 fn supports_resources(&self) -> bool {
87 self.capabilities().supports_resources()
88 }
89 fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String> {
90 crate::mcp::client::McpClient::list_prompts(self).map_err(|e| e.to_string())
91 }
92 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
93 crate::mcp::client::McpClient::get_prompt(self, name, arguments)
94 .map(|r| r.messages)
95 .map_err(|e| e.to_string())
96 }
97 fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String> {
98 crate::mcp::client::McpClient::list_resources(self).map_err(|e| e.to_string())
99 }
100 fn read_resource(&self, uri: &str) -> Result<String, String> {
101 crate::mcp::client::McpClient::read_resource(self, uri)
102 .map(|r| r.text())
103 .map_err(|e| e.to_string())
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Discover {
110 Prompts,
111 Resources,
112 Auto,
113}
114
115#[derive(Debug, Default)]
117pub struct Catalogue {
118 skills: BTreeMap<String, SkillMeta>,
119 bodies: BTreeMap<String, SkillBody>, max_bytes: usize,
121 pub prefix: String,
122 pub errors: BTreeMap<String, String>,
124}
125
126impl Catalogue {
127 pub fn new(prefix: &str, max_bytes: usize) -> Catalogue {
128 Catalogue {
129 prefix: prefix.to_string(),
130 max_bytes,
131 ..Default::default()
132 }
133 }
134
135 pub fn discover(
139 &mut self,
140 server: &dyn SkillServer,
141 mode: Discover,
142 filter: Option<&str>,
143 ) -> Vec<String> {
144 let name = server.server_name();
145 let mut found = Vec::new();
146 let want_prompts =
147 matches!(mode, Discover::Prompts | Discover::Auto) && server.supports_prompts();
148 let want_resources =
149 matches!(mode, Discover::Resources | Discover::Auto) && server.supports_resources();
150 if want_prompts {
151 match server.list_prompts() {
152 Ok(prompts) => {
153 for p in prompts {
154 if !passes(filter, &p.name) {
155 continue;
156 }
157 let (description, when) =
158 split_when(p.description.as_deref().unwrap_or(""));
159 let meta = SkillMeta {
160 name: p.name.clone(),
161 description,
162 when_to_use: when,
163 arguments: p
164 .arguments
165 .iter()
166 .map(|a| serde_json::to_value(a).unwrap_or(Value::Null))
167 .collect(),
168 source: SkillSourceRef {
169 server: name.clone(),
170 kind: SkillSourceKind::Prompt,
171 reference: p.name.clone(),
172 body: None,
173 },
174 };
175 if self.insert(meta) {
176 found.push(p.name);
177 }
178 }
179 }
180 Err(e) => {
181 self.errors
182 .insert(name.clone(), format!("prompts/list: {e}"));
183 }
184 }
185 }
186 if want_resources {
187 match server.list_resources() {
188 Ok(resources) => {
189 for r in resources {
190 let is_skill = r.uri.starts_with(SKILL_SCHEME)
191 || r.mime_type.as_deref() == Some(SKILL_MIME);
192 if !is_skill {
193 continue;
194 }
195 let skill_name = r
196 .uri
197 .strip_prefix(SKILL_SCHEME)
198 .map(|s| s.trim_matches('/').to_string())
199 .filter(|s| !s.is_empty())
200 .or_else(|| r.name.clone())
201 .unwrap_or_else(|| r.uri.clone());
202 if skill_name.is_empty() {
204 continue;
205 }
206 if !passes(filter, &skill_name) {
207 continue;
208 }
209 let (description, when) =
210 split_when(r.description.as_deref().unwrap_or(""));
211 let meta = SkillMeta {
212 name: skill_name.clone(),
213 description,
214 when_to_use: when,
215 arguments: Vec::new(),
216 source: SkillSourceRef {
217 server: name.clone(),
218 kind: SkillSourceKind::Resource,
219 reference: r.uri.clone(),
220 body: None,
221 },
222 };
223 if self.insert(meta) {
224 found.push(skill_name);
225 }
226 }
227 }
228 Err(e) => {
229 self.errors
230 .entry(name.clone())
231 .and_modify(|m| m.push_str(&format!("; resources/list: {e}")))
232 .or_insert(format!("resources/list: {e}"));
233 }
234 }
235 }
236 found
237 }
238
239 fn insert(&mut self, meta: SkillMeta) -> bool {
240 if let Some(existing) = self.skills.get(&meta.name) {
241 if existing.source == meta.source {
243 self.skills.insert(meta.name.clone(), meta);
244 return true;
245 }
246 return false;
247 }
248 self.skills.insert(meta.name.clone(), meta);
249 true
250 }
251
252 pub fn forget_server(&mut self, server: &str) {
254 self.skills.retain(|_, m| m.source.server != server);
255 self.errors.remove(server);
256 }
257
258 pub fn get(&self, name: &str) -> Option<&SkillMeta> {
259 self.skills.get(name)
260 }
261 pub fn names(&self) -> Vec<String> {
262 self.skills.keys().cloned().collect()
263 }
264 pub fn len(&self) -> usize {
265 self.skills.len()
266 }
267 pub fn is_empty(&self) -> bool {
268 self.skills.is_empty()
269 }
270
271 pub fn list_value(&self) -> Value {
273 json!({
274 "skills": self.skills.values().map(|m| json!({
275 "name": m.name, "description": m.description, "when_to_use": m.when_to_use,
276 "arguments": m.arguments, "source": {"server": m.source.server, "kind": m.source.kind}
277 })).collect::<Vec<_>>(),
278 "errors": self.errors,
279 })
280 }
281
282 pub fn render_catalogue(&self) -> Option<String> {
285 if self.skills.is_empty() {
286 return None;
287 }
288 let mut out = format!(
289 "Available skills (reference one as {}<name> or call skills.load to read its full instructions):\n",
290 self.prefix
291 );
292 for m in self.skills.values() {
293 out.push_str(&format!("- {}: {}", m.name, m.description));
294 if let Some(w) = &m.when_to_use {
295 out.push_str(&format!(" (use when: {w})"));
296 }
297 out.push('\n');
298 }
299 Some(out)
300 }
301
302 pub fn load(
305 &mut self,
306 name: &str,
307 arguments: Option<Value>,
308 servers: &dyn Fn(&str) -> Option<std::sync::Arc<dyn SkillServer>>,
309 ) -> Result<SkillBody, String> {
310 let meta = self
311 .skills
312 .get(name)
313 .cloned()
314 .ok_or_else(|| format!("unknown skill {name:?}"))?;
315 let text = if meta.source.kind == SkillSourceKind::Inline {
316 meta.source
317 .body
318 .clone()
319 .ok_or_else(|| format!("inline skill {name:?} lost its body"))?
320 } else {
321 let server = servers(&meta.source.server).ok_or_else(|| {
322 format!(
323 "skill {name:?}: server {:?} is not connected",
324 meta.source.server
325 )
326 })?;
327 match meta.source.kind {
328 SkillSourceKind::Prompt => {
329 let messages = server.get_prompt(&meta.source.reference, arguments)?;
330 prompt_messages_text(&messages)
331 }
332 SkillSourceKind::Resource => server.read_resource(&meta.source.reference)?,
333 SkillSourceKind::Inline => unreachable!("handled above"),
334 }
335 };
336 if text.trim().is_empty() {
337 return Err(format!("skill {name:?} has an empty body"));
338 }
339 let text = if text.len() > self.max_bytes {
340 let mut cut = self.max_bytes;
341 while !text.is_char_boundary(cut) {
342 cut -= 1;
343 }
344 format!(
345 "{}\n\n[skill body truncated to skills.max_bytes = {} bytes]",
346 &text[..cut],
347 self.max_bytes
348 )
349 } else {
350 text
351 };
352 let hash = crate::sha::sha256_hex(text.as_bytes());
353 let body = SkillBody {
354 name: name.to_string(),
355 hash: hash.clone(),
356 body: text,
357 };
358 self.bodies.insert(hash, body.clone());
359 Ok(body)
360 }
361
362 pub fn add_inline(&mut self, skills: &[crate::config::directives::InlineSkill]) -> Vec<String> {
367 let mut names = Vec::new();
368 for sk in skills {
369 self.skills.insert(
370 sk.name.clone(),
371 SkillMeta {
372 name: sk.name.clone(),
373 description: sk.description.clone(),
374 when_to_use: sk.when_to_use.clone(),
375 arguments: Vec::new(),
376 source: SkillSourceRef {
377 server: "instruction".into(),
378 kind: SkillSourceKind::Inline,
379 reference: sk.name.clone(),
380 body: Some(sk.body.clone()),
381 },
382 },
383 );
384 names.push(sk.name.clone());
385 }
386 names
387 }
388
389 pub fn add_dir(&mut self, dir: &std::path::Path) -> (Vec<String>, Vec<String>) {
403 let (mut names, mut errs) = (Vec::new(), Vec::new());
404 let Ok(rd) = std::fs::read_dir(dir) else {
405 return (names, errs);
406 };
407 let mut candidates: Vec<(String, std::path::PathBuf)> = Vec::new();
408 for ent in rd.flatten() {
409 let path = ent.path();
410 let stem = path
411 .file_stem()
412 .and_then(|s| s.to_str())
413 .unwrap_or_default()
414 .to_string();
415 if path.is_dir() {
416 let inner = path.join("SKILL.md");
417 if inner.is_file() {
418 candidates.push((stem, inner));
419 }
420 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
421 candidates.push((stem, path));
422 }
423 }
424 candidates.sort();
427 for (stem, path) in candidates {
428 let text = match std::fs::read_to_string(&path) {
429 Ok(t) => t,
430 Err(e) => {
431 errs.push(format!("{}: {e}", path.display()));
432 continue;
433 }
434 };
435 let (meta, body) = split_frontmatter(&text);
436 if body.trim().is_empty() {
437 errs.push(format!("{}: empty skill body", path.display()));
438 continue;
439 }
440 let name = meta
441 .as_ref()
442 .and_then(|m| m.get("name"))
443 .and_then(|v| v.as_str())
444 .map(str::to_string)
445 .unwrap_or(stem);
446 let raw_desc = meta
447 .as_ref()
448 .and_then(|m| m.get("description"))
449 .and_then(|v| v.as_str())
450 .map(str::to_string)
451 .unwrap_or_else(|| first_paragraph(&body));
452 let (description, when_to_use) = split_when(&raw_desc);
453 self.skills.insert(
454 name.clone(),
455 SkillMeta {
456 name: name.clone(),
457 description,
458 when_to_use,
459 arguments: Vec::new(),
460 source: SkillSourceRef {
461 server: "file".into(),
462 kind: SkillSourceKind::Inline,
463 reference: path.to_string_lossy().into_owned(),
464 body: Some(body),
465 },
466 },
467 );
468 names.push(name);
469 }
470 (names, errs)
471 }
472
473 pub fn body(&self, hash: &str) -> Option<&SkillBody> {
474 self.bodies.get(hash)
475 }
476
477 pub fn evict_except(&mut self, keep: &[String]) {
479 self.bodies.retain(|h, _| keep.iter().any(|k| k == h));
480 }
481
482 pub fn references(&self, text: &str) -> Vec<String> {
484 find_references(text, &self.prefix)
485 }
486}
487
488pub fn find_references(text: &str, prefix: &str) -> Vec<String> {
491 let mut out: Vec<String> = Vec::new();
492 if prefix.is_empty() {
493 return out;
494 }
495 let mut rest = text;
496 while let Some(pos) = rest.find(prefix) {
497 let after = &rest[pos + prefix.len()..];
498 let name: String = after
499 .chars()
500 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
501 .collect();
502 let name = name.trim_end_matches(['.', '/']).to_string();
503 let consumed = name.len().min(after.len());
504 if !name.is_empty() && !out.contains(&name) {
505 out.push(name);
506 }
507 rest = &after[consumed..];
508 }
509 out
510}
511
512pub fn prompt_messages_text(messages: &[Value]) -> String {
514 let mut out = String::new();
515 for m in messages {
516 let content = m.get("content").unwrap_or(&Value::Null);
517 let text = match content {
518 Value::String(s) => s.clone(),
519 Value::Object(o) => o
520 .get("text")
521 .and_then(Value::as_str)
522 .unwrap_or("")
523 .to_string(),
524 Value::Array(parts) => parts
525 .iter()
526 .filter_map(|p| p.get("text").and_then(Value::as_str))
527 .collect::<Vec<_>>()
528 .join("\n"),
529 _ => String::new(),
530 };
531 if !text.is_empty() {
532 if !out.is_empty() {
533 out.push_str("\n\n");
534 }
535 out.push_str(&text);
536 }
537 }
538 out
539}
540
541pub fn render_bodies(bodies: &[&SkillBody]) -> Option<String> {
543 if bodies.is_empty() {
544 return None;
545 }
546 let mut out = String::from("Loaded skills — follow these instructions when relevant:\n");
547 for b in bodies {
548 out.push_str(&format!("\n### Skill: {}\n{}\n", b.name, b.body.trim()));
549 }
550 Some(out)
551}
552
553fn passes(filter: Option<&str>, name: &str) -> bool {
554 match filter {
555 None => true,
556 Some(f) => {
557 let f = f.trim();
558 if let Some(prefix) = f.strip_suffix('*') {
559 name.starts_with(prefix)
560 } else {
561 f == name || f.is_empty()
562 }
563 }
564 }
565}
566
567fn split_frontmatter(text: &str) -> (Option<serde_json::Value>, String) {
571 let rest = match text.strip_prefix("---\n") {
572 Some(r) => r,
573 None => return (None, text.to_string()),
574 };
575 let Some((head, body)) = rest.split_once("\n---\n") else {
576 return (None, text.to_string());
577 };
578 match crate::config::file::parse_document(head, crate::config::file::Format::Yaml) {
579 Ok(v) if v.is_object() => (Some(v), body.to_string()),
580 _ => (None, text.to_string()),
584 }
585}
586
587fn first_paragraph(body: &str) -> String {
590 body.split("\n\n")
591 .map(str::trim)
592 .find(|p| !p.is_empty() && !p.starts_with('#'))
593 .unwrap_or("")
594 .replace('\n', " ")
595}
596
597fn split_when(desc: &str) -> (String, Option<String>) {
599 for marker in ["When to use:", "when to use:", "Use when:", "use when:"] {
600 if let Some((a, b)) = desc.split_once(marker) {
601 let w = b.trim();
602 return (
603 a.trim().trim_end_matches('.').to_string(),
604 (!w.is_empty()).then(|| w.to_string()),
605 );
606 }
607 }
608 (desc.trim().to_string(), None)
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use ::mcp::wire::{Prompt, PromptArgument, Resource};
615
616 struct Fake {
617 name: String,
618 prompts: Vec<Prompt>,
619 resources: Vec<Resource>,
620 bodies: BTreeMap<String, String>,
621 }
622 impl SkillServer for Fake {
623 fn server_name(&self) -> String {
624 self.name.clone()
625 }
626 fn supports_prompts(&self) -> bool {
627 !self.prompts.is_empty()
628 }
629 fn supports_resources(&self) -> bool {
630 !self.resources.is_empty()
631 }
632 fn list_prompts(&self) -> Result<Vec<Prompt>, String> {
633 Ok(self.prompts.clone())
634 }
635 fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
636 let body = self.bodies.get(name).cloned().ok_or("no such prompt")?;
637 let body = match arguments
638 .and_then(|a| a.get("target").and_then(Value::as_str).map(str::to_string))
639 {
640 Some(t) => body.replace("{target}", &t),
641 None => body,
642 };
643 Ok(vec![
644 json!({"role": "user", "content": {"type": "text", "text": body}}),
645 ])
646 }
647 fn list_resources(&self) -> Result<Vec<Resource>, String> {
648 Ok(self.resources.clone())
649 }
650 fn read_resource(&self, uri: &str) -> Result<String, String> {
651 self.bodies
652 .get(uri)
653 .cloned()
654 .ok_or("no such resource".into())
655 }
656 }
657
658 fn fake() -> Fake {
659 Fake {
660 name: "skills".into(),
661 prompts: vec![
662 Prompt {
663 name: "review-pr".into(),
664 title: None,
665 description: Some(
666 "Review a pull request. When to use: any code review request".into(),
667 ),
668 arguments: vec![PromptArgument {
669 name: "target".into(),
670 title: None,
671 description: None,
672 required: Some(false),
673 }],
674 },
675 Prompt {
676 name: "internal-tool".into(),
677 title: None,
678 description: None,
679 arguments: vec![],
680 },
681 ],
682 resources: vec![
683 Resource {
684 uri: "skill://deploy".into(),
685 name: Some("deploy".into()),
686 title: None,
687 description: Some("Deploy safely".into()),
688 mime_type: Some(SKILL_MIME.into()),
689 },
690 Resource {
691 uri: "file:///readme.md".into(),
692 name: None,
693 title: None,
694 description: None,
695 mime_type: Some("text/markdown".into()),
696 },
697 Resource {
698 uri: "notes://x".into(),
699 name: Some("x".into()),
700 title: None,
701 description: None,
702 mime_type: Some(SKILL_MIME.into()),
703 },
704 ],
705 bodies: [
706 (
707 "review-pr".to_string(),
708 "# Review PR\nLook at {target} carefully.".to_string(),
709 ),
710 ("internal-tool".to_string(), "internal".to_string()),
711 (
712 "skill://deploy".to_string(),
713 "# Deploy\n1. plan 2. apply".to_string(),
714 ),
715 ("notes://x".to_string(), "x body".to_string()),
716 ]
717 .into_iter()
718 .collect(),
719 }
720 }
721
722 #[test]
723 fn discovery_over_prompts_and_resources_with_filters() {
724 let f = fake();
725 let mut c = Catalogue::new(DEFAULT_PREFIX, 1024);
726 let found = c.discover(&f, Discover::Auto, None);
727 assert_eq!(found, vec!["review-pr", "internal-tool", "deploy", "x"]);
728 let m = c.get("review-pr").unwrap();
729 assert_eq!(m.description, "Review a pull request");
730 assert_eq!(m.when_to_use.as_deref(), Some("any code review request"));
731 assert_eq!(m.arguments.len(), 1);
732 assert_eq!(
733 c.get("deploy").unwrap().source.kind,
734 SkillSourceKind::Resource
735 );
736 assert!(
737 c.get("readme.md").is_none(),
738 "a plain markdown resource is not a skill"
739 );
740 let cat = c.render_catalogue().unwrap();
741 assert!(
742 cat.contains("- review-pr: Review a pull request (use when: any code review request)"),
743 "{cat}"
744 );
745 let mut c2 = Catalogue::new(DEFAULT_PREFIX, 1024);
747 assert_eq!(
748 c2.discover(&f, Discover::Prompts, Some("review-*")),
749 vec!["review-pr"]
750 );
751 let mut other = fake();
753 other.name = "other".into();
754 assert!(c.discover(&other, Discover::Auto, None).is_empty());
755 assert_eq!(c.get("deploy").unwrap().source.server, "skills");
756 c.forget_server("skills");
757 assert!(c.is_empty());
758 }
759
760 #[test]
761 fn load_caches_by_hash_truncates_and_renders() {
762 let f = std::sync::Arc::new(fake());
763 let mut c = Catalogue::new(DEFAULT_PREFIX, 30);
764 c.discover(&*f, Discover::Auto, None);
765 let f2 = f.clone();
766 let servers = move |n: &str| -> Option<std::sync::Arc<dyn SkillServer>> {
767 (n == "skills").then(|| f2.clone() as std::sync::Arc<dyn SkillServer>)
768 };
769 let b = c
770 .load("review-pr", Some(json!({"target": "PR #7"})), &servers)
771 .unwrap();
772 assert!(b.body.contains("PR #7"));
773 assert!(b.body.contains("truncated to skills.max_bytes"));
774 assert!(c.body(&b.hash).is_some());
775 let d = c.load("deploy", None, &servers).unwrap();
776 assert!(d.body.starts_with("# Deploy"));
777 assert!(c.load("nope", None, &servers).is_err());
778 assert!(
779 c.load("deploy", None, &|_| None).is_err(),
780 "server not connected"
781 );
782 let block = render_bodies(&[&b, &d]).unwrap();
783 assert!(block.contains("### Skill: review-pr") && block.contains("### Skill: deploy"));
784 c.evict_except(std::slice::from_ref(&d.hash));
785 assert!(c.body(&b.hash).is_none());
786 assert!(c.body(&d.hash).is_some());
787 }
788
789 #[test]
790 fn references_are_found_and_deduped() {
791 let refs = find_references(
792 "please @skill:review-pr this, then @skill:deploy. Also @skill:review-pr again and @skill:",
793 "@skill:",
794 );
795 assert_eq!(refs, vec!["review-pr", "deploy"]);
796 assert!(find_references("nothing here", "@skill:").is_empty());
797 assert_eq!(find_references("use +s:x/y.", "+s:"), vec!["x/y"]);
798 assert_eq!(
799 prompt_messages_text(&[
800 json!({"content": "a"}),
801 json!({"content": [{"type": "text", "text": "b"}, {"type": "image"}]})
802 ]),
803 "a\n\nb"
804 );
805 }
806
807 #[test]
811 fn a_local_folder_registers_every_layout() {
812 let dir = std::env::temp_dir().join(format!("agentd-skilldir-{}", std::process::id()));
813 let _ = std::fs::remove_dir_all(&dir);
814 std::fs::create_dir_all(dir.join("runbook")).unwrap();
815
816 std::fs::write(
817 dir.join("triage.md"),
818 "---\nname: triage\ndescription: Triage an issue. Use when: it has no labels\n---\n\nRead it, label it.\n",
819 )
820 .unwrap();
821 std::fs::write(
824 dir.join("runbook/SKILL.md"),
825 "---\nname: incident\ndescription: Handle an incident. When to use: an alert fires\n---\n\nAcknowledge, then mitigate.\n",
826 )
827 .unwrap();
828 std::fs::write(
831 dir.join("deploy.md"),
832 "# Deploy safely\n\nAlways deploy behind a flag.\n",
833 )
834 .unwrap();
835
836 let mut cat = Catalogue::new(DEFAULT_PREFIX, 32_768);
837 let (names, errs) = cat.add_dir(&dir);
838 assert!(errs.is_empty(), "{errs:?}");
839 assert_eq!(
840 names,
841 ["deploy", "incident", "triage"],
842 "sorted by file stem"
843 );
844
845 let triage = cat.skills.get("triage").expect("triage");
846 assert_eq!(triage.description, "Triage an issue");
847 assert_eq!(triage.when_to_use.as_deref(), Some("it has no labels"));
848
849 let incident = cat.skills.get("incident").expect("frontmatter name wins");
850 assert_eq!(incident.when_to_use.as_deref(), Some("an alert fires"));
851
852 let deploy = cat.skills.get("deploy").expect("deploy");
853 assert_eq!(deploy.description, "Always deploy behind a flag.");
854 assert!(deploy.when_to_use.is_none());
855
856 let body = cat
859 .load("deploy", None, &|_| None)
860 .expect("an inline body needs no server");
861 assert!(body.body.contains("behind a flag"), "{}", body.body);
862
863 let _ = std::fs::remove_dir_all(&dir);
864 }
865
866 #[test]
869 fn malformed_frontmatter_degrades_to_body() {
870 let (meta, body) = split_frontmatter("---\n: : not yaml\n---\nhello\n");
871 assert!(meta.is_none());
872 assert!(body.contains("hello"), "{body}");
873
874 let (meta, body) = split_frontmatter("no header here\n");
875 assert!(meta.is_none());
876 assert_eq!(body, "no header here\n");
877 }
878}