1use std::collections::BTreeMap;
8use std::env;
9use std::fmt;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14
15pub const HARN_SKILLS_DIR_ENV: &str = "HARN_SKILLS_DIR";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct SkillFrontmatter {
21 pub name: &'static str,
22 pub short: &'static str,
23 pub description: &'static str,
24 pub when_to_use: Option<&'static str>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct EmbeddedSkill {
30 pub name: &'static str,
31 pub frontmatter: SkillFrontmatter,
32 pub body: &'static str,
33 pub source: &'static str,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiskSkillFrontmatter {
43 pub name: String,
44 pub short: String,
45 pub description: String,
46 pub when_to_use: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct DiskSkill {
52 pub name: String,
53 pub frontmatter: DiskSkillFrontmatter,
54 pub body: String,
55 pub source: String,
56 pub path: PathBuf,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum SkillCorpus {
62 Embedded(&'static [EmbeddedSkill]),
63 Disk(Vec<DiskSkill>),
64}
65
66impl SkillCorpus {
67 pub fn is_disk(&self) -> bool {
68 matches!(self, Self::Disk(_))
69 }
70
71 pub fn len(&self) -> usize {
72 match self {
73 Self::Embedded(skills) => skills.len(),
74 Self::Disk(skills) => skills.len(),
75 }
76 }
77
78 pub fn is_empty(&self) -> bool {
79 self.len() == 0
80 }
81}
82
83#[derive(Debug)]
85pub enum SkillDiscoveryError {
86 Io {
87 path: PathBuf,
88 source: io::Error,
89 },
90 MissingFrontmatter {
91 path: PathBuf,
92 },
93 MissingField {
94 path: PathBuf,
95 field: &'static str,
96 },
97 DuplicateName {
98 name: String,
99 first: PathBuf,
100 second: PathBuf,
101 },
102}
103
104impl fmt::Display for SkillDiscoveryError {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 match self {
107 Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
108 Self::MissingFrontmatter { path } => {
109 write!(f, "{}: missing SKILL.md frontmatter", path.display())
110 }
111 Self::MissingField { path, field } => {
112 write!(f, "{}: missing `{field}` frontmatter field", path.display())
113 }
114 Self::DuplicateName {
115 name,
116 first,
117 second,
118 } => write!(
119 f,
120 "duplicate skill `{name}` in {} and {}",
121 first.display(),
122 second.display()
123 ),
124 }
125 }
126}
127
128impl std::error::Error for SkillDiscoveryError {}
129
130const SOURCES: &[&str] = &[
131 include_str!("corpus/harn-agent/SKILL.md"),
132 include_str!("corpus/harn-apps/SKILL.md"),
133 include_str!("corpus/harn-de-slop/SKILL.md"),
134 include_str!("corpus/harn-diagnostics/SKILL.md"),
135 include_str!("corpus/harn-docs/SKILL.md"),
136 include_str!("corpus/harn-language/SKILL.md"),
137 include_str!("corpus/harn-mcp/SKILL.md"),
138 include_str!("corpus/harn-orchestration/SKILL.md"),
139 include_str!("corpus/harn-probe/SKILL.md"),
140 include_str!("corpus/harn-product-quality/SKILL.md"),
141 include_str!("corpus/harn-providers/SKILL.md"),
142 include_str!("corpus/harn-rules/SKILL.md"),
143 include_str!("corpus/harn-testing/SKILL.md"),
144 include_str!("corpus/harn-tracing/SKILL.md"),
145 include_str!("corpus/release-harn/SKILL.md"),
146];
147
148static EMBEDDED_SKILLS: OnceLock<Box<[EmbeddedSkill]>> = OnceLock::new();
149
150pub fn list_embedded_skills() -> &'static [EmbeddedSkill] {
152 EMBEDDED_SKILLS
153 .get_or_init(|| SOURCES.iter().map(|source| parse_skill(source)).collect())
154 .as_ref()
155}
156
157pub fn get_embedded_skill(name: &str) -> Option<&'static EmbeddedSkill> {
159 list_embedded_skills()
160 .iter()
161 .find(|skill| skill.name == name)
162}
163
164pub fn resolve_skill_corpus_from_env() -> Result<SkillCorpus, SkillDiscoveryError> {
168 let Ok(dir) = env::var(HARN_SKILLS_DIR_ENV) else {
169 return Ok(SkillCorpus::Embedded(list_embedded_skills()));
170 };
171 if dir.trim().is_empty() {
172 return Ok(SkillCorpus::Embedded(list_embedded_skills()));
173 }
174
175 let skills = list_disk_skills(dir)?;
176 if skills.is_empty() {
177 Ok(SkillCorpus::Embedded(list_embedded_skills()))
178 } else {
179 Ok(SkillCorpus::Disk(skills))
180 }
181}
182
183pub fn list_disk_skills(root: impl AsRef<Path>) -> Result<Vec<DiskSkill>, SkillDiscoveryError> {
188 let root = root.as_ref();
189 if !root.exists() {
190 return Ok(Vec::new());
191 }
192
193 let mut paths = Vec::new();
194 collect_skill_paths(root, &mut paths)?;
195 paths.sort();
196
197 let mut by_name: BTreeMap<String, DiskSkill> = BTreeMap::new();
198 for path in paths {
199 let skill = parse_disk_skill(&path)?;
200 if let Some(first) = by_name.get(&skill.name) {
201 return Err(SkillDiscoveryError::DuplicateName {
202 name: skill.name,
203 first: first.path.clone(),
204 second: path,
205 });
206 }
207 by_name.insert(skill.name.clone(), skill);
208 }
209
210 Ok(by_name.into_values().collect())
211}
212
213fn parse_skill(source: &'static str) -> EmbeddedSkill {
214 let (frontmatter, body) = split_frontmatter(source);
215 let frontmatter = parse_frontmatter(frontmatter);
216 EmbeddedSkill {
217 name: frontmatter.name,
218 frontmatter,
219 body,
220 source,
221 }
222}
223
224fn collect_skill_paths(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), SkillDiscoveryError> {
225 let entries = fs::read_dir(dir).map_err(|source| SkillDiscoveryError::Io {
226 path: dir.to_path_buf(),
227 source,
228 })?;
229 for entry in entries {
230 let entry = entry.map_err(|source| SkillDiscoveryError::Io {
231 path: dir.to_path_buf(),
232 source,
233 })?;
234 let path = entry.path();
235 let file_type = entry
236 .file_type()
237 .map_err(|source| SkillDiscoveryError::Io {
238 path: path.clone(),
239 source,
240 })?;
241 if file_type.is_dir() {
242 collect_skill_paths(&path, out)?;
243 } else if file_type.is_file() && entry.file_name() == "SKILL.md" {
244 out.push(path);
245 }
246 }
247 Ok(())
248}
249
250fn parse_disk_skill(path: &Path) -> Result<DiskSkill, SkillDiscoveryError> {
251 let source = fs::read_to_string(path).map_err(|source| SkillDiscoveryError::Io {
252 path: path.to_path_buf(),
253 source,
254 })?;
255 let (frontmatter, body) =
256 split_disk_frontmatter(&source).ok_or_else(|| SkillDiscoveryError::MissingFrontmatter {
257 path: path.to_path_buf(),
258 })?;
259 let frontmatter = parse_disk_frontmatter(path, frontmatter)?;
260 Ok(DiskSkill {
261 name: frontmatter.name.clone(),
262 frontmatter,
263 body: body.to_string(),
264 source,
265 path: path.to_path_buf(),
266 })
267}
268
269fn split_disk_frontmatter(source: &str) -> Option<(&str, &str)> {
270 split_frontmatter_parts(source)
271}
272
273fn parse_disk_frontmatter(
274 path: &Path,
275 frontmatter: &str,
276) -> Result<DiskSkillFrontmatter, SkillDiscoveryError> {
277 let mut name = None;
278 let mut short = None;
279 let mut description = None;
280 let mut when_to_use = None;
281
282 for line in frontmatter.lines() {
283 let Some((key, value)) = line.split_once(':') else {
284 continue;
285 };
286 let value = value.trim().to_string();
287 match key {
288 "name" => name = Some(value),
289 "short" => short = Some(value),
290 "description" => description = Some(value),
291 "when_to_use" => when_to_use = Some(value),
292 _ => {}
293 }
294 }
295
296 Ok(DiskSkillFrontmatter {
297 name: require_disk_field(path, name, "name")?,
298 short: short.unwrap_or_default(),
299 description: require_disk_field(path, description, "description")?,
300 when_to_use,
301 })
302}
303
304fn require_disk_field(
305 path: &Path,
306 value: Option<String>,
307 field: &'static str,
308) -> Result<String, SkillDiscoveryError> {
309 value.ok_or_else(|| SkillDiscoveryError::MissingField {
310 path: path.to_path_buf(),
311 field,
312 })
313}
314
315fn split_frontmatter(source: &'static str) -> (&'static str, &'static str) {
316 let Some((after_open, line_ending)) = split_opening_frontmatter(source) else {
317 panic!("embedded skill source is missing opening frontmatter delimiter");
318 };
319 let Some((frontmatter, body)) = split_closing_frontmatter(after_open, line_ending) else {
320 panic!("embedded skill source is missing closing frontmatter delimiter");
321 };
322 (frontmatter, body)
323}
324
325fn split_frontmatter_parts(source: &str) -> Option<(&str, &str)> {
326 let (after_open, line_ending) = split_opening_frontmatter(source)?;
327 split_closing_frontmatter(after_open, line_ending)
328}
329
330fn split_opening_frontmatter(source: &str) -> Option<(&str, &str)> {
331 if let Some(after_open) = source.strip_prefix("---\n") {
332 Some((after_open, "\n"))
333 } else if let Some(after_open) = source.strip_prefix("---\r\n") {
334 Some((after_open, "\r\n"))
335 } else {
336 None
337 }
338}
339
340fn split_closing_frontmatter<'a>(
341 after_open: &'a str,
342 line_ending: &str,
343) -> Option<(&'a str, &'a str)> {
344 let close = format!("{line_ending}---{line_ending}");
345 after_open.split_once(&close)
346}
347
348fn parse_frontmatter(frontmatter: &'static str) -> SkillFrontmatter {
349 let mut name = None;
350 let mut short = None;
351 let mut description = None;
352 let mut when_to_use = None;
353
354 for line in frontmatter.lines() {
355 let Some((key, value)) = line.split_once(':') else {
356 continue;
357 };
358 let value = value.trim();
359 match key {
360 "name" => name = Some(value),
361 "short" => short = Some(value),
362 "description" => description = Some(value),
363 "when_to_use" => when_to_use = Some(value),
364 _ => {}
365 }
366 }
367
368 SkillFrontmatter {
369 name: name.expect("embedded skill frontmatter is missing `name`"),
370 short: short.expect("embedded skill frontmatter is missing `short`"),
371 description: description.expect("embedded skill frontmatter is missing `description`"),
372 when_to_use,
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::collections::BTreeSet;
380 use tempfile::TempDir;
381
382 #[test]
383 fn lists_expected_initial_corpus() {
384 let skills = list_embedded_skills();
385 let names: Vec<&str> = skills.iter().map(|skill| skill.name).collect();
386 assert_eq!(
387 names,
388 [
389 "harn-agent",
390 "harn-apps",
391 "harn-de-slop",
392 "harn-diagnostics",
393 "harn-docs",
394 "harn-language",
395 "harn-mcp",
396 "harn-orchestration",
397 "harn-probe",
398 "harn-product-quality",
399 "harn-providers",
400 "harn-rules",
401 "harn-testing",
402 "harn-tracing",
403 "release-harn",
404 ]
405 );
406 assert_eq!(skills.len(), SOURCES.len());
407 }
408
409 #[test]
410 fn can_fetch_harn_language_skill() {
411 let skill = get_embedded_skill("harn-language").expect("harn-language skill is embedded");
412 assert_eq!(skill.frontmatter.name, "harn-language");
413 assert!(skill.body.contains("Harn language"));
414 }
415
416 #[test]
417 fn skills_have_unique_names_and_body_only_content() {
418 let mut names = BTreeSet::new();
419 for skill in list_embedded_skills() {
420 assert_eq!(skill.name, skill.frontmatter.name);
421 assert!(names.insert(skill.name), "duplicate skill {}", skill.name);
422 assert!(
423 !skill.body.trim().is_empty(),
424 "{} body is empty",
425 skill.name
426 );
427 assert!(
428 !skill.body.trim_start().starts_with("---"),
429 "{} body includes frontmatter",
430 skill.name
431 );
432 }
433 }
434
435 #[test]
436 fn skills_are_sorted_by_name() {
437 let names: Vec<&str> = list_embedded_skills()
438 .iter()
439 .map(|skill| skill.name)
440 .collect();
441 let mut sorted = names.clone();
442 sorted.sort_unstable();
443 assert_eq!(names, sorted);
444 }
445
446 #[test]
447 fn source_round_trips_to_frontmatter_and_body() {
448 for skill in list_embedded_skills() {
449 assert!(
450 split_frontmatter_parts(skill.source).is_some(),
451 "{} source missing opening fence",
452 skill.name
453 );
454 assert!(
455 skill.source.ends_with(skill.body),
456 "{} source must end with the body so dump output is byte-stable",
457 skill.name
458 );
459 assert!(
460 skill.source.contains(&format!("name: {}\n", skill.name))
461 || skill.source.contains(&format!("name: {}\r\n", skill.name)),
462 "{} source missing canonical name field",
463 skill.name
464 );
465 }
466 }
467
468 #[test]
469 fn frontmatter_split_accepts_crlf_sources() {
470 let source = "---\r\nname: crlf\r\n---\r\n# Body\r\n";
471 let (frontmatter, body) = split_frontmatter_parts(source).expect("CRLF frontmatter");
472 assert_eq!(frontmatter, "name: crlf");
473 assert_eq!(body, "# Body\r\n");
474 }
475
476 #[test]
477 fn frontmatter_split_rejects_missing_closing_fence() {
478 assert!(split_frontmatter_parts("---\nname: missing\n# Body\n").is_none());
479 }
480
481 #[test]
482 fn embedded_corpus_stays_within_binary_budget() {
483 let bytes: usize = SOURCES.iter().map(|source| source.len()).sum();
484 assert!(
485 bytes <= 200 * 1024,
486 "embedded corpus is {bytes} bytes, expected <= 200 KiB"
487 );
488 }
489
490 #[test]
491 fn skill_bodies_are_focused_and_not_placeholders() {
492 let expectations = [
493 ("harn-agent", ["agent_loop", "session id", "approval"]),
494 ("harn-apps", ["std/ui", "ui.test.run", "harn app run"]),
495 ("harn-de-slop", ["typed", "structural guard", "rebase"]),
496 ("harn-diagnostics", ["diagnostic", "repair", "conformance"]),
497 ("harn-docs", ["diátaxis", "glossary", "slopwash"]),
498 ("harn-language", ["quickref", "type", "conformance"]),
499 ("harn-mcp", ["mcp_call", "elicitation", "harn serve mcp"]),
500 ("harn-orchestration", ["agent_loop", "workflow", "host"]),
501 ("harn-probe", ["probe", "fact", "evidence"]),
502 (
503 "harn-product-quality",
504 ["canonical path", "control", "liveness"],
505 ),
506 ("harn-providers", ["llm_call", "provider", "schema"]),
507 (
508 "harn-testing",
509 ["conformance", "deterministic", "mock_time"],
510 ),
511 ("harn-tracing", ["replay", "receipts", "transcript"]),
512 ("release-harn", ["release_ship", "merge queue", "tag"]),
513 ];
514
515 for (name, terms) in expectations {
516 let skill = get_embedded_skill(name).expect("expected embedded skill");
517 let body = skill.body.to_ascii_lowercase();
518 assert!(
519 !body.contains("embedded stub") && !body.contains("placeholder"),
520 "{name} should contain real guidance, not stub wording"
521 );
522 for term in terms {
523 assert!(
524 body.contains(term),
525 "{name} body should mention focused term `{term}`"
526 );
527 }
528 }
529 }
530
531 #[test]
532 fn skill_bodies_match_split_skill_contract() {
533 for skill in list_embedded_skills() {
534 let lines = skill.body.lines().count();
535 assert!(
536 lines >= 80,
537 "{} body is {lines} lines, expected at least 80",
538 skill.name
539 );
540 assert!(
541 lines <= 300,
542 "{} body is {lines} lines, expected at most 300",
543 skill.name
544 );
545 }
546 }
547
548 #[test]
549 fn language_skill_names_authoritative_spec_sources() {
550 let skill = get_embedded_skill("harn-language").expect("language skill");
551 assert!(
552 skill
553 .body
554 .contains("authoritative chapters under `spec/chapters/*.md`"),
555 "harn-language should direct spec edits to the registered source files"
556 );
557 for generated_edit_target in [
558 "Edit `spec/HARN_SPEC.md`",
559 "Edit `docs/src/language-spec.md`",
560 ] {
561 assert!(
562 !skill.body.contains(generated_edit_target),
563 "harn-language must not direct edits to generated projection `{generated_edit_target}`"
564 );
565 }
566 }
567
568 #[test]
569 fn skills_use_repository_owned_command_and_policy_seams() {
570 for skill in list_embedded_skills() {
571 for bypass in [
572 "`cargo run",
573 "`cargo test",
574 "`cargo check",
575 "`cargo fmt",
576 "`cargo clippy",
577 ] {
578 assert!(
579 !skill.body.contains(bypass),
580 "{} recommends guarded raw Cargo command `{bypass}`",
581 skill.name
582 );
583 }
584 }
585
586 let language = get_embedded_skill("harn-language").expect("language skill");
587 assert!(language.body.contains("HARN_BIN_NO_BUILD=1"));
588 assert!(language.body.contains("typed data-only module"));
589
590 let testing = get_embedded_skill("harn-testing").expect("testing skill");
591 assert!(testing.body.contains("free worktree-local resource lane"));
592 assert!(testing
593 .body
594 .contains("report the skipped local umbrella gate"));
595 }
596
597 #[test]
598 fn skill_cross_links_resolve_to_embedded_skills() {
599 let names: BTreeSet<&str> = list_embedded_skills()
600 .iter()
601 .map(|skill| skill.name)
602 .collect();
603 for skill in list_embedded_skills() {
604 for reference in bracketed_skill_references(skill.body) {
605 assert!(
606 names.contains(reference),
607 "{} links to unknown embedded skill [[{}]]",
608 skill.name,
609 reference
610 );
611 }
612 }
613 }
614
615 #[test]
616 fn diagnostics_skill_mentions_all_code_categories() {
617 let skill = get_embedded_skill("harn-diagnostics").expect("diagnostics skill");
618 for category in [
619 "TYP", "PAR", "NAM", "CAP", "LLM", "ORC", "STD", "PRM", "MOD", "LNT", "FMT", "IMP",
620 "OWN", "RCV", "MAT",
621 ] {
622 assert!(
623 skill.body.contains(&format!("`{category}`")),
624 "harn-diagnostics should mention diagnostic category `{category}`"
625 );
626 }
627 }
628
629 #[test]
630 fn disk_discovery_finds_recursive_skill_files_sorted_by_name() {
631 let temp = TempDir::new().expect("temp dir");
632 write_skill(
633 &temp.path().join("zeta").join("SKILL.md"),
634 "zeta-skill",
635 "Zeta",
636 );
637 write_skill(
638 &temp.path().join("nested").join("alpha").join("SKILL.md"),
639 "alpha-skill",
640 "Alpha",
641 );
642
643 let skills = list_disk_skills(temp.path()).expect("discover disk skills");
644 let names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
645 assert_eq!(names, ["alpha-skill", "zeta-skill"]);
646 assert_eq!(skills[0].frontmatter.description, "Alpha description");
647 assert!(skills[0].body.contains("Alpha body"));
648 }
649
650 #[test]
651 fn disk_discovery_treats_missing_root_as_empty() {
652 let temp = TempDir::new().expect("temp dir");
653 let skills = list_disk_skills(temp.path().join("missing")).expect("discover disk skills");
654 assert!(skills.is_empty());
655 }
656
657 #[test]
658 fn disk_discovery_rejects_duplicate_skill_names() {
659 let temp = TempDir::new().expect("temp dir");
660 write_skill(
661 &temp.path().join("one").join("SKILL.md"),
662 "same-skill",
663 "One",
664 );
665 write_skill(
666 &temp.path().join("two").join("SKILL.md"),
667 "same-skill",
668 "Two",
669 );
670
671 let error = list_disk_skills(temp.path()).expect_err("duplicate name should fail");
672 assert!(
673 error.to_string().contains("duplicate skill `same-skill`"),
674 "unexpected error: {error}"
675 );
676 }
677
678 fn write_skill(path: &Path, name: &str, label: &str) {
679 fs::create_dir_all(path.parent().expect("skill parent")).expect("create skill parent");
680 fs::write(
681 path,
682 format!(
683 "---\nname: {name}\nshort: {label} short\ndescription: {label} description\n---\n# {label}\n\n{label} body\n"
684 ),
685 )
686 .expect("write SKILL.md");
687 }
688
689 fn bracketed_skill_references(body: &str) -> Vec<&str> {
690 let mut references = Vec::new();
691 let mut rest = body;
692 while let Some((_, after_open)) = rest.split_once("[[") {
693 let Some((reference, tail)) = after_open.split_once("]]") else {
694 break;
695 };
696 references.push(reference);
697 rest = tail;
698 }
699 references
700 }
701}