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-de-slop/SKILL.md"),
133 include_str!("corpus/harn-diagnostics/SKILL.md"),
134 include_str!("corpus/harn-language/SKILL.md"),
135 include_str!("corpus/harn-orchestration/SKILL.md"),
136 include_str!("corpus/harn-probe/SKILL.md"),
137 include_str!("corpus/harn-product-quality/SKILL.md"),
138 include_str!("corpus/harn-providers/SKILL.md"),
139 include_str!("corpus/harn-rules/SKILL.md"),
140 include_str!("corpus/harn-testing/SKILL.md"),
141 include_str!("corpus/harn-tracing/SKILL.md"),
142 include_str!("corpus/release-harn/SKILL.md"),
143];
144
145static EMBEDDED_SKILLS: OnceLock<Box<[EmbeddedSkill]>> = OnceLock::new();
146
147pub fn list_embedded_skills() -> &'static [EmbeddedSkill] {
149 EMBEDDED_SKILLS
150 .get_or_init(|| SOURCES.iter().map(|source| parse_skill(source)).collect())
151 .as_ref()
152}
153
154pub fn get_embedded_skill(name: &str) -> Option<&'static EmbeddedSkill> {
156 list_embedded_skills()
157 .iter()
158 .find(|skill| skill.name == name)
159}
160
161pub fn resolve_skill_corpus_from_env() -> Result<SkillCorpus, SkillDiscoveryError> {
165 let Ok(dir) = env::var(HARN_SKILLS_DIR_ENV) else {
166 return Ok(SkillCorpus::Embedded(list_embedded_skills()));
167 };
168 if dir.trim().is_empty() {
169 return Ok(SkillCorpus::Embedded(list_embedded_skills()));
170 }
171
172 let skills = list_disk_skills(dir)?;
173 if skills.is_empty() {
174 Ok(SkillCorpus::Embedded(list_embedded_skills()))
175 } else {
176 Ok(SkillCorpus::Disk(skills))
177 }
178}
179
180pub fn list_disk_skills(root: impl AsRef<Path>) -> Result<Vec<DiskSkill>, SkillDiscoveryError> {
185 let root = root.as_ref();
186 if !root.exists() {
187 return Ok(Vec::new());
188 }
189
190 let mut paths = Vec::new();
191 collect_skill_paths(root, &mut paths)?;
192 paths.sort();
193
194 let mut by_name: BTreeMap<String, DiskSkill> = BTreeMap::new();
195 for path in paths {
196 let skill = parse_disk_skill(&path)?;
197 if let Some(first) = by_name.get(&skill.name) {
198 return Err(SkillDiscoveryError::DuplicateName {
199 name: skill.name,
200 first: first.path.clone(),
201 second: path,
202 });
203 }
204 by_name.insert(skill.name.clone(), skill);
205 }
206
207 Ok(by_name.into_values().collect())
208}
209
210fn parse_skill(source: &'static str) -> EmbeddedSkill {
211 let (frontmatter, body) = split_frontmatter(source);
212 let frontmatter = parse_frontmatter(frontmatter);
213 EmbeddedSkill {
214 name: frontmatter.name,
215 frontmatter,
216 body,
217 source,
218 }
219}
220
221fn collect_skill_paths(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), SkillDiscoveryError> {
222 let entries = fs::read_dir(dir).map_err(|source| SkillDiscoveryError::Io {
223 path: dir.to_path_buf(),
224 source,
225 })?;
226 for entry in entries {
227 let entry = entry.map_err(|source| SkillDiscoveryError::Io {
228 path: dir.to_path_buf(),
229 source,
230 })?;
231 let path = entry.path();
232 let file_type = entry
233 .file_type()
234 .map_err(|source| SkillDiscoveryError::Io {
235 path: path.clone(),
236 source,
237 })?;
238 if file_type.is_dir() {
239 collect_skill_paths(&path, out)?;
240 } else if file_type.is_file() && entry.file_name() == "SKILL.md" {
241 out.push(path);
242 }
243 }
244 Ok(())
245}
246
247fn parse_disk_skill(path: &Path) -> Result<DiskSkill, SkillDiscoveryError> {
248 let source = fs::read_to_string(path).map_err(|source| SkillDiscoveryError::Io {
249 path: path.to_path_buf(),
250 source,
251 })?;
252 let (frontmatter, body) =
253 split_disk_frontmatter(&source).ok_or_else(|| SkillDiscoveryError::MissingFrontmatter {
254 path: path.to_path_buf(),
255 })?;
256 let frontmatter = parse_disk_frontmatter(path, frontmatter)?;
257 Ok(DiskSkill {
258 name: frontmatter.name.clone(),
259 frontmatter,
260 body: body.to_string(),
261 source,
262 path: path.to_path_buf(),
263 })
264}
265
266fn split_disk_frontmatter(source: &str) -> Option<(&str, &str)> {
267 split_frontmatter_parts(source)
268}
269
270fn parse_disk_frontmatter(
271 path: &Path,
272 frontmatter: &str,
273) -> Result<DiskSkillFrontmatter, SkillDiscoveryError> {
274 let mut name = None;
275 let mut short = None;
276 let mut description = None;
277 let mut when_to_use = None;
278
279 for line in frontmatter.lines() {
280 let Some((key, value)) = line.split_once(':') else {
281 continue;
282 };
283 let value = value.trim().to_string();
284 match key {
285 "name" => name = Some(value),
286 "short" => short = Some(value),
287 "description" => description = Some(value),
288 "when_to_use" => when_to_use = Some(value),
289 _ => {}
290 }
291 }
292
293 Ok(DiskSkillFrontmatter {
294 name: require_disk_field(path, name, "name")?,
295 short: short.unwrap_or_default(),
296 description: require_disk_field(path, description, "description")?,
297 when_to_use,
298 })
299}
300
301fn require_disk_field(
302 path: &Path,
303 value: Option<String>,
304 field: &'static str,
305) -> Result<String, SkillDiscoveryError> {
306 value.ok_or_else(|| SkillDiscoveryError::MissingField {
307 path: path.to_path_buf(),
308 field,
309 })
310}
311
312fn split_frontmatter(source: &'static str) -> (&'static str, &'static str) {
313 let Some((after_open, line_ending)) = split_opening_frontmatter(source) else {
314 panic!("embedded skill source is missing opening frontmatter delimiter");
315 };
316 let Some((frontmatter, body)) = split_closing_frontmatter(after_open, line_ending) else {
317 panic!("embedded skill source is missing closing frontmatter delimiter");
318 };
319 (frontmatter, body)
320}
321
322fn split_frontmatter_parts(source: &str) -> Option<(&str, &str)> {
323 let (after_open, line_ending) = split_opening_frontmatter(source)?;
324 split_closing_frontmatter(after_open, line_ending)
325}
326
327fn split_opening_frontmatter(source: &str) -> Option<(&str, &str)> {
328 if let Some(after_open) = source.strip_prefix("---\n") {
329 Some((after_open, "\n"))
330 } else if let Some(after_open) = source.strip_prefix("---\r\n") {
331 Some((after_open, "\r\n"))
332 } else {
333 None
334 }
335}
336
337fn split_closing_frontmatter<'a>(
338 after_open: &'a str,
339 line_ending: &str,
340) -> Option<(&'a str, &'a str)> {
341 let close = format!("{line_ending}---{line_ending}");
342 let close_offset = after_open.find(&close)?;
343 Some((
344 &after_open[..close_offset],
345 &after_open[close_offset + close.len()..],
346 ))
347}
348
349fn parse_frontmatter(frontmatter: &'static str) -> SkillFrontmatter {
350 let mut name = None;
351 let mut short = None;
352 let mut description = None;
353 let mut when_to_use = None;
354
355 for line in frontmatter.lines() {
356 let Some((key, value)) = line.split_once(':') else {
357 continue;
358 };
359 let value = value.trim();
360 match key {
361 "name" => name = Some(value),
362 "short" => short = Some(value),
363 "description" => description = Some(value),
364 "when_to_use" => when_to_use = Some(value),
365 _ => {}
366 }
367 }
368
369 SkillFrontmatter {
370 name: name.expect("embedded skill frontmatter is missing `name`"),
371 short: short.expect("embedded skill frontmatter is missing `short`"),
372 description: description.expect("embedded skill frontmatter is missing `description`"),
373 when_to_use,
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use std::collections::BTreeSet;
381 use tempfile::TempDir;
382
383 #[test]
384 fn lists_expected_initial_corpus() {
385 let skills = list_embedded_skills();
386 let names: Vec<&str> = skills.iter().map(|skill| skill.name).collect();
387 assert_eq!(
388 names,
389 [
390 "harn-agent",
391 "harn-de-slop",
392 "harn-diagnostics",
393 "harn-language",
394 "harn-orchestration",
395 "harn-probe",
396 "harn-product-quality",
397 "harn-providers",
398 "harn-rules",
399 "harn-testing",
400 "harn-tracing",
401 "release-harn",
402 ]
403 );
404 assert_eq!(skills.len(), SOURCES.len());
405 }
406
407 #[test]
408 fn can_fetch_harn_language_skill() {
409 let skill = get_embedded_skill("harn-language").expect("harn-language skill is embedded");
410 assert_eq!(skill.frontmatter.name, "harn-language");
411 assert!(skill.body.contains("Harn language"));
412 }
413
414 #[test]
415 fn skills_have_unique_names_and_body_only_content() {
416 let mut names = BTreeSet::new();
417 for skill in list_embedded_skills() {
418 assert_eq!(skill.name, skill.frontmatter.name);
419 assert!(names.insert(skill.name), "duplicate skill {}", skill.name);
420 assert!(
421 !skill.body.trim().is_empty(),
422 "{} body is empty",
423 skill.name
424 );
425 assert!(
426 !skill.body.trim_start().starts_with("---"),
427 "{} body includes frontmatter",
428 skill.name
429 );
430 }
431 }
432
433 #[test]
434 fn skills_are_sorted_by_name() {
435 let names: Vec<&str> = list_embedded_skills()
436 .iter()
437 .map(|skill| skill.name)
438 .collect();
439 let mut sorted = names.clone();
440 sorted.sort_unstable();
441 assert_eq!(names, sorted);
442 }
443
444 #[test]
445 fn source_round_trips_to_frontmatter_and_body() {
446 for skill in list_embedded_skills() {
447 assert!(
448 split_frontmatter_parts(skill.source).is_some(),
449 "{} source missing opening fence",
450 skill.name
451 );
452 assert!(
453 skill.source.ends_with(skill.body),
454 "{} source must end with the body so dump output is byte-stable",
455 skill.name
456 );
457 assert!(
458 skill.source.contains(&format!("name: {}\n", skill.name))
459 || skill.source.contains(&format!("name: {}\r\n", skill.name)),
460 "{} source missing canonical name field",
461 skill.name
462 );
463 }
464 }
465
466 #[test]
467 fn frontmatter_split_accepts_crlf_sources() {
468 let source = "---\r\nname: crlf\r\n---\r\n# Body\r\n";
469 let (frontmatter, body) = split_frontmatter_parts(source).expect("CRLF frontmatter");
470 assert_eq!(frontmatter, "name: crlf");
471 assert_eq!(body, "# Body\r\n");
472 }
473
474 #[test]
475 fn frontmatter_split_rejects_missing_closing_fence() {
476 assert!(split_frontmatter_parts("---\nname: missing\n# Body\n").is_none());
477 }
478
479 #[test]
480 fn embedded_corpus_stays_within_binary_budget() {
481 let bytes: usize = SOURCES.iter().map(|source| source.len()).sum();
482 assert!(
483 bytes <= 200 * 1024,
484 "embedded corpus is {bytes} bytes, expected <= 200 KiB"
485 );
486 }
487
488 #[test]
489 fn skill_bodies_are_focused_and_not_placeholders() {
490 let expectations = [
491 ("harn-agent", ["agent_loop", "session id", "approval"]),
492 ("harn-de-slop", ["typed", "structural guard", "rebase"]),
493 ("harn-diagnostics", ["diagnostic", "repair", "conformance"]),
494 ("harn-language", ["quickref", "type", "conformance"]),
495 ("harn-orchestration", ["agent_loop", "workflow", "host"]),
496 ("harn-probe", ["probe", "fact", "evidence"]),
497 (
498 "harn-product-quality",
499 ["canonical path", "control", "liveness"],
500 ),
501 ("harn-providers", ["llm_call", "provider", "schema"]),
502 (
503 "harn-testing",
504 ["conformance", "deterministic", "mock_time"],
505 ),
506 ("harn-tracing", ["replay", "receipts", "transcript"]),
507 ("release-harn", ["release_ship", "merge queue", "tag"]),
508 ];
509
510 for (name, terms) in expectations {
511 let skill = get_embedded_skill(name).expect("expected embedded skill");
512 let body = skill.body.to_ascii_lowercase();
513 assert!(
514 !body.contains("embedded stub") && !body.contains("placeholder"),
515 "{name} should contain real guidance, not stub wording"
516 );
517 for term in terms {
518 assert!(
519 body.contains(term),
520 "{name} body should mention focused term `{term}`"
521 );
522 }
523 }
524 }
525
526 #[test]
527 fn skill_bodies_match_split_skill_contract() {
528 for skill in list_embedded_skills() {
529 let lines = skill.body.lines().count();
530 assert!(
531 lines >= 80,
532 "{} body is {lines} lines, expected at least 80",
533 skill.name
534 );
535 assert!(
536 lines <= 300,
537 "{} body is {lines} lines, expected at most 300",
538 skill.name
539 );
540 }
541 }
542
543 #[test]
544 fn language_skill_names_authoritative_spec_sources() {
545 let skill = get_embedded_skill("harn-language").expect("language skill");
546 assert!(
547 skill
548 .body
549 .contains("authoritative chapters under `spec/chapters/*.md`"),
550 "harn-language should direct spec edits to the registered source files"
551 );
552 for generated_edit_target in [
553 "Edit `spec/HARN_SPEC.md`",
554 "Edit `docs/src/language-spec.md`",
555 ] {
556 assert!(
557 !skill.body.contains(generated_edit_target),
558 "harn-language must not direct edits to generated projection `{generated_edit_target}`"
559 );
560 }
561 }
562
563 #[test]
564 fn skill_cross_links_resolve_to_embedded_skills() {
565 let names: BTreeSet<&str> = list_embedded_skills()
566 .iter()
567 .map(|skill| skill.name)
568 .collect();
569 for skill in list_embedded_skills() {
570 for reference in bracketed_skill_references(skill.body) {
571 assert!(
572 names.contains(reference),
573 "{} links to unknown embedded skill [[{}]]",
574 skill.name,
575 reference
576 );
577 }
578 }
579 }
580
581 #[test]
582 fn diagnostics_skill_mentions_all_code_categories() {
583 let skill = get_embedded_skill("harn-diagnostics").expect("diagnostics skill");
584 for category in [
585 "TYP", "PAR", "NAM", "CAP", "LLM", "ORC", "STD", "PRM", "MOD", "LNT", "FMT", "IMP",
586 "OWN", "RCV", "MAT",
587 ] {
588 assert!(
589 skill.body.contains(&format!("`{category}`")),
590 "harn-diagnostics should mention diagnostic category `{category}`"
591 );
592 }
593 }
594
595 #[test]
596 fn disk_discovery_finds_recursive_skill_files_sorted_by_name() {
597 let temp = TempDir::new().expect("temp dir");
598 write_skill(
599 &temp.path().join("zeta").join("SKILL.md"),
600 "zeta-skill",
601 "Zeta",
602 );
603 write_skill(
604 &temp.path().join("nested").join("alpha").join("SKILL.md"),
605 "alpha-skill",
606 "Alpha",
607 );
608
609 let skills = list_disk_skills(temp.path()).expect("discover disk skills");
610 let names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
611 assert_eq!(names, ["alpha-skill", "zeta-skill"]);
612 assert_eq!(skills[0].frontmatter.description, "Alpha description");
613 assert!(skills[0].body.contains("Alpha body"));
614 }
615
616 #[test]
617 fn disk_discovery_treats_missing_root_as_empty() {
618 let temp = TempDir::new().expect("temp dir");
619 let skills = list_disk_skills(temp.path().join("missing")).expect("discover disk skills");
620 assert!(skills.is_empty());
621 }
622
623 #[test]
624 fn disk_discovery_rejects_duplicate_skill_names() {
625 let temp = TempDir::new().expect("temp dir");
626 write_skill(
627 &temp.path().join("one").join("SKILL.md"),
628 "same-skill",
629 "One",
630 );
631 write_skill(
632 &temp.path().join("two").join("SKILL.md"),
633 "same-skill",
634 "Two",
635 );
636
637 let error = list_disk_skills(temp.path()).expect_err("duplicate name should fail");
638 assert!(
639 error.to_string().contains("duplicate skill `same-skill`"),
640 "unexpected error: {error}"
641 );
642 }
643
644 fn write_skill(path: &Path, name: &str, label: &str) {
645 fs::create_dir_all(path.parent().expect("skill parent")).expect("create skill parent");
646 fs::write(
647 path,
648 format!(
649 "---\nname: {name}\nshort: {label} short\ndescription: {label} description\n---\n# {label}\n\n{label} body\n"
650 ),
651 )
652 .expect("write SKILL.md");
653 }
654
655 fn bracketed_skill_references(body: &str) -> Vec<&str> {
656 let mut references = Vec::new();
657 let mut rest = body;
658 while let Some(start) = rest.find("[[") {
659 rest = &rest[start + 2..];
660 let Some(end) = rest.find("]]") else {
661 break;
662 };
663 references.push(&rest[..end]);
664 rest = &rest[end + 2..];
665 }
666 references
667 }
668}