1use 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
31pub const PACK: &str = "skills";
33pub const FILE: &str = "SKILL.md";
35pub const EXAMPLE: &str = "example.ron";
37pub const MAX_LINES: usize = 500;
40pub const MAX_NAME: usize = 64;
42pub const MAX_DESCRIPTION: usize = 1024;
44pub const MAX_COMPATIBILITY: usize = 500;
46
47static EMBEDDED: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../skills");
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Skill {
53 pub name: String,
55 pub files: BTreeMap<String, Vec<u8>>,
58}
59
60impl Skill {
61 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 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 pub fn has_example(&self) -> bool {
76 self.files.contains_key(EXAMPLE)
77 }
78}
79
80#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct Pack {
83 pub skills: Vec<Skill>,
85 pub not_skills: Vec<String>,
87}
88
89impl Pack {
90 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 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 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
151fn 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
174fn 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
187fn shown(path: &Path) -> String {
189 path.to_string_lossy().replace('\\', "/")
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct Frontmatter {
197 pub name: String,
199 pub description: String,
201 #[serde(default)]
203 pub license: Option<String>,
204 #[serde(default)]
206 pub compatibility: Option<String>,
207 #[serde(default, rename = "allowed-tools")]
209 pub allowed_tools: Option<String>,
210 #[serde(default)]
213 pub metadata: BTreeMap<String, String>,
214}
215
216impl Frontmatter {
217 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 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#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct Problem {
253 pub file: String,
255 pub line: Option<usize>,
257 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
272pub struct Against {
273 pub commands: BTreeSet<String>,
276 pub open_tickets: Option<BTreeSet<String>>,
279}
280
281impl Against {
282 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
318pub 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
364fn 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
456fn 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
470fn 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
529type Pieces = Vec<(usize, String)>;
531
532fn 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 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
579fn 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
602fn 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
623fn 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
654fn 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 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;