Skip to main content

skill_harness/
okf.rs

1//! OKF (Open Knowledge Format) validation for skill resource bundles.
2
3use 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}