Skip to main content

harn_skills/
lib.rs

1//! Embedded Harn skill corpus.
2//!
3//! This crate exposes the bundled corpus as metadata plus `SKILL.md`
4//! bodies. CLI commands that enumerate, dump, or install these skills
5//! are layered above this foundation.
6
7use 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
15/// Environment override for canonical Harn skill discovery.
16pub const HARN_SKILLS_DIR_ENV: &str = "HARN_SKILLS_DIR";
17
18/// Frontmatter fields embedded with each bundled skill.
19#[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/// A single skill embedded into the Harn build.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct EmbeddedSkill {
30    pub name: &'static str,
31    pub frontmatter: SkillFrontmatter,
32    pub body: &'static str,
33    /// The full original SKILL.md source — frontmatter delimiter,
34    /// frontmatter block, blank line, and body — exactly as embedded.
35    /// Use this when round-tripping a skill back to disk so the dumped
36    /// copy is byte-identical to the binary's canonical record.
37    pub source: &'static str,
38}
39
40/// Owned frontmatter fields loaded from a `SKILL.md` on disk.
41#[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/// A single skill discovered recursively from `HARN_SKILLS_DIR`.
50#[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/// The active canonical corpus used by `harn skill list/get`.
60#[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/// Error returned when disk skill discovery finds malformed files.
84#[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
150/// Return every skill bundled into this build.
151pub 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
157/// Return one bundled skill by canonical skill name.
158pub fn get_embedded_skill(name: &str) -> Option<&'static EmbeddedSkill> {
159    list_embedded_skills()
160        .iter()
161        .find(|skill| skill.name == name)
162}
163
164/// Return the active canonical corpus. `HARN_SKILLS_DIR` wins only
165/// when it contains at least one recursively discovered `SKILL.md`;
166/// otherwise callers fall back to the embedded corpus.
167pub 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
183/// Recursively discover `SKILL.md` files under `root`.
184///
185/// A missing root is treated as an empty disk corpus so
186/// `HARN_SKILLS_DIR` can fall back cleanly to embedded skills.
187pub 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}