Skip to main content

mur_common/skill/
loader.rs

1//! Single-pass skill loader: lists global + per-agent skills,
2//! resolves trust level, checks drift, returns one flat Vec.
3
4use crate::skill::types::TrustLevel;
5use crate::skill::{SkillManifest, content_hash_for_trust, local};
6use crate::trust::skills::SkillTrustStore;
7use std::path::Path;
8
9/// Validate that a skill name contains only safe identifier characters.
10///
11/// Skill names are interpolated into XML-like `<skill-instruction source="…">`
12/// attributes.  Restricting the character set at load time means injection is
13/// blocked at the source rather than relying solely on escaping at emit time.
14pub fn is_valid_skill_name(name: &str) -> bool {
15    !name.is_empty()
16        && name.len() <= 64
17        // Reserved path components: a skill name is joined into
18        // `<mur_home>/skills/<name>`, so `.`/`..` must never be accepted.
19        && name != "."
20        && name != ".."
21        // The character set already excludes `/` and `\`, which keeps a name to
22        // a single path component (no traversal into sibling/parent dirs).
23        && name
24            .chars()
25            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SkillScope {
30    Global,
31    Agent,
32}
33
34/// Outcome of resolving a `profile.yaml` skill ref (e.g. `skills/<name>`)
35/// against an agent's home directory.
36///
37/// Distinguishing `Missing` from `Malformed` matters: a ref written without
38/// installing the backing files (issue #717) is a *missing* skill — telling
39/// the user it "no longer parses" points them at the wrong root cause.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SkillRefStatus {
42    /// The ref resolves to a manifest that parses and validates.
43    Loadable,
44    /// No file exists at the resolved manifest path.
45    Missing { path: std::path::PathBuf },
46    /// A file exists but does not parse/validate as a skill manifest.
47    Malformed {
48        path: std::path::PathBuf,
49        error: String,
50    },
51    /// The ref itself cannot name a file — it holds whitespace, which a skill
52    /// id never does. Seen in the wild as several refs concatenated into one
53    /// `profile.yaml` list item (`skills/a - skills/b - skills/b`), growing by
54    /// a segment each time something appended to the string instead of pushing
55    /// a new item.
56    ///
57    /// Separate from `Missing` for the same reason `Missing` is separate from
58    /// `Malformed`: the advice differs. Nothing is missing here — the backing
59    /// skills are installed — so "install it" sends people to run a command
60    /// that cannot help. The entry has to be split or removed.
61    CorruptRef { reason: String },
62}
63
64/// Resolve a `profile.yaml` skill ref to its backing manifest file and report
65/// whether it is loadable, missing, or malformed.
66///
67/// Resolution mirrors the runtime loader's layout rules: modern refs point at
68/// a *directory* (`skills/<name>`) holding `skill.yaml`; legacy refs may point
69/// directly at a `.yaml`/`.yml`/`.md` file. This is the single source of truth
70/// for ref resolution — the Hub loadability badge and the creation-time
71/// validation in mur-core both call it.
72pub fn skill_ref_status(agent_home: &Path, rel_ref: &str) -> SkillRefStatus {
73    let joined = agent_home.join(rel_ref);
74    let ext = joined
75        .extension()
76        .and_then(|e| e.to_str())
77        .unwrap_or("")
78        .to_ascii_lowercase();
79    let file = if joined.is_dir() || !matches!(ext.as_str(), "yaml" | "yml" | "md" | "markdown") {
80        // Modern directory layout: the ref names the skill dir; the manifest
81        // lives inside it. Also used when the dir is absent, so the Missing
82        // path names the exact manifest we expected to find.
83        joined.join("skill.yaml")
84    } else {
85        joined
86    };
87    if !file.is_file() {
88        // Only reclassify once resolution has already failed, and only when the
89        // ref holds whitespace. Anything that resolves today keeps resolving —
90        // this can never turn a working ref into an error.
91        if rel_ref.split_whitespace().count() > 1 {
92            return SkillRefStatus::CorruptRef {
93                reason: "a skill ref cannot contain whitespace; this entry looks like several \
94                         refs concatenated — split it into separate list items, or remove it"
95                    .to_string(),
96            };
97        }
98        return SkillRefStatus::Missing { path: file };
99    }
100    let text = match std::fs::read_to_string(&file) {
101        Ok(t) => t,
102        Err(e) => {
103            return SkillRefStatus::Malformed {
104                path: file,
105                error: format!("unreadable: {e}"),
106            };
107        }
108    };
109    let ext = file
110        .extension()
111        .and_then(|e| e.to_str())
112        .unwrap_or("")
113        .to_ascii_lowercase();
114    let parsed = match ext.as_str() {
115        "yaml" | "yml" => crate::skill::parse_canonical(&text),
116        "md" | "markdown" => crate::skill::parse_markdown(&text)
117            .or_else(|_| crate::skill::parse_legacy_markdown(&text)),
118        other => {
119            return SkillRefStatus::Malformed {
120                path: file,
121                error: format!("unsupported manifest extension '.{other}'"),
122            };
123        }
124    };
125    match parsed {
126        Ok(m) => match crate::skill::validate(&m) {
127            Ok(()) => SkillRefStatus::Loadable,
128            Err(e) => SkillRefStatus::Malformed {
129                path: file,
130                error: format!("invalid manifest: {e}"),
131            },
132        },
133        Err(e) => SkillRefStatus::Malformed {
134            path: file,
135            error: format!("parse failed: {e}"),
136        },
137    }
138}
139
140#[derive(Debug, Clone)]
141pub struct LoadedSkill {
142    pub name: String,
143    pub manifest: SkillManifest,
144    pub trust: TrustLevel,
145    pub scope: SkillScope,
146    pub content_hash: String,
147    /// Absolute install directory of this skill (holds skill.yaml + any bundle).
148    pub dir: std::path::PathBuf,
149}
150
151pub fn load_all(mur_home: &Path, agent_name: &str) -> Vec<LoadedSkill> {
152    let trust = load_trust_migrated(mur_home);
153    let mut out: Vec<LoadedSkill> = Vec::new();
154    let mut seen_names: std::collections::HashSet<String> = Default::default();
155
156    // Per-agent first (wins on name collision)
157    if let Ok(names) = local::list_installed_agent(mur_home, agent_name) {
158        for name in names {
159            // Skip non-skill dirs (e.g. a fleet run-ledger `fleet:<name>/`
160            // written under skills/ by the DAG executor's record_run — it holds
161            // events.jsonl, not skill.yaml). Without this, its colon name trips
162            // is_valid_skill_name in load_one and spams a warning every load.
163            if !crate::skill::store::agent_skill_dir(mur_home, agent_name)
164                .join(&name)
165                .join("skill.yaml")
166                .is_file()
167            {
168                continue;
169            }
170            if let Some(mut loaded) =
171                load_one(mur_home, &name, SkillScope::Agent, &trust, |m, n| {
172                    local::load_installed_agent(m, agent_name, n)
173                })
174            {
175                loaded.dir = crate::skill::store::agent_skill_dir(mur_home, agent_name).join(&name);
176                seen_names.insert(loaded.name.clone());
177                out.push(loaded);
178            }
179        }
180    }
181    // Federated knowledge cache next (daemon-assembled snapshot of global
182    // skills at or above the lifecycle floor; federation P0). A cache entry
183    // wins over a same-named global — it IS that global skill, scope-filtered
184    // — but never over a per-agent install.
185    let cache_dir = mur_home
186        .join("agents")
187        .join(agent_name)
188        .join("knowledge_cache");
189    if let Ok(entries) = std::fs::read_dir(&cache_dir) {
190        let mut names: Vec<String> = entries
191            .filter_map(|e| e.ok())
192            .filter(|e| e.path().join("skill.yaml").is_file())
193            .filter_map(|e| e.file_name().to_str().map(String::from))
194            .collect();
195        names.sort(); // deterministic load order
196        for name in names {
197            if seen_names.contains(&name) {
198                continue;
199            }
200            let dir = cache_dir.join(&name);
201            let dir_for_loader = dir.clone();
202            if let Some(mut loaded) = load_one(
203                mur_home,
204                &name,
205                SkillScope::Global,
206                &trust,
207                move |_m, _n| crate::skill::read_from_dir(&dir_for_loader),
208            ) {
209                loaded.dir = dir;
210                seen_names.insert(loaded.name.clone());
211                out.push(loaded);
212            }
213        }
214    }
215
216    if let Ok(names) = local::list_installed(mur_home) {
217        for name in names {
218            if seen_names.contains(&name) {
219                continue;
220            }
221            // Skip non-skill dirs (see the agent loop above) — a manifest-less
222            // dir is a ledger/data dir, not a skill.
223            if !crate::skill::store::global_skill_dir(mur_home, &name)
224                .join("skill.yaml")
225                .is_file()
226            {
227                continue;
228            }
229            if let Some(mut loaded) = load_one(
230                mur_home,
231                &name,
232                SkillScope::Global,
233                &trust,
234                local::load_installed,
235            ) {
236                loaded.dir = crate::skill::store::global_skill_dir(mur_home, &name);
237                out.push(loaded);
238            }
239        }
240    }
241    out
242}
243
244/// Load the trust store, re-keying it into the trust-hash domain on first use.
245///
246/// The migration runs here rather than in a separate command because this is
247/// the one path every agent start goes through, so a store never stays stale
248/// long enough for the loss to be noticed. It is a no-op once the schema is
249/// current, and it is fail-soft in both directions: if the re-key cannot be
250/// saved the in-memory store is still correct for this run, and if a skill is
251/// missing from disk its entry is kept untouched.
252fn load_trust_migrated(mur_home: &Path) -> SkillTrustStore {
253    let mut trust = SkillTrustStore::load(mur_home).unwrap_or_default();
254    let Some(rekeyed) =
255        trust.migrate_to_trust_hash(|name| local::load_installed(mur_home, name).ok())
256    else {
257        return trust; // already current — the common case, no write
258    };
259    {
260        match trust.save(mur_home) {
261            Ok(()) => tracing::info!(
262                rekeyed,
263                "skill trust store migrated to the trust-hash domain"
264            ),
265            // Not fatal: the in-memory store is already correct, so this run
266            // resolves trust properly and the next start retries the write.
267            Err(e) => tracing::warn!(error = %e, "could not persist trust-store migration"),
268        }
269    }
270    trust
271}
272
273fn load_one<F>(
274    mur_home: &Path,
275    name: &str,
276    scope: SkillScope,
277    trust: &SkillTrustStore,
278    loader: F,
279) -> Option<LoadedSkill>
280where
281    F: FnOnce(&Path, &str) -> Result<SkillManifest, crate::skill::StoreError>,
282{
283    // Validate name before loading: only safe identifier characters allowed.
284    // Skill names are interpolated into XML attributes; an unvalidated name
285    // containing `"` or `>` could break the attribute boundary even after
286    // escaping if the validator itself is bypassed.
287    if !is_valid_skill_name(name) {
288        tracing::warn!(
289            skill = %name,
290            "skill name contains invalid characters (expected [A-Za-z0-9_.-]{{1,64}}); skipping"
291        );
292        return None;
293    }
294
295    let manifest = match loader(mur_home, name) {
296        Ok(m) => m,
297        Err(e) => {
298            tracing::warn!(skill = %name, error = %e, "skill load failed; skipping");
299            return None;
300        }
301    };
302    // `content_hash_for_trust`, not `content_sha256`: this is the trust-store
303    // key, and the trust hash excludes `transfer_chain` / `evolution_log` so a
304    // transfer or a generation increment does not silently re-key an already
305    // trusted skill. Using the plain content hash here is what made every
306    // transfer- and fleet-import-installed skill (which key by the trust hash)
307    // miss its entry and load as Sandboxed regardless of its recorded level.
308    let hash = match content_hash_for_trust(&manifest) {
309        Ok(h) => h,
310        Err(e) => {
311            tracing::warn!(skill = %name, error = %e, "skill hash failed; skipping");
312            return None;
313        }
314    };
315    // No separate drift check: the entry is KEYED by the content hash, so
316    // finding one is already proof the content matches the pinned bytes. The
317    // check that used to sit here compared `content_sha256(&manifest)` against
318    // a hash derived from the same manifest — a value against itself, which
319    // could never report drift. Worse, once the key became the trust hash the
320    // two would differ by construction and every skill would refuse to load.
321    let entry = trust.entries.get(&hash);
322    if let Some(pinned) = entry {
323        if trust.is_revoked(&hash) {
324            tracing::warn!(skill = %name, "skill hash revoked; skipping");
325            return None;
326        }
327        Some(LoadedSkill {
328            name: name.into(),
329            manifest,
330            trust: pinned.level,
331            scope,
332            content_hash: hash,
333            dir: std::path::PathBuf::new(), // overwritten by load_all
334        })
335    } else {
336        // Unpinned = first-load Sandboxed.
337        Some(LoadedSkill {
338            name: name.into(),
339            manifest,
340            trust: TrustLevel::Sandboxed,
341            scope,
342            content_hash: hash,
343            dir: std::path::PathBuf::new(), // overwritten by load_all
344        })
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::skill::{parse_canonical, write_to_dir};
352    use tempfile::tempdir;
353
354    /// The bug this domain unification fixes, stated as a test.
355    ///
356    /// A skill whose `evolution_log` grows — which happens during ordinary use —
357    /// keeps the same `content_hash_for_trust` but gets a NEW `content_sha256`.
358    /// The loader used to key on the latter, so the trust entry written at
359    /// install stopped matching and the skill silently dropped to `Sandboxed`.
360    #[test]
361    fn a_generation_increment_does_not_lose_the_recorded_trust_level() {
362        use crate::trust::skills::{SkillTrustStore, TrustEntry};
363        let dir = tempdir().unwrap();
364        let home = dir.path();
365        let mut m = make("evolving");
366        let sdir = home
367            .join("agents")
368            .join("a1")
369            .join("skills")
370            .join("evolving");
371        write_to_dir(&sdir, &m).unwrap();
372
373        // Trust recorded at install time, keyed by the trust hash.
374        let key = crate::skill::content_hash_for_trust(&m).unwrap();
375        let mut trust = SkillTrustStore::default();
376        trust.insert(
377            key.clone(),
378            TrustEntry {
379                name: "evolving".into(),
380                version: m.version.clone(),
381                level: TrustLevel::Trusted,
382                installed_at: "2026-08-19T00:00:00Z".into(),
383                ..Default::default()
384            },
385        );
386        trust.save(home).unwrap();
387
388        // The skill evolves in place: plain content hash moves, trust hash does not.
389        let before_plain = crate::skill::content_sha256(&m).unwrap();
390        m.evolution_log
391            .push(crate::skill::evolution::EvolutionEvent::initial_human(
392                "t", "1.0.0",
393            ));
394        write_to_dir(&sdir, &m).unwrap();
395        let after_plain = crate::skill::content_sha256(&m).unwrap();
396        assert_ne!(
397            before_plain, after_plain,
398            "precondition: an evolution entry must move the plain content hash"
399        );
400        assert_eq!(
401            key,
402            crate::skill::content_hash_for_trust(&m).unwrap(),
403            "precondition: the trust hash must be stable across a generation increment"
404        );
405
406        let loaded = load_all(home, "a1");
407        let s = loaded.iter().find(|s| s.name == "evolving").unwrap();
408        assert_eq!(
409            s.trust,
410            TrustLevel::Trusted,
411            "the recorded trust level was lost when the skill evolved"
412        );
413    }
414
415    /// A v1 store (keys from `content_sha256`) is re-keyed on first load, so an
416    /// existing install keeps its trust level across the upgrade instead of
417    /// silently reverting to Sandboxed.
418    #[test]
419    fn a_legacy_store_is_migrated_on_load() {
420        use crate::trust::skills::SkillTrustStore;
421        let dir = tempdir().unwrap();
422        let home = dir.path();
423        // The two domains differ only once a skill carries an evolution log or a
424        // transfer chain — for a pristine skill they are the same hash, which is
425        // why this migration touches far fewer entries than it might appear to.
426        let mut m = make("legacy");
427        m.evolution_log
428            .push(crate::skill::evolution::EvolutionEvent::initial_human(
429                "t", "1.0.0",
430            ));
431        write_to_dir(&home.join("skills").join("legacy"), &m).unwrap();
432
433        // v1: keyed by the plain content hash, and no schema field on disk.
434        let legacy_key = crate::skill::content_sha256(&m).unwrap();
435        let trust_key = crate::skill::content_hash_for_trust(&m).unwrap();
436        assert_ne!(
437            legacy_key, trust_key,
438            "precondition: the two domains must differ for this to be a migration"
439        );
440        let json = format!(
441            r#"{{"entries":{{"{legacy_key}":{{"name":"legacy","version":"{}","level":"trusted","installed_at":"2026-08-19T00:00:00Z"}}}},"revoked":[]}}"#,
442            m.version
443        );
444        std::fs::create_dir_all(home.join("trust")).unwrap();
445        std::fs::write(SkillTrustStore::path(home), json).unwrap();
446
447        let loaded = load_all(home, "a1");
448        let s = loaded.iter().find(|s| s.name == "legacy").unwrap();
449        assert_eq!(
450            s.trust,
451            TrustLevel::Trusted,
452            "a v1 entry must survive the domain change"
453        );
454
455        // ...and the migration is persisted, so it runs once.
456        let reloaded = SkillTrustStore::load(home).unwrap();
457        assert_eq!(reloaded.schema, crate::trust::skills::TRUST_STORE_SCHEMA);
458        assert!(reloaded.entries.contains_key(&trust_key));
459        assert!(!reloaded.entries.contains_key(&legacy_key));
460    }
461
462    #[test]
463    fn load_all_sets_agent_skill_dir() {
464        let dir = tempdir().unwrap();
465        let home = dir.path();
466        let sdir = home.join("agents").join("a1").join("skills").join("demo");
467        write_to_dir(&sdir, &make("demo")).unwrap();
468
469        let loaded = load_all(home, "a1");
470        let demo = loaded.iter().find(|s| s.name == "demo").unwrap();
471        assert_eq!(demo.dir, sdir);
472    }
473
474    fn make(name: &str) -> SkillManifest {
475        make_desc(name, "test")
476    }
477
478    fn make_desc(name: &str, desc: &str) -> SkillManifest {
479        parse_canonical(&format!(
480            r#"name: {name}
481version: 1.0.0
482publisher: human:t
483description: {desc}
484category: context
485content:
486  abstract: hi
487  context: body
488"#
489        ))
490        .unwrap()
491    }
492
493    #[test]
494    fn knowledge_cache_skill_loads() {
495        let dir = tempdir().unwrap();
496        let home = dir.path();
497        let cdir = home
498            .join("agents/a1/knowledge_cache")
499            .join("federated-skill");
500        write_to_dir(&cdir, &make("federated-skill")).unwrap();
501
502        let loaded = load_all(home, "a1");
503        let hit = loaded
504            .iter()
505            .find(|s| s.name == "federated-skill")
506            .expect("cached skill must be visible to the loader");
507        assert_eq!(hit.dir, cdir);
508    }
509
510    #[test]
511    fn agent_local_wins_over_cache_wins_over_global() {
512        let dir = tempdir().unwrap();
513        let home = dir.path();
514        write_to_dir(
515            &home.join("agents/a1/skills/dup"),
516            &make_desc("dup", "agent-local"),
517        )
518        .unwrap();
519        write_to_dir(
520            &home.join("agents/a1/knowledge_cache/dup"),
521            &make_desc("dup", "cache"),
522        )
523        .unwrap();
524        write_to_dir(&home.join("skills/dup"), &make_desc("dup", "global")).unwrap();
525
526        let loaded = load_all(home, "a1");
527        let dups: Vec<_> = loaded.iter().filter(|s| s.name == "dup").collect();
528        assert_eq!(dups.len(), 1, "name collision must resolve to ONE copy");
529        assert_eq!(dups[0].manifest.description, "agent-local");
530
531        // Remove the per-agent copy: the cache copy takes over, not the global.
532        std::fs::remove_dir_all(home.join("agents/a1/skills/dup")).unwrap();
533        let loaded = load_all(home, "a1");
534        let dup = loaded.iter().find(|s| s.name == "dup").unwrap();
535        assert_eq!(dup.manifest.description, "cache");
536    }
537
538    #[test]
539    fn empty_mur_home_returns_empty() {
540        let dir = tempdir().unwrap();
541        let loaded = load_all(dir.path(), "alice");
542        assert!(loaded.is_empty());
543    }
544
545    #[test]
546    fn load_all_skips_non_skill_dirs() {
547        let dir = tempdir().unwrap();
548        let home = dir.path();
549        // A real global skill (has skill.yaml)…
550        write_to_dir(&home.join("skills").join("real"), &make("real")).unwrap();
551        // …and a non-skill dir under skills/ (only events.jsonl, no skill.yaml) —
552        // e.g. a fleet run-ledger. Uses a portable name here: the real ledger id
553        // is `fleet:<name>`, but a colon is an illegal filename on Windows, so
554        // the test fixture would fail to even create it. The skip logic keys on
555        // the absent skill.yaml, not the name.
556        let ledger = home.join("skills").join("not-a-skill");
557        std::fs::create_dir_all(&ledger).unwrap();
558        std::fs::write(ledger.join("events.jsonl"), "{}\n").unwrap();
559
560        let loaded = load_all(home, "a1");
561        let names: Vec<_> = loaded.iter().map(|s| s.name.as_str()).collect();
562        assert_eq!(
563            names,
564            vec!["real"],
565            "ledger dir must not be loaded as a skill"
566        );
567    }
568
569    #[test]
570    fn is_valid_skill_name_rejects_traversal_and_reserved() {
571        // Legit names.
572        assert!(is_valid_skill_name("web-search"));
573        assert!(is_valid_skill_name("my.skill_v2"));
574        // Reserved path components.
575        assert!(!is_valid_skill_name("."));
576        assert!(!is_valid_skill_name(".."));
577        // Path separators (the dangerous traversal form) and absolutes.
578        assert!(!is_valid_skill_name("../agents/victim/skills/evil"));
579        assert!(!is_valid_skill_name("a/b"));
580        assert!(!is_valid_skill_name("a\\b"));
581        assert!(!is_valid_skill_name("/etc/passwd"));
582        // Bounds.
583        assert!(!is_valid_skill_name(""));
584        assert!(!is_valid_skill_name(&"x".repeat(65)));
585    }
586
587    #[test]
588    fn global_skill_returns_sandboxed_when_no_trust_entry() {
589        let dir = tempdir().unwrap();
590        write_to_dir(&dir.path().join("skills").join("demo"), &make("demo")).unwrap();
591        let loaded = load_all(dir.path(), "alice");
592        assert_eq!(loaded.len(), 1);
593        assert_eq!(loaded[0].name, "demo");
594        assert_eq!(loaded[0].trust, TrustLevel::Sandboxed);
595        assert_eq!(loaded[0].scope, SkillScope::Global);
596    }
597
598    #[test]
599    fn agent_overrides_global_by_name() {
600        let dir = tempdir().unwrap();
601        // Both global and agent have "shared"
602        write_to_dir(&dir.path().join("skills").join("shared"), &make("shared")).unwrap();
603        write_to_dir(
604            &dir.path()
605                .join("agents")
606                .join("alice")
607                .join("skills")
608                .join("shared"),
609            &make("shared"),
610        )
611        .unwrap();
612        let loaded = load_all(dir.path(), "alice");
613        let shared: Vec<_> = loaded.iter().filter(|s| s.name == "shared").collect();
614        assert_eq!(shared.len(), 1);
615        assert_eq!(shared[0].scope, SkillScope::Agent);
616    }
617
618    // ── skill_ref_status (#717): missing vs malformed ────────────────────
619
620    #[test]
621    fn skill_ref_status_loadable_for_installed_dir_skill() {
622        let home = tempdir().unwrap();
623        write_to_dir(&home.path().join("skills").join("demo"), &make("demo")).unwrap();
624        assert_eq!(
625            skill_ref_status(home.path(), "skills/demo"),
626            SkillRefStatus::Loadable
627        );
628    }
629
630    #[test]
631    fn skill_ref_status_absent_ref_is_missing_with_manifest_path() {
632        let home = tempdir().unwrap();
633        match skill_ref_status(home.path(), "skills/executing-plans") {
634            SkillRefStatus::Missing { path } => {
635                // The reported path names the exact manifest we expected.
636                assert!(path.ends_with("skills/executing-plans/skill.yaml"));
637            }
638            other => panic!("expected Missing, got {other:?}"),
639        }
640    }
641
642    /// Observed on a live concierge: one `profile.yaml` item holding ten refs
643    /// run together, growing by a segment every time something appended to the
644    /// string instead of pushing a new item. Reported as `missing`, which sent
645    /// the user to install skills that were already installed.
646    #[test]
647    fn skill_ref_status_concatenated_refs_are_corrupt_not_missing() {
648        let home = tempdir().unwrap();
649        let ref_ = "skills/pm-spec-handoff - skills/brainstorming - skills/brainstorming";
650        match skill_ref_status(home.path(), ref_) {
651            SkillRefStatus::CorruptRef { reason } => {
652                assert!(reason.contains("whitespace"), "unhelpful reason: {reason}");
653            }
654            other => panic!("expected CorruptRef, got {other:?}"),
655        }
656    }
657
658    /// The regression that matters most: a plain uninstalled ref must keep
659    /// reporting `Missing`, because there "install it" is the right advice.
660    #[test]
661    fn skill_ref_status_plain_absent_ref_stays_missing() {
662        let home = tempdir().unwrap();
663        assert!(matches!(
664            skill_ref_status(home.path(), "skills/never-installed"),
665            SkillRefStatus::Missing { .. }
666        ));
667    }
668
669    #[test]
670    fn skill_ref_status_garbage_yaml_is_malformed() {
671        let home = tempdir().unwrap();
672        let sdir = home.path().join("skills").join("broken");
673        std::fs::create_dir_all(&sdir).unwrap();
674        std::fs::write(sdir.join("skill.yaml"), "{{{ not: [valid").unwrap();
675        assert!(matches!(
676            skill_ref_status(home.path(), "skills/broken"),
677            SkillRefStatus::Malformed { .. }
678        ));
679    }
680
681    #[test]
682    fn skill_ref_status_legacy_md_file_resolves_directly() {
683        let home = tempdir().unwrap();
684        let sdir = home.path().join("skills");
685        std::fs::create_dir_all(&sdir).unwrap();
686        // Absent legacy .md ref → Missing at the file itself (no /skill.yaml).
687        match skill_ref_status(home.path(), "skills/old.md") {
688            SkillRefStatus::Missing { path } => assert!(path.ends_with("skills/old.md")),
689            other => panic!("expected Missing, got {other:?}"),
690        }
691        // Present but unparseable legacy .md → Malformed.
692        std::fs::write(sdir.join("old.md"), "no frontmatter here").unwrap();
693        assert!(matches!(
694            skill_ref_status(home.path(), "skills/old.md"),
695            SkillRefStatus::Malformed { .. }
696        ));
697    }
698}