Skip to main content

shuyi_cli/
skills.rs

1//! The engine's skill pack (ADR-0005 §2, ADR-0043): `skills/<name>/SKILL.md`
2//! folders in the open Agent Skills format, each with a worked example
3//! (`example.ron`, a script of contract commands) unless it waits for work
4//! still to come.
5//!
6//! - [`Pack`]: the skills of a folder, by name, with their files: read from
7//!   a checkout of the engine ([`Pack::read`]) or the one this `shuyi`
8//!   embeds ([`Pack::embedded`]). `shuyi new` copies a pack's skills into a
9//!   new game's `.claude/skills/`, all but the modules' (`shuyi add` copies
10//!   those);
11//! - [`Frontmatter`]: what a `SKILL.md` says of itself, between its `---`
12//!   lines: the open format's keys, and in `metadata` the pack's own,
13//!   `version` and `waits_for`;
14//! - [`check`]: `shuyi check-skills`' checks of a pack, each problem with
15//!   its file and line: the frontmatter, the `shuyi` commands the skills
16//!   name, the files they link, their length, what they wait for and their
17//!   worked examples, and the problem codes they name.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fmt;
21use std::fs;
22use std::path::{Component, Path};
23
24use include_dir::{Dir, DirEntry, include_dir};
25use serde::Deserialize;
26
27use crate::contract::Op;
28use crate::contract::codes;
29use crate::contract::new::ENGINE_GIT;
30
31/// The skill pack, from the engine's root.
32pub const PACK: &str = "skills";
33/// A skill's own file, in its folder.
34pub const FILE: &str = "SKILL.md";
35/// A skill's worked example, in its folder.
36pub const EXAMPLE: &str = "example.ron";
37/// The most lines a `SKILL.md` may have: the open format keeps the rest in
38/// files it loads on demand.
39pub const MAX_LINES: usize = 500;
40/// The longest a skill's name may be.
41pub const MAX_NAME: usize = 64;
42/// The longest a skill's description may be.
43pub const MAX_DESCRIPTION: usize = 1024;
44/// The longest a skill's `compatibility` may be.
45pub const MAX_COMPATIBILITY: usize = 500;
46
47/// The pack this `shuyi` was built with, embedded.
48static EMBEDDED: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../skills");
49
50/// A skill: its name (its folder's) and its files.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Skill {
53    /// Its name, its folder's.
54    pub name: String,
55    /// Every file of its folder, by its path from there (`/`-separated),
56    /// with its bytes.
57    pub files: BTreeMap<String, Vec<u8>>,
58}
59
60impl Skill {
61    /// Its `SKILL.md`, as text.
62    pub fn text(&self) -> Option<String> {
63        self.files
64            .get(FILE)
65            .map(|bytes| String::from_utf8_lossy(bytes).replace("\r\n", "\n"))
66    }
67
68    /// Its frontmatter, read; or what is wrong with it.
69    pub fn frontmatter(&self) -> Result<Frontmatter, String> {
70        let text = self.text().ok_or_else(|| format!("no {FILE}"))?;
71        Frontmatter::parse(&text).map(|(frontmatter, _)| frontmatter)
72    }
73
74    /// Whether it has a worked example.
75    pub fn has_example(&self) -> bool {
76        self.files.contains_key(EXAMPLE)
77    }
78}
79
80/// A skill pack: its skills, by name.
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct Pack {
83    /// Its skills, in name order.
84    pub skills: Vec<Skill>,
85    /// Its folders that hold no `SKILL.md`, by name.
86    pub not_skills: Vec<String>,
87}
88
89impl Pack {
90    /// The pack in folder `dir`: each folder in it with its files (a file
91    /// beside them, a README, is no skill). A missing folder is an empty
92    /// pack.
93    pub fn read(dir: &Path) -> Result<Self, String> {
94        let mut pack = Self::default();
95        let Ok(entries) = fs::read_dir(dir) else {
96            return Ok(pack);
97        };
98        for entry in entries {
99            let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
100            let path = entry.path();
101            if !path.is_dir() {
102                continue;
103            }
104            let name = entry.file_name().to_string_lossy().into_owned();
105            let mut files = BTreeMap::new();
106            read_files(&path, &path, &mut files)?;
107            pack.add(name, files);
108        }
109        pack.sort();
110        Ok(pack)
111    }
112
113    /// The pack this `shuyi` was built with.
114    pub fn embedded() -> Self {
115        let mut pack = Self::default();
116        for entry in EMBEDDED.entries() {
117            if let DirEntry::Dir(dir) = entry {
118                let name = dir
119                    .path()
120                    .file_name()
121                    .map(|n| n.to_string_lossy().into_owned())
122                    .unwrap_or_default();
123                let mut files = BTreeMap::new();
124                embedded_files(dir, dir.path(), &mut files);
125                pack.add(name, files);
126            }
127        }
128        pack.sort();
129        pack
130    }
131
132    /// The skill named `name`.
133    pub fn skill(&self, name: &str) -> Option<&Skill> {
134        self.skills.iter().find(|s| s.name == name)
135    }
136
137    fn add(&mut self, name: String, files: BTreeMap<String, Vec<u8>>) {
138        if files.contains_key(FILE) {
139            self.skills.push(Skill { name, files });
140        } else {
141            self.not_skills.push(name);
142        }
143    }
144
145    fn sort(&mut self) {
146        self.skills.sort_by(|a, b| a.name.cmp(&b.name));
147        self.not_skills.sort();
148    }
149}
150
151/// Every file under `dir` into `files`, by its path from `root`.
152fn read_files(
153    root: &Path,
154    dir: &Path,
155    files: &mut BTreeMap<String, Vec<u8>>,
156) -> Result<(), String> {
157    let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
158    for entry in entries {
159        let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
160        let path = entry.path();
161        let kind = entry
162            .file_type()
163            .map_err(|e| format!("{}: {e}", path.display()))?;
164        if kind.is_dir() {
165            read_files(root, &path, files)?;
166        } else if kind.is_file() {
167            let bytes = fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?;
168            files.insert(shown(path.strip_prefix(root).unwrap_or(&path)), bytes);
169        }
170    }
171    Ok(())
172}
173
174/// Every file of an embedded folder into `files`, by its path from `root`.
175fn embedded_files(dir: &Dir<'_>, root: &Path, files: &mut BTreeMap<String, Vec<u8>>) {
176    for entry in dir.entries() {
177        match entry {
178            DirEntry::Dir(d) => embedded_files(d, root, files),
179            DirEntry::File(f) => {
180                let path = f.path().strip_prefix(root).unwrap_or(f.path());
181                files.insert(shown(path), f.contents().to_vec());
182            }
183        }
184    }
185}
186
187/// A path, `/`-separated.
188fn shown(path: &Path) -> String {
189    path.to_string_lossy().replace('\\', "/")
190}
191
192/// What a `SKILL.md` says of itself: the open Agent Skills format's keys,
193/// and no others.
194#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct Frontmatter {
197    /// Its name: its folder's.
198    pub name: String,
199    /// What it does, when to use it and when not.
200    pub description: String,
201    /// The license of its files.
202    #[serde(default)]
203    pub license: Option<String>,
204    /// What it needs to work.
205    #[serde(default)]
206    pub compatibility: Option<String>,
207    /// The tools it may use without asking, as some agents read it.
208    #[serde(default, rename = "allowed-tools")]
209    pub allowed_tools: Option<String>,
210    /// Its own keys, each a string: the pack's are `version` and
211    /// `waits_for`.
212    #[serde(default)]
213    pub metadata: BTreeMap<String, String>,
214}
215
216impl Frontmatter {
217    /// The frontmatter of `SKILL.md`'s text, and the line its body starts
218    /// on (from 1); or what is wrong with it.
219    pub fn parse(text: &str) -> Result<(Self, usize), String> {
220        let rest = text
221            .strip_prefix("---\n")
222            .ok_or("the file starts with no `---` line before its frontmatter")?;
223        let end = rest
224            .find("\n---\n")
225            .or_else(|| rest.strip_suffix("\n---").map(str::len))
226            .ok_or("the frontmatter has no closing `---` line")?;
227        let yaml = &rest[..end];
228        let frontmatter: Self = serde_norway::from_str(yaml).map_err(|e| {
229            format!("the frontmatter doesn't read as the open format's keys, each metadata value a string: {e}")
230        })?;
231        Ok((frontmatter, yaml.lines().count() + 3))
232    }
233
234    /// The ticket ids it waits for: `metadata.waits_for`, split on spaces
235    /// and commas.
236    pub fn waits_for(&self) -> Vec<String> {
237        self.metadata
238            .get("waits_for")
239            .map(|ids| {
240                ids.split([' ', ','])
241                    .filter(|id| !id.is_empty())
242                    .map(str::to_owned)
243                    .collect()
244            })
245            .unwrap_or_default()
246    }
247}
248
249/// A problem `check` found: the file (from the pack's folder), its line
250/// when it has one, and what is wrong.
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct Problem {
253    /// The file, from the pack's folder (`shuyi-gates/SKILL.md`).
254    pub file: String,
255    /// Its line, from 1.
256    pub line: Option<usize>,
257    /// What is wrong.
258    pub message: String,
259}
260
261impl fmt::Display for Problem {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self.line {
264            Some(line) => write!(f, "{}:{line}: {}", self.file, self.message),
265            None => write!(f, "{}: {}", self.file, self.message),
266        }
267    }
268}
269
270/// What `check` checks against, besides the pack.
271#[derive(Debug, Clone, Default, PartialEq, Eq)]
272pub struct Against {
273    /// The `shuyi` commands a skill may name: the contract's operations,
274    /// the MCP session's tools and the other commands.
275    pub commands: BTreeSet<String>,
276    /// The ids of the tickets still to come, if known: each id in a
277    /// `waits_for` must be one.
278    pub open_tickets: Option<BTreeSet<String>>,
279}
280
281impl Against {
282    /// This `shuyi`'s commands ([`crate::COMMANDS`] and the contract's),
283    /// and the ticket ids of the open tickets in `tickets` if given (each
284    /// `<id>-….md` file there).
285    pub fn this_shuyi(tickets: Option<&Path>) -> Result<Self, String> {
286        let mut commands: BTreeSet<String> = Op::ALL
287            .iter()
288            .chain(Op::SESSION.iter())
289            .map(|op| op.name().to_owned())
290            .collect();
291        commands.extend(crate::COMMANDS.iter().map(|c| (*c).to_owned()));
292        let open_tickets = match tickets {
293            None => None,
294            Some(dir) => {
295                let entries =
296                    fs::read_dir(dir).map_err(|e| format!("--tickets {}: {e}", dir.display()))?;
297                let ids = entries
298                    .flatten()
299                    .filter_map(|e| {
300                        let name = e.file_name().to_string_lossy().into_owned();
301                        let id = name.split_once('-')?.0.to_owned();
302                        Path::new(&name)
303                            .extension()
304                            .is_some_and(|x| x == "md")
305                            .then_some(id)
306                    })
307                    .collect();
308                Some(ids)
309            }
310        };
311        Ok(Self {
312            commands,
313            open_tickets,
314        })
315    }
316}
317
318/// Every problem of `pack`, checked against `against`, in order of skill
319/// and file:
320///
321/// 1. its frontmatter reads, with its folder's `name` (lowercase letters,
322///    digits and single `-`), a `description` that says when to use it
323///    (`Use …`) and when not (`Not …`), and a `metadata.version` (`1.0`);
324/// 2. every `shuyi <command>` in the code of its Markdown files is a
325///    command of `shuyi`;
326/// 3. every relative link of its Markdown files names a file of the pack,
327///    and none names the engine's repository on `main` (a skill sends an
328///    agent to the project's `AGENTS.md`, which names the engine's docs at
329///    the version the game builds with);
330/// 4. its name, description, compatibility and `SKILL.md` are within the
331///    format's lengths ([`MAX_NAME`], [`MAX_DESCRIPTION`],
332///    [`MAX_COMPATIBILITY`], [`MAX_LINES`]);
333/// 5. every id in its `waits_for` is an open ticket (when they are
334///    known), and a skill of the engine's pack ([`Pack::embedded`], by
335///    name) that waits for nothing has a worked example. A project's own
336///    skill, one the embedded pack doesn't have, needs none: it teaches
337///    the project's own workflow, with nothing for a new game to run;
338/// 6. every problem code (`SH2003`) in its files is a row of the table
339///    (`shuyi explain`) that isn't retired.
340///
341/// A folder of the pack with no `SKILL.md` is a problem too.
342pub fn check(pack: &Pack, against: &Against) -> Vec<Problem> {
343    let mut problems: Vec<Problem> = pack
344        .not_skills
345        .iter()
346        .map(|name| Problem {
347            file: format!("{name}/"),
348            line: None,
349            message: format!("a folder of the pack with no {FILE}: every skill has one"),
350        })
351        .collect();
352    let engines: BTreeSet<String> = Pack::embedded()
353        .skills
354        .into_iter()
355        .map(|skill| skill.name)
356        .collect();
357    for skill in &pack.skills {
358        let engines = engines.contains(&skill.name);
359        check_skill(pack, skill, engines, against, &mut problems);
360    }
361    problems
362}
363
364/// The problems of one skill of `pack`, into `problems`; `engines` when
365/// it is a skill of the engine's pack, which has a worked example.
366fn check_skill(
367    pack: &Pack,
368    skill: &Skill,
369    engines: bool,
370    against: &Against,
371    problems: &mut Vec<Problem>,
372) {
373    let main = format!("{}/{FILE}", skill.name);
374    let problem = |file: &str, line: Option<usize>, message: String| Problem {
375        file: file.to_owned(),
376        line,
377        message,
378    };
379    let text = skill.text().unwrap_or_default();
380    match Frontmatter::parse(&text) {
381        Err(e) => problems.push(problem(&main, Some(1), e)),
382        Ok((frontmatter, _)) => {
383            for message in frontmatter_problems(&skill.name, &frontmatter) {
384                problems.push(problem(&main, Some(1), message));
385            }
386            let waits = frontmatter.waits_for();
387            if let Some(open) = &against.open_tickets {
388                for id in waits.iter().filter(|id| !open.contains(*id)) {
389                    problems.push(problem(
390                        &main,
391                        Some(1),
392                        format!(
393                            "waits_for: {id} is no open ticket: if it is done, take its id out, \
394                             and write the skill's worked example once nothing is left"
395                        ),
396                    ));
397                }
398            }
399            if engines && waits.is_empty() && !skill.has_example() {
400                problems.push(problem(
401                    &main,
402                    None,
403                    format!(
404                        "no worked example ({EXAMPLE}): a skill of the engine's pack that waits \
405                         for nothing has one"
406                    ),
407                ));
408            }
409        }
410    }
411    let lines = text.lines().count();
412    if lines > MAX_LINES {
413        problems.push(problem(
414            &main,
415            None,
416            format!(
417                "{lines} lines, over {MAX_LINES}: move the detail into a file it links, which \
418                 an agent reads when it needs it"
419            ),
420        ));
421    }
422    for (path, bytes) in &skill.files {
423        let Ok(text) = std::str::from_utf8(bytes) else {
424            continue;
425        };
426        let text = text.replace("\r\n", "\n");
427        let file = format!("{}/{path}", skill.name);
428        for (line, code) in codes_in(&text) {
429            if let Some(message) = code_problem(&code, codes::entry(&code)) {
430                problems.push(problem(&file, Some(line), message));
431            }
432        }
433        let markdown = Path::new(path)
434            .extension()
435            .is_some_and(|e| e.eq_ignore_ascii_case("md"));
436        if !markdown {
437            continue;
438        }
439        for (line, command) in commands_in(&text) {
440            if !against.commands.contains(&command) {
441                problems.push(problem(
442                    &file,
443                    Some(line),
444                    format!("`shuyi {command}`: no such command (`shuyi` alone lists them)"),
445                ));
446            }
447        }
448        for (line, target) in links_in(&text) {
449            if let Some(message) = link_problem(pack, &skill.name, path, &target) {
450                problems.push(problem(&file, Some(line), message));
451            }
452        }
453    }
454}
455
456/// What is wrong with a skill naming problem code `code`, whose row in the
457/// table is `entry`: none, or a retired one.
458fn code_problem(code: &str, entry: Option<&codes::Entry>) -> Option<String> {
459    match entry {
460        None => Some(format!(
461            "{code} is no problem code: `shuyi explain` lists them"
462        )),
463        Some(entry) if entry.retired => {
464            Some(format!("{code} is retired: no error gives it any more"))
465        }
466        Some(_) => None,
467    }
468}
469
470/// What is wrong with the frontmatter of the skill in folder `folder`.
471fn frontmatter_problems(folder: &str, frontmatter: &Frontmatter) -> Vec<String> {
472    let mut problems = Vec::new();
473    let name = &frontmatter.name;
474    if name != folder {
475        problems.push(format!(
476            "name: `{name}` is not its folder's name, `{folder}`"
477        ));
478    }
479    let well_formed = !name.is_empty()
480        && name.split('-').all(|w| {
481            !w.is_empty()
482                && w.chars()
483                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
484        });
485    if !well_formed {
486        problems.push(format!(
487            "name: `{name}`: lowercase letters and digits in words joined by single `-`"
488        ));
489    }
490    if name.chars().count() > MAX_NAME {
491        problems.push(format!("name: longer than {MAX_NAME} characters"));
492    }
493    let description = &frontmatter.description;
494    if !description.contains("Use ") {
495        problems.push("description: say when to use it, in a sentence starting `Use …`".to_owned());
496    }
497    if !description.contains("Not ") {
498        problems
499            .push("description: say when not to use it, in a sentence starting `Not …`".to_owned());
500    }
501    let length = description.chars().count();
502    if length > MAX_DESCRIPTION {
503        problems.push(format!(
504            "description: {length} characters, over {MAX_DESCRIPTION}"
505        ));
506    }
507    if let Some(compatibility) = &frontmatter.compatibility
508        && compatibility.chars().count() > MAX_COMPATIBILITY
509    {
510        problems.push(format!(
511            "compatibility: longer than {MAX_COMPATIBILITY} characters"
512        ));
513    }
514    let version = frontmatter.metadata.get("version");
515    let is_version = version.is_some_and(|v| {
516        !v.is_empty()
517            && v.split('.')
518                .all(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
519    });
520    if !is_version {
521        problems.push(
522            "metadata.version: give the skill a version, a string of numbers and dots (\"1.0\")"
523                .to_owned(),
524        );
525    }
526    problems
527}
528
529/// Pieces of a text, each with the line it starts on (from 1).
530type Pieces = Vec<(usize, String)>;
531
532/// The code and the prose of `text`. A fenced block's lines are code, and
533/// so is an inline span between backticks, which may wrap onto the next
534/// lines of its paragraph; the rest of each line is prose.
535fn split_code(text: &str) -> (Pieces, Pieces) {
536    let mut code = Vec::new();
537    let mut prose = Vec::new();
538    let mut fenced = false;
539    let mut span: Option<(usize, String)> = None;
540    for (i, line) in text.lines().enumerate() {
541        let trimmed = line.trim_start();
542        let fence = trimmed.starts_with("```") || trimmed.starts_with("~~~");
543        if fence || trimmed.is_empty() {
544            // A span still open at a paragraph's end was a lone backtick.
545            span = None;
546            fenced ^= fence;
547            continue;
548        }
549        if fenced {
550            code.push((i + 1, line.to_owned()));
551            continue;
552        }
553        let mut outside = String::new();
554        for (n, part) in line.split('`').enumerate() {
555            if n > 0 {
556                span = match span.take() {
557                    Some(closed) => {
558                        code.push(closed);
559                        None
560                    }
561                    None => Some((i + 1, String::new())),
562                };
563            }
564            if let Some((_, text)) = &mut span {
565                if !text.is_empty() {
566                    text.push(' ');
567                }
568                text.push_str(part);
569            } else {
570                outside.push_str(part);
571                outside.push(' ');
572            }
573        }
574        prose.push((i + 1, outside));
575    }
576    (code, prose)
577}
578
579/// Each `shuyi <command>` in the code of `text`, with its line: the word
580/// after a `shuyi` that starts the code or follows a space.
581fn commands_in(text: &str) -> Vec<(usize, String)> {
582    let mut found = Vec::new();
583    for (line, code) in split_code(text).0 {
584        let words: Vec<&str> = code.split_whitespace().collect();
585        for pair in words.windows(2) {
586            let (word, next) = (pair[0], pair[1]);
587            let starts = next.starts_with(|c: char| c.is_ascii_lowercase());
588            if word == "shuyi" && starts {
589                let command: String = next
590                    .chars()
591                    .take_while(|c| {
592                        c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-' || *c == '_'
593                    })
594                    .collect();
595                found.push((line, command));
596            }
597        }
598    }
599    found
600}
601
602/// Each problem code (`SH` and four digits) in `text`, with its line.
603fn codes_in(text: &str) -> Vec<(usize, String)> {
604    let mut found = Vec::new();
605    for (i, line) in text.lines().enumerate() {
606        let bytes = line.as_bytes();
607        let mut at = 0;
608        while let Some(offset) = line[at..].find("SH") {
609            let start = at + offset;
610            let digits = &bytes[start + 2..];
611            let four = digits.len() >= 4 && digits[..4].iter().all(u8::is_ascii_digit);
612            let fifth = digits.get(4).is_some_and(u8::is_ascii_alphanumeric);
613            let before = start > 0 && bytes[start - 1].is_ascii_alphanumeric();
614            if four && !fifth && !before {
615                found.push((i + 1, line[start..start + 6].to_owned()));
616            }
617            at = start + 2;
618        }
619    }
620    found
621}
622
623/// Each link's target in the prose of `text` (`[words](target)`), with its
624/// line, and every URL of the engine's repository anywhere in it.
625fn links_in(text: &str) -> Vec<(usize, String)> {
626    let mut found = Vec::new();
627    for (line, prose) in split_code(text).1 {
628        let mut rest = prose.as_str();
629        while let Some(at) = rest.find("](") {
630            let after = &rest[at + 2..];
631            let Some(end) = after.find(')') else {
632                break;
633            };
634            found.push((line, after[..end].trim().to_owned()));
635            rest = &after[end..];
636        }
637    }
638    for (i, line) in text.lines().enumerate() {
639        let mut rest = line;
640        while let Some(at) = rest.find(ENGINE_GIT) {
641            let url: String = rest[at..]
642                .chars()
643                .take_while(|c| !c.is_whitespace() && !matches!(c, ')' | '>' | '`' | '"'))
644                .collect();
645            if !found.iter().any(|(l, t)| *l == i + 1 && *t == url) {
646                found.push((i + 1, url.clone()));
647            }
648            rest = &rest[at + ENGINE_GIT.len()..];
649        }
650    }
651    found
652}
653
654/// What is wrong with a link to `target` in the file `path` of skill
655/// `skill`, if anything.
656fn link_problem(pack: &Pack, skill: &str, path: &str, target: &str) -> Option<String> {
657    if target.starts_with(ENGINE_GIT) {
658        let on_main = ["/blob/main/", "/tree/main/", "/blob/main", "/tree/main"]
659            .iter()
660            .any(|m| target[ENGINE_GIT.len()..].starts_with(m));
661        return on_main.then(|| {
662            format!(
663                "{target}: a fixed page on the engine's `main`: send the agent to the project's \
664                 `AGENTS.md`, which names the engine's docs at the version the game builds with"
665            )
666        });
667    }
668    let target = target.split(['#', '?']).next().unwrap_or(target);
669    let external = target.contains("://") || target.starts_with("mailto:");
670    if target.is_empty() || external {
671        return None;
672    }
673    // The link from the pack's folder: the skill's folder, the file's own
674    // folder in it, then the target.
675    let mut at: Vec<String> = vec![skill.to_owned()];
676    at.extend(path.split('/').map(str::to_owned));
677    at.pop();
678    for component in Path::new(target).components() {
679        match component {
680            Component::CurDir => {}
681            Component::ParentDir => {
682                if at.pop().is_none() {
683                    return Some(format!(
684                        "{target}: the link leaves the skill pack, which is all a game gets: \
685                         link a file of the pack, or name the file in words"
686                    ));
687                }
688            }
689            Component::Normal(name) => at.push(name.to_string_lossy().into_owned()),
690            Component::RootDir | Component::Prefix(_) => {
691                return Some(format!(
692                    "{target}: a link from the root: give it from the file's folder"
693                ));
694            }
695        }
696    }
697    let Some((first, rest)) = at.split_first() else {
698        return Some(format!(
699            "{target}: the link names the pack's folder, not a file"
700        ));
701    };
702    let exists = pack.skill(first).is_some_and(|s| {
703        let path = rest.join("/");
704        path.is_empty()
705            || s.files.contains_key(&path)
706            || s.files.keys().any(|f| f.starts_with(&format!("{path}/")))
707    });
708    (!exists).then(|| format!("{target}: no such file in the skill pack"))
709}
710
711#[cfg(test)]
712mod tests;