1use anyhow::{Context, Result};
4use serde_yaml::{Mapping, Value};
5use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7
8const RESOURCE_ROOTS: &[&str] = &["runbooks", "references", "scripts", "assets", "okf"];
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct OkfReport {
12 pub root: PathBuf,
13 pub okf_version: Option<String>,
14 pub index_present: bool,
15 pub log_present: bool,
16 pub concept_count: usize,
17 pub files: Vec<OkfFile>,
18 pub errors: Vec<OkfIssue>,
19 pub warnings: Vec<OkfIssue>,
20}
21
22impl OkfReport {
23 pub fn is_valid(&self) -> bool {
24 self.errors.is_empty()
25 }
26
27 pub fn error_messages(&self) -> Vec<String> {
28 self.errors.iter().map(OkfIssue::message).collect()
29 }
30
31 pub fn warning_messages(&self) -> Vec<String> {
32 self.warnings.iter().map(OkfIssue::message).collect()
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SkillDirectoryReport {
38 pub root: PathBuf,
39 pub okf: Option<OkfReport>,
40 pub resource_refs: Vec<ResourceRef>,
41 pub errors: Vec<OkfIssue>,
42 pub warnings: Vec<OkfIssue>,
43}
44
45impl SkillDirectoryReport {
46 pub fn is_valid(&self) -> bool {
47 self.errors.is_empty()
48 }
49
50 pub fn error_messages(&self) -> Vec<String> {
51 self.errors.iter().map(OkfIssue::message).collect()
52 }
53
54 pub fn warning_messages(&self) -> Vec<String> {
55 self.warnings.iter().map(OkfIssue::message).collect()
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60pub struct ResourceRef {
61 pub path: PathBuf,
62 pub line: usize,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct OkfFile {
67 pub path: PathBuf,
68 pub kind: OkfFileKind,
69 pub concept_type: Option<String>,
70 pub title: Option<String>,
71 pub tags: Vec<String>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum OkfFileKind {
76 Index,
77 Log,
78 Concept,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct OkfIssue {
83 pub path: Option<PathBuf>,
84 pub message: String,
85}
86
87impl OkfIssue {
88 pub fn message(&self) -> String {
89 match &self.path {
90 Some(path) => format!("{}: {}", path.display(), self.message),
91 None => self.message.clone(),
92 }
93 }
94}
95
96pub fn validate_okf_bundle(root: &Path) -> Result<OkfReport> {
97 let mut report = OkfReport {
98 root: root.to_path_buf(),
99 okf_version: None,
100 index_present: false,
101 log_present: false,
102 concept_count: 0,
103 files: Vec::new(),
104 errors: Vec::new(),
105 warnings: Vec::new(),
106 };
107
108 if !root.exists() {
109 report.errors.push(OkfIssue {
110 path: None,
111 message: format!("OKF root does not exist: {}", root.display()),
112 });
113 return Ok(report);
114 }
115 if !root.is_dir() {
116 report.errors.push(OkfIssue {
117 path: None,
118 message: format!("OKF root is not a directory: {}", root.display()),
119 });
120 return Ok(report);
121 }
122
123 let files = collect_relative_files(root)?;
124 for rel in files {
125 if rel.extension().and_then(|ext| ext.to_str()) != Some("md") {
126 report.warnings.push(OkfIssue {
127 path: Some(rel),
128 message: "non-Markdown file ignored by OKF validation".to_string(),
129 });
130 continue;
131 }
132
133 let abs = root.join(&rel);
134 let content = std::fs::read_to_string(&abs)
135 .with_context(|| format!("failed to read {}", abs.display()))?;
136 validate_markdown_file(&mut report, &rel, &content);
137 }
138
139 if !report.index_present {
140 report.warnings.push(OkfIssue {
141 path: Some(PathBuf::from("index.md")),
142 message: "root index.md is recommended for OKF navigation".to_string(),
143 });
144 }
145 if report.concept_count == 0 {
146 report.errors.push(OkfIssue {
147 path: None,
148 message: "OKF bundle must contain at least one concept Markdown file".to_string(),
149 });
150 }
151
152 report.files.sort_by(|a, b| a.path.cmp(&b.path));
153 Ok(report)
154}
155
156pub fn validate_skill_directory(source_dir: &Path) -> Result<SkillDirectoryReport> {
157 let mut report = SkillDirectoryReport {
158 root: source_dir.to_path_buf(),
159 okf: None,
160 resource_refs: Vec::new(),
161 errors: Vec::new(),
162 warnings: Vec::new(),
163 };
164
165 let skill_path = source_dir.join("SKILL.md");
166 if !skill_path.is_file() {
167 report.errors.push(OkfIssue {
168 path: Some(PathBuf::from("SKILL.md")),
169 message: "skill directory must contain SKILL.md".to_string(),
170 });
171 return Ok(report);
172 }
173
174 let skill_content = std::fs::read_to_string(&skill_path)
175 .with_context(|| format!("failed to read {}", skill_path.display()))?;
176 validate_skill_frontmatter(&mut report, &skill_content);
177
178 report.resource_refs = collect_local_resource_refs(&skill_content);
179 validate_resource_refs(&mut report, source_dir);
180 collect_duplication_warnings(&mut report, source_dir, &skill_content)?;
181
182 let okf_dir = source_dir.join("okf");
183 if okf_dir.exists() {
184 let okf_report = validate_okf_bundle(&okf_dir)?;
185 report.errors.extend(
186 okf_report
187 .errors
188 .iter()
189 .map(|issue| prefix_issue("okf", issue)),
190 );
191 report.warnings.extend(
192 okf_report
193 .warnings
194 .iter()
195 .map(|issue| prefix_issue("okf", issue)),
196 );
197 report.okf = Some(okf_report);
198 }
199
200 Ok(report)
201}
202
203pub fn validate_skill_directory_okf(source_dir: &Path) -> Result<()> {
204 let report = validate_skill_directory(source_dir)?;
205 for message in report.warning_messages() {
206 eprintln!("warning: {message}");
207 }
208 if report.is_valid() {
209 return Ok(());
210 }
211
212 anyhow::bail!(
213 "invalid skill directory at {}:\n{}",
214 source_dir.display(),
215 report.error_messages().join("\n")
216 );
217}
218
219pub fn single_file_resource_warnings(file: &Path, content: &str) -> Vec<String> {
220 collect_local_resource_refs(content)
221 .into_iter()
222 .map(|resource| {
223 format!(
224 "{}:{} references local resource `{}`; use `install-dir` to copy companion resources",
225 file.display(),
226 resource.line,
227 resource.path.display()
228 )
229 })
230 .collect()
231}
232
233fn validate_skill_frontmatter(report: &mut SkillDirectoryReport, content: &str) {
234 let frontmatter = match split_frontmatter(content) {
235 Ok((frontmatter, _)) => frontmatter,
236 Err(message) => {
237 report.errors.push(OkfIssue {
238 path: Some(PathBuf::from("SKILL.md")),
239 message,
240 });
241 return;
242 }
243 };
244
245 let Some(raw) = frontmatter else {
246 return;
247 };
248
249 let value = match serde_yaml::from_str::<Value>(raw) {
250 Ok(value) => value,
251 Err(err) => {
252 report.errors.push(OkfIssue {
253 path: Some(PathBuf::from("SKILL.md")),
254 message: format!("invalid YAML frontmatter: {err}"),
255 });
256 return;
257 }
258 };
259
260 let Some(mapping) = value.as_mapping() else {
261 report.errors.push(OkfIssue {
262 path: Some(PathBuf::from("SKILL.md")),
263 message: "frontmatter must be a YAML mapping".to_string(),
264 });
265 return;
266 };
267
268 validate_dynamic_context(report, mapping);
269}
270
271fn validate_dynamic_context(report: &mut SkillDirectoryReport, mapping: &Mapping) {
272 let Some(value) = mapping.get(Value::String("dynamic_context".to_string())) else {
273 return;
274 };
275
276 let Some(items) = value.as_sequence() else {
277 report.errors.push(OkfIssue {
278 path: Some(PathBuf::from("SKILL.md")),
279 message: "`dynamic_context` must be a YAML sequence".to_string(),
280 });
281 return;
282 };
283
284 for (index, item) in items.iter().enumerate() {
285 let Some(item_mapping) = item.as_mapping() else {
286 report.errors.push(OkfIssue {
287 path: Some(PathBuf::from("SKILL.md")),
288 message: format!("`dynamic_context[{index}]` must be a YAML mapping"),
289 });
290 continue;
291 };
292
293 validate_dynamic_context_string_field(report, item_mapping, index, "name", true);
294 validate_dynamic_context_string_field(report, item_mapping, index, "command", true);
295 validate_dynamic_context_string_field(report, item_mapping, index, "cache_owner", false);
296
297 if let Some(command) = string_field(item_mapping, "command")
298 && command.lines().count() > 1
299 {
300 report.errors.push(OkfIssue {
301 path: Some(PathBuf::from("SKILL.md")),
302 message: format!("`dynamic_context[{index}].command` must be a single-line string"),
303 });
304 }
305 }
306}
307
308fn validate_dynamic_context_string_field(
309 report: &mut SkillDirectoryReport,
310 mapping: &Mapping,
311 index: usize,
312 field: &str,
313 required: bool,
314) {
315 let Some(value) = mapping.get(Value::String(field.to_string())) else {
316 if required {
317 report.errors.push(OkfIssue {
318 path: Some(PathBuf::from("SKILL.md")),
319 message: format!("`dynamic_context[{index}].{field}` is required"),
320 });
321 }
322 return;
323 };
324
325 match scalar_to_string(value) {
326 Some(value) if !value.trim().is_empty() => {}
327 Some(_) => report.errors.push(OkfIssue {
328 path: Some(PathBuf::from("SKILL.md")),
329 message: format!("`dynamic_context[{index}].{field}` must not be empty"),
330 }),
331 None => report.errors.push(OkfIssue {
332 path: Some(PathBuf::from("SKILL.md")),
333 message: format!("`dynamic_context[{index}].{field}` must be a string"),
334 }),
335 }
336}
337
338fn collect_local_resource_refs(content: &str) -> Vec<ResourceRef> {
339 let mut refs = BTreeSet::new();
340 for (line_index, line) in content.lines().enumerate() {
341 let line_number = line_index + 1;
342 for target in markdown_link_targets(line) {
343 if let Some(path) = normalize_resource_ref(target, true) {
344 refs.insert(ResourceRef {
345 path,
346 line: line_number,
347 });
348 }
349 }
350 for token in path_like_tokens(line) {
351 if let Some(path) = normalize_resource_ref(token, false) {
352 refs.insert(ResourceRef {
353 path,
354 line: line_number,
355 });
356 }
357 }
358 }
359 refs.into_iter().collect()
360}
361
362fn markdown_link_targets(line: &str) -> Vec<&str> {
363 let mut targets = Vec::new();
364 let mut rest = line;
365 while let Some(start) = rest.find("](") {
366 let after_open = &rest[start + 2..];
367 let Some(end) = after_open.find(')') else {
368 break;
369 };
370 targets.push(&after_open[..end]);
371 rest = &after_open[end + 1..];
372 }
373 targets
374}
375
376fn path_like_tokens(line: &str) -> impl Iterator<Item = &str> {
377 line.split(|ch: char| {
378 ch.is_whitespace()
379 || matches!(
380 ch,
381 '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | '"' | '\'' | '`' | ',' | ';'
382 )
383 })
384}
385
386fn normalize_resource_ref(raw: &str, allow_spec: bool) -> Option<PathBuf> {
387 let mut value = raw.trim();
388 if value.is_empty()
389 || value.starts_with('#')
390 || value.starts_with("http://")
391 || value.starts_with("https://")
392 || value.starts_with("mailto:")
393 || value.starts_with("skill://")
394 || value.starts_with('/')
395 {
396 return None;
397 }
398
399 value = value
400 .split(['#', '?'])
401 .next()
402 .unwrap_or(value)
403 .trim_matches(|ch: char| matches!(ch, ':' | '.' | '!' | '?'));
404 while let Some(stripped) = value.strip_prefix("./") {
405 value = stripped;
406 }
407
408 if (allow_spec && value == "SPEC.md")
409 || RESOURCE_ROOTS.iter().any(|root| {
410 let prefix = format!("{root}/");
411 value.starts_with(&prefix) && value.len() > prefix.len()
412 })
413 {
414 return Some(PathBuf::from(value));
415 }
416
417 None
418}
419
420fn validate_resource_refs(report: &mut SkillDirectoryReport, source_dir: &Path) {
421 for resource in report.resource_refs.clone() {
422 if resource
423 .path
424 .components()
425 .any(|component| matches!(component, std::path::Component::ParentDir))
426 {
427 report.errors.push(OkfIssue {
428 path: Some(PathBuf::from("SKILL.md")),
429 message: format!(
430 "line {} references resource outside the skill directory: {}",
431 resource.line,
432 resource.path.display()
433 ),
434 });
435 continue;
436 }
437
438 if !source_dir.join(&resource.path).exists() {
439 report.errors.push(OkfIssue {
440 path: Some(PathBuf::from("SKILL.md")),
441 message: format!(
442 "line {} references missing resource: {}",
443 resource.line,
444 resource.path.display()
445 ),
446 });
447 }
448 }
449}
450
451fn collect_duplication_warnings(
452 report: &mut SkillDirectoryReport,
453 source_dir: &Path,
454 skill_content: &str,
455) -> Result<()> {
456 let skill_lines: BTreeSet<String> = skill_content
457 .lines()
458 .map(str::trim)
459 .filter(|line| !line.is_empty())
460 .map(ToOwned::to_owned)
461 .collect();
462 let skill_code_blocks = fenced_code_blocks(skill_content);
463 let mut checked = BTreeSet::new();
464
465 for resource in report.resource_refs.clone() {
466 if !checked.insert(resource.path.clone()) {
467 continue;
468 }
469 let path = source_dir.join(&resource.path);
470 if !path.is_file() {
471 continue;
472 }
473
474 let content = std::fs::read_to_string(&path)
475 .with_context(|| format!("failed to read {}", path.display()))?;
476
477 if resource.path.extension().and_then(|ext| ext.to_str()) == Some("md") {
478 collect_markdown_duplication_warnings(report, &resource.path, skill_content, &content);
479 } else if path_starts_with(&resource.path, "scripts") {
480 collect_script_duplication_warnings(
481 report,
482 &resource.path,
483 &skill_lines,
484 &skill_code_blocks,
485 &content,
486 );
487 }
488 }
489
490 Ok(())
491}
492
493fn collect_markdown_duplication_warnings(
494 report: &mut SkillDirectoryReport,
495 resource_path: &Path,
496 skill_content: &str,
497 resource_content: &str,
498) {
499 for heading in resource_content
500 .lines()
501 .map(str::trim)
502 .filter(|line| line.starts_with('#') && line.len() >= 12)
503 {
504 if skill_content.lines().any(|line| line.trim() == heading) {
505 report.warnings.push(OkfIssue {
506 path: Some(PathBuf::from("SKILL.md")),
507 message: format!(
508 "SKILL.md appears to duplicate heading `{heading}` from {}; keep details in the resource and route to it",
509 resource_path.display()
510 ),
511 });
512 }
513 }
514
515 for line in resource_content
516 .lines()
517 .map(str::trim)
518 .filter(|line| line.len() >= 100)
519 {
520 if skill_content.contains(line) {
521 report.warnings.push(OkfIssue {
522 path: Some(PathBuf::from("SKILL.md")),
523 message: format!(
524 "SKILL.md appears to duplicate a long passage from {}; keep bulky context in the resource",
525 resource_path.display()
526 ),
527 });
528 return;
529 }
530 }
531}
532
533fn collect_script_duplication_warnings(
534 report: &mut SkillDirectoryReport,
535 resource_path: &Path,
536 skill_lines: &BTreeSet<String>,
537 skill_code_blocks: &[String],
538 script_content: &str,
539) {
540 let script_lines: Vec<String> = script_content
541 .lines()
542 .map(str::trim)
543 .filter(|line| !line.is_empty())
544 .map(ToOwned::to_owned)
545 .collect();
546 if script_lines.len() < 3 {
547 return;
548 }
549
550 let prefix = script_lines
551 .iter()
552 .take(3)
553 .cloned()
554 .collect::<Vec<_>>()
555 .join("\n");
556 let duplicate_prefix = skill_code_blocks
557 .iter()
558 .any(|block| block.contains(&prefix));
559 let duplicate_lines = script_lines
560 .iter()
561 .take(5)
562 .filter(|line| skill_lines.contains(*line))
563 .count()
564 >= 3;
565
566 if duplicate_prefix || duplicate_lines {
567 report.warnings.push(OkfIssue {
568 path: Some(PathBuf::from("SKILL.md")),
569 message: format!(
570 "SKILL.md appears to duplicate script content from {}; keep executable logic in scripts/",
571 resource_path.display()
572 ),
573 });
574 }
575}
576
577fn fenced_code_blocks(content: &str) -> Vec<String> {
578 let mut blocks = Vec::new();
579 let mut current = Vec::new();
580 let mut in_block = false;
581
582 for line in content.lines() {
583 if line.trim_start().starts_with("```") {
584 if in_block {
585 blocks.push(current.join("\n"));
586 current.clear();
587 }
588 in_block = !in_block;
589 continue;
590 }
591
592 if in_block {
593 current.push(line.trim().to_string());
594 }
595 }
596
597 blocks
598}
599
600fn path_starts_with(path: &Path, prefix: &str) -> bool {
601 path.components()
602 .next()
603 .and_then(|component| match component {
604 std::path::Component::Normal(value) => value.to_str(),
605 _ => None,
606 })
607 == Some(prefix)
608}
609
610fn prefix_issue(prefix: &str, issue: &OkfIssue) -> OkfIssue {
611 OkfIssue {
612 path: issue
613 .path
614 .as_ref()
615 .map(|path| PathBuf::from(prefix).join(path))
616 .or_else(|| Some(PathBuf::from(prefix))),
617 message: issue.message.clone(),
618 }
619}
620
621fn validate_markdown_file(report: &mut OkfReport, rel: &Path, content: &str) {
622 let kind = match rel.to_string_lossy().replace('\\', "/").as_str() {
623 "index.md" => {
624 report.index_present = true;
625 OkfFileKind::Index
626 }
627 "log.md" => {
628 report.log_present = true;
629 OkfFileKind::Log
630 }
631 _ => OkfFileKind::Concept,
632 };
633
634 let (frontmatter, body) = match split_frontmatter(content) {
635 Ok(parts) => parts,
636 Err(message) => {
637 report.errors.push(OkfIssue {
638 path: Some(rel.to_path_buf()),
639 message,
640 });
641 (None, content)
642 }
643 };
644
645 let yaml = frontmatter.and_then(|raw| parse_frontmatter(report, rel, raw));
646 let title = markdown_title(body);
647 let mut concept_type = None;
648 let mut tags = Vec::new();
649
650 if let Some(mapping) = yaml.as_ref().and_then(Value::as_mapping) {
651 if kind == OkfFileKind::Index {
652 report.okf_version = string_field(mapping, "okf_version");
653 }
654 concept_type = string_field(mapping, "type");
655 tags = string_or_sequence_field(mapping, "tags");
656 }
657
658 if kind == OkfFileKind::Concept {
659 report.concept_count += 1;
660 match yaml {
661 None => report.errors.push(OkfIssue {
662 path: Some(rel.to_path_buf()),
663 message: "concept file must start with YAML frontmatter".to_string(),
664 }),
665 Some(Value::Mapping(_)) => {
666 if concept_type.as_deref().unwrap_or("").trim().is_empty() {
667 report.errors.push(OkfIssue {
668 path: Some(rel.to_path_buf()),
669 message: "concept frontmatter must include non-empty `type`".to_string(),
670 });
671 }
672 }
673 Some(_) => report.errors.push(OkfIssue {
674 path: Some(rel.to_path_buf()),
675 message: "frontmatter must be a YAML mapping".to_string(),
676 }),
677 }
678 }
679
680 report.files.push(OkfFile {
681 path: rel.to_path_buf(),
682 kind,
683 concept_type,
684 title,
685 tags,
686 });
687}
688
689fn parse_frontmatter(report: &mut OkfReport, rel: &Path, raw: &str) -> Option<Value> {
690 match serde_yaml::from_str::<Value>(raw) {
691 Ok(value) => Some(value),
692 Err(err) => {
693 report.errors.push(OkfIssue {
694 path: Some(rel.to_path_buf()),
695 message: format!("invalid YAML frontmatter: {err}"),
696 });
697 None
698 }
699 }
700}
701
702fn string_field(mapping: &Mapping, field: &str) -> Option<String> {
703 mapping
704 .get(Value::String(field.to_string()))
705 .and_then(scalar_to_string)
706 .map(|value| value.trim().to_string())
707 .filter(|value| !value.is_empty())
708}
709
710fn scalar_to_string(value: &Value) -> Option<String> {
711 match value {
712 Value::String(value) => Some(value.clone()),
713 Value::Number(value) => Some(value.to_string()),
714 _ => None,
715 }
716}
717
718fn string_or_sequence_field(mapping: &Mapping, field: &str) -> Vec<String> {
719 let Some(value) = mapping.get(Value::String(field.to_string())) else {
720 return Vec::new();
721 };
722 if let Some(single) = value.as_str() {
723 let trimmed = single.trim();
724 return (!trimmed.is_empty())
725 .then(|| trimmed.to_string())
726 .into_iter()
727 .collect();
728 }
729 value
730 .as_sequence()
731 .map(|items| {
732 items
733 .iter()
734 .filter_map(Value::as_str)
735 .map(str::trim)
736 .filter(|value| !value.is_empty())
737 .map(ToOwned::to_owned)
738 .collect()
739 })
740 .unwrap_or_default()
741}
742
743fn split_frontmatter(content: &str) -> std::result::Result<(Option<&str>, &str), String> {
744 let mut cursor = 0;
745 let mut lines = content.split_inclusive('\n');
746 let Some(first) = lines.next() else {
747 return Ok((None, content));
748 };
749 if trim_line_ending(first) != "---" {
750 return Ok((None, content));
751 }
752
753 let start = first.len();
754 cursor += first.len();
755 for line in lines {
756 if trim_line_ending(line) == "---" {
757 let frontmatter = &content[start..cursor];
758 let body = &content[cursor + line.len()..];
759 return Ok((Some(frontmatter), body));
760 }
761 cursor += line.len();
762 }
763
764 if content[cursor..].trim() == "---" {
765 let frontmatter = &content[start..cursor];
766 return Ok((Some(frontmatter), ""));
767 }
768
769 Err("frontmatter opened with `---` but no closing delimiter was found".to_string())
770}
771
772fn trim_line_ending(line: &str) -> &str {
773 line.trim_end_matches('\n').trim_end_matches('\r')
774}
775
776fn markdown_title(body: &str) -> Option<String> {
777 body.lines()
778 .find_map(|line| line.strip_prefix("# ").map(str::trim))
779 .filter(|title| !title.is_empty())
780 .map(ToOwned::to_owned)
781}
782
783fn collect_relative_files(root: &Path) -> Result<BTreeSet<PathBuf>> {
784 let mut files = BTreeSet::new();
785 collect_relative_files_inner(root, root, &mut files)?;
786 Ok(files)
787}
788
789fn collect_relative_files_inner(
790 root: &Path,
791 current: &Path,
792 files: &mut BTreeSet<PathBuf>,
793) -> Result<()> {
794 for entry in std::fs::read_dir(current)
795 .with_context(|| format!("failed to read {}", current.display()))?
796 {
797 let entry = entry?;
798 let path = entry.path();
799 let file_type = entry.file_type()?;
800 if file_type.is_dir() {
801 collect_relative_files_inner(root, &path, files)?;
802 } else if file_type.is_file() {
803 files.insert(
804 path.strip_prefix(root)
805 .with_context(|| {
806 format!("failed to strip {} from {}", root.display(), path.display())
807 })?
808 .to_path_buf(),
809 );
810 }
811 }
812 Ok(())
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 #[test]
820 fn accepts_valid_okf_bundle() {
821 let dir = tempfile::tempdir().unwrap();
822 std::fs::write(
823 dir.path().join("index.md"),
824 "---\nokf_version: 0.1\n---\n# Index\n",
825 )
826 .unwrap();
827 std::fs::write(
828 dir.path().join("concept.md"),
829 "---\ntype: concept\ntags: [agent, context]\n---\n# Concept\nBody.\n",
830 )
831 .unwrap();
832
833 let report = validate_okf_bundle(dir.path()).unwrap();
834 assert!(report.is_valid(), "{:?}", report.error_messages());
835 assert_eq!(report.okf_version.as_deref(), Some("0.1"));
836 assert_eq!(report.concept_count, 1);
837 assert_eq!(report.files.len(), 2);
838 }
839
840 #[test]
841 fn rejects_concept_without_type() {
842 let dir = tempfile::tempdir().unwrap();
843 std::fs::write(
844 dir.path().join("concept.md"),
845 "---\ntags: [agent]\n---\n# Concept\n",
846 )
847 .unwrap();
848
849 let report = validate_okf_bundle(dir.path()).unwrap();
850 assert!(!report.is_valid());
851 assert!(
852 report
853 .error_messages()
854 .iter()
855 .any(|message| message.contains("non-empty `type`"))
856 );
857 }
858
859 #[test]
860 fn validates_skill_directory_okf_subdir() {
861 let dir = tempfile::tempdir().unwrap();
862 std::fs::write(dir.path().join("SKILL.md"), "# Skill\n").unwrap();
863 std::fs::create_dir_all(dir.path().join("okf")).unwrap();
864 std::fs::write(
865 dir.path().join("okf/context.md"),
866 "---\ntype: reference\n---\n# Context\n",
867 )
868 .unwrap();
869
870 validate_skill_directory_okf(dir.path()).unwrap();
871 }
872
873 #[test]
874 fn reports_missing_linked_runbook() {
875 let dir = tempfile::tempdir().unwrap();
876 std::fs::write(
877 dir.path().join("SKILL.md"),
878 "# Skill\n\nFollow [Deploy](runbooks/deploy.md).\n",
879 )
880 .unwrap();
881
882 let report = validate_skill_directory(dir.path()).unwrap();
883 assert!(!report.is_valid());
884 assert!(report.error_messages().iter().any(|message| {
885 message.contains("missing resource") && message.contains("runbooks/deploy.md")
886 }));
887 }
888
889 #[test]
890 fn accepts_existing_reference_and_ignores_external_links() {
891 let dir = tempfile::tempdir().unwrap();
892 std::fs::create_dir_all(dir.path().join("references")).unwrap();
893 std::fs::write(dir.path().join("references/schema.md"), "# Schema\n").unwrap();
894 std::fs::write(
895 dir.path().join("SKILL.md"),
896 "# Skill\n\nRead [schema](references/schema.md) and https://example.com/runbooks/nope.md.\n",
897 )
898 .unwrap();
899
900 let report = validate_skill_directory(dir.path()).unwrap();
901 assert!(report.is_valid(), "{:?}", report.error_messages());
902 assert_eq!(report.resource_refs.len(), 1);
903 }
904
905 #[test]
906 fn single_file_install_warns_about_resource_links() {
907 let warnings = single_file_resource_warnings(
908 Path::new("SKILL.md"),
909 "# Skill\n\nUse `runbooks/deploy.md` when deploying.\n",
910 );
911
912 assert_eq!(warnings.len(), 1);
913 assert!(warnings[0].contains("install-dir"));
914 assert!(warnings[0].contains("runbooks/deploy.md"));
915 }
916
917 #[test]
918 fn bare_resource_root_mentions_are_not_file_references() {
919 let warnings = single_file_resource_warnings(
920 Path::new("SKILL.md"),
921 "# Skill\n\nUse `install-dir` for skills that include `runbooks/`, `okf/`, or `assets/`.\n",
922 );
923
924 assert!(warnings.is_empty());
925 }
926
927 #[test]
928 fn bare_spec_mentions_are_not_file_references() {
929 let warnings = single_file_resource_warnings(
930 Path::new("SKILL.md"),
931 "# Skill\n\nUse `install-dir` for skills that include `SPEC.md`.\n",
932 );
933
934 assert!(warnings.is_empty());
935 }
936
937 #[test]
938 fn linked_spec_mentions_are_validated() {
939 let dir = tempfile::tempdir().unwrap();
940 std::fs::write(
941 dir.path().join("SKILL.md"),
942 "# Skill\n\nRead [the spec](SPEC.md) before editing.\n",
943 )
944 .unwrap();
945
946 let report = validate_skill_directory(dir.path()).unwrap();
947 assert!(!report.is_valid());
948 assert!(
949 report
950 .error_messages()
951 .iter()
952 .any(|message| message.contains("missing resource") && message.contains("SPEC.md"))
953 );
954 }
955
956 #[test]
957 fn valid_dynamic_context_metadata_passes() {
958 let dir = tempfile::tempdir().unwrap();
959 std::fs::write(
960 dir.path().join("SKILL.md"),
961 "---\ndynamic_context:\n - name: code-context\n command: tsift --envelope context-pack {query} --budget normal\n cache_owner: tsift\n---\n# Skill\n",
962 )
963 .unwrap();
964
965 let report = validate_skill_directory(dir.path()).unwrap();
966 assert!(report.is_valid(), "{:?}", report.error_messages());
967 }
968
969 #[test]
970 fn invalid_dynamic_context_command_shape_fails() {
971 let dir = tempfile::tempdir().unwrap();
972 std::fs::write(
973 dir.path().join("SKILL.md"),
974 "---\ndynamic_context:\n - name: code-context\n command: [tsift, context-pack]\n---\n# Skill\n",
975 )
976 .unwrap();
977
978 let report = validate_skill_directory(dir.path()).unwrap();
979 assert!(!report.is_valid());
980 assert!(report.error_messages().iter().any(|message| {
981 message.contains("dynamic_context[0].command") && message.contains("string")
982 }));
983 }
984
985 #[test]
986 fn duplicated_runbook_heading_emits_warning() {
987 let dir = tempfile::tempdir().unwrap();
988 std::fs::create_dir_all(dir.path().join("runbooks")).unwrap();
989 std::fs::write(
990 dir.path().join("runbooks/deploy.md"),
991 "# Deploy\n\n## Rollout Steps\n\nRun the deploy command after checking health.\n",
992 )
993 .unwrap();
994 std::fs::write(
995 dir.path().join("SKILL.md"),
996 "# Skill\n\nUse `runbooks/deploy.md`.\n\n## Rollout Steps\n\nRun the deploy command after checking health.\n",
997 )
998 .unwrap();
999
1000 let report = validate_skill_directory(dir.path()).unwrap();
1001 assert!(report.is_valid(), "{:?}", report.error_messages());
1002 assert!(report.warning_messages().iter().any(|message| {
1003 message.contains("duplicate heading") && message.contains("runbooks/deploy.md")
1004 }));
1005 }
1006
1007 #[test]
1008 fn short_router_sentence_does_not_warn() {
1009 let dir = tempfile::tempdir().unwrap();
1010 std::fs::create_dir_all(dir.path().join("runbooks")).unwrap();
1011 std::fs::write(
1012 dir.path().join("runbooks/deploy.md"),
1013 "# Deploy\n\n## Rollout Steps\n\nRun the deploy command after checking health.\n",
1014 )
1015 .unwrap();
1016 std::fs::write(
1017 dir.path().join("SKILL.md"),
1018 "# Skill\n\nUse `runbooks/deploy.md` for deploys.\n",
1019 )
1020 .unwrap();
1021
1022 let report = validate_skill_directory(dir.path()).unwrap();
1023 assert!(report.is_valid(), "{:?}", report.error_messages());
1024 assert!(report.warning_messages().is_empty());
1025 }
1026}