1use super::{DoctorFinding, DoctorSeverity};
8use crate::install::link_map::{EntryKind, LinkEntry, LinkMap};
9use serde::Serialize;
10use std::ffi::OsStr;
11use std::path::{Component, Path, PathBuf};
12
13pub const CLASS: &str = "skill-surface";
14pub const FILE_SYMLINK_WARNING: &str = "codex.active-skill.file-symlink";
15pub const CODEX_ACCEPTANCE_BOUNDARY: &str = "shape validation only; live Codex Desktop discovery still requires `codex debug prompt-input` in a fresh session";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18pub struct SkillSurfaceReport {
19 pub product: String,
20 pub items: Vec<SkillSurfaceItem>,
21 pub findings: Vec<DoctorFinding>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub acceptance_boundary: Option<String>,
24}
25
26impl SkillSurfaceReport {
27 pub fn empty(product: &str) -> Self {
28 Self {
29 product: product.to_string(),
30 items: Vec::new(),
31 findings: Vec::new(),
32 acceptance_boundary: acceptance_boundary(product).map(str::to_string),
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct SkillSurfaceItem {
39 pub id: String,
40 pub source: String,
41 pub destination: String,
42 pub link_mode: SkillSurfaceLinkMode,
43 pub expected_codex_discoverable: CodexDiscoverability,
44 #[serde(skip_serializing_if = "Vec::is_empty")]
45 pub warnings: Vec<SkillSurfaceWarning>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum SkillSurfaceLinkMode {
51 File,
52 Directory,
53 RecursiveFile,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CodexDiscoverability {
58 Yes,
59 No,
60 NotApplicable,
61}
62
63impl Serialize for CodexDiscoverability {
64 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
65 where
66 S: serde::Serializer,
67 {
68 match self {
69 Self::Yes => serializer.serialize_bool(true),
70 Self::No => serializer.serialize_bool(false),
71 Self::NotApplicable => serializer.serialize_str("not-applicable"),
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct SkillSurfaceWarning {
78 pub code: &'static str,
79 pub message: String,
80 pub remediation: &'static str,
81}
82
83pub fn acceptance_boundary(product: &str) -> Option<&'static str> {
84 (product == "codex").then_some(CODEX_ACCEPTANCE_BOUNDARY)
85}
86
87pub fn check(product: &str, source_root: &Path, link_map: &LinkMap) -> SkillSurfaceReport {
88 let mut items = Vec::new();
89 let mut findings = Vec::new();
90 for entry in &link_map.entries {
91 let Some(item) = classify_entry(product, source_root, entry) else {
92 continue;
93 };
94 for warning in &item.warnings {
95 findings.push(DoctorFinding {
96 product: product.to_string(),
97 check: CLASS,
98 severity: DoctorSeverity::Warn,
99 entry_id: Some(item.id.clone()),
100 path: Some(PathBuf::from(&item.destination)),
101 message: format!(
102 "{}: {}; {}",
103 warning.code, warning.message, warning.remediation
104 ),
105 });
106 }
107 items.push(item);
108 }
109 SkillSurfaceReport {
110 product: product.to_string(),
111 items,
112 findings,
113 acceptance_boundary: acceptance_boundary(product).map(str::to_string),
114 }
115}
116
117fn classify_entry(
118 product: &str,
119 source_root: &Path,
120 entry: &LinkEntry,
121) -> Option<SkillSurfaceItem> {
122 let source = entry.source.as_deref()?;
123 let source_abs = source_root.join(source);
124 let link_mode = link_mode(&source_abs, entry);
125 let destination = clean_rel_path(&entry.destination);
126 let expected_codex_discoverable =
127 expected_codex_discoverable(product, destination.as_deref(), link_mode, entry);
128 let warnings = warnings(product, destination.as_deref(), &entry.id);
129 Some(SkillSurfaceItem {
130 id: entry.id.clone(),
131 source: source.to_string(),
132 destination: entry.destination.clone(),
133 link_mode,
134 expected_codex_discoverable,
135 warnings,
136 })
137}
138
139fn link_mode(source_abs: &Path, entry: &LinkEntry) -> SkillSurfaceLinkMode {
140 if entry.kind == EntryKind::SymlinkedFile && entry.recursive {
141 return SkillSurfaceLinkMode::RecursiveFile;
142 }
143 match std::fs::symlink_metadata(source_abs) {
144 Ok(meta) if meta.is_dir() => SkillSurfaceLinkMode::Directory,
145 _ => SkillSurfaceLinkMode::File,
146 }
147}
148
149fn expected_codex_discoverable(
150 product: &str,
151 destination: Option<&Path>,
152 link_mode: SkillSurfaceLinkMode,
153 entry: &LinkEntry,
154) -> CodexDiscoverability {
155 if product != "codex" {
156 return CodexDiscoverability::NotApplicable;
157 }
158 let Some(destination) = destination else {
159 return CodexDiscoverability::NotApplicable;
160 };
161 if !is_skills_prefixed(destination) {
162 return CodexDiscoverability::NotApplicable;
163 }
164 if entry.kind == EntryKind::SymlinkedFile
165 && !entry.recursive
166 && link_mode == SkillSurfaceLinkMode::Directory
167 && is_domain_nested_skill_leaf(destination)
168 {
169 CodexDiscoverability::Yes
170 } else {
171 CodexDiscoverability::No
172 }
173}
174
175fn warnings(
176 product: &str,
177 destination: Option<&Path>,
178 _entry_id: &str,
179) -> Vec<SkillSurfaceWarning> {
180 let Some(destination) = destination else {
181 return Vec::new();
182 };
183 if product == "codex" && is_skill_md_leaf(destination) {
184 vec![SkillSurfaceWarning {
185 code: FILE_SYMLINK_WARNING,
186 message: format!(
187 "Codex active skill destination `{}` is a SKILL.md file symlink",
188 destination.display()
189 ),
190 remediation: "use a directory-symlink leaf at `skills/<domain>/<skill>`",
191 }]
192 } else {
193 Vec::new()
194 }
195}
196
197fn clean_rel_path(raw: &str) -> Option<PathBuf> {
198 let path = Path::new(raw);
199 if path.as_os_str().is_empty() || path.is_absolute() {
200 return None;
201 }
202 if path
203 .components()
204 .any(|component| !matches!(component, Component::Normal(_)))
205 {
206 return None;
207 }
208 Some(path.to_path_buf())
209}
210
211fn is_skills_prefixed(path: &Path) -> bool {
212 matches!(path.components().next(), Some(Component::Normal(first)) if first == OsStr::new("skills"))
213}
214
215fn is_domain_nested_skill_leaf(path: &Path) -> bool {
216 let components: Vec<_> = path.components().collect();
217 components.len() >= 3
218 && matches!(components[0], Component::Normal(first) if first == OsStr::new("skills"))
219 && !matches!(components.last(), Some(Component::Normal(last)) if *last == OsStr::new("SKILL.md"))
220}
221
222fn is_skill_md_leaf(path: &Path) -> bool {
223 is_skills_prefixed(path)
224 && matches!(
225 path.file_name().and_then(|name| name.to_str()),
226 Some("SKILL.md")
227 )
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::install::link_map::LinkEntry;
234 use pretty_assertions::assert_eq;
235 use std::fs;
236 use tempfile::TempDir;
237
238 fn entry(id: &str, source: &str, destination: &str, recursive: bool) -> LinkEntry {
239 LinkEntry {
240 id: id.to_string(),
241 kind: EntryKind::SymlinkedFile,
242 source: Some(source.to_string()),
243 destination: destination.to_string(),
244 recursive,
245 surface: None,
246 comment_style: None,
247 body_template: None,
248 }
249 }
250
251 #[test]
252 fn classifies_domain_nested_directory_skill_as_codex_discoverable() {
253 let tmp = TempDir::new().unwrap();
254 let source = tmp
255 .path()
256 .join("build/codex/plugins/reporting/skills/daily-brief");
257 fs::create_dir_all(&source).unwrap();
258 fs::write(source.join("SKILL.md"), "# daily brief\n").unwrap();
259
260 let item = classify_entry(
261 "codex",
262 tmp.path(),
263 &entry(
264 "reporting.daily-brief",
265 "build/codex/plugins/reporting/skills/daily-brief",
266 "skills/reporting/daily-brief",
267 false,
268 ),
269 )
270 .unwrap();
271
272 assert_eq!(item.link_mode, SkillSurfaceLinkMode::Directory);
273 assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::Yes);
274 assert_eq!(item.warnings, Vec::new());
275 }
276
277 #[test]
278 fn classifies_skill_md_file_leaf_as_not_discoverable_with_warning() {
279 let tmp = TempDir::new().unwrap();
280 let source = tmp
281 .path()
282 .join("build/codex/plugins/reporting/skills/daily-brief/SKILL.md");
283 fs::create_dir_all(source.parent().unwrap()).unwrap();
284 fs::write(&source, "# daily brief\n").unwrap();
285
286 let item = classify_entry(
287 "codex",
288 tmp.path(),
289 &entry(
290 "reporting.daily-brief",
291 "build/codex/plugins/reporting/skills/daily-brief/SKILL.md",
292 "skills/reporting/daily-brief/SKILL.md",
293 false,
294 ),
295 )
296 .unwrap();
297
298 assert_eq!(item.link_mode, SkillSurfaceLinkMode::File);
299 assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::No);
300 assert_eq!(item.warnings.len(), 1);
301 assert_eq!(item.warnings[0].code, FILE_SYMLINK_WARNING);
302 }
303
304 #[test]
305 fn classifies_recursive_skill_entry_as_not_discoverable() {
306 let tmp = TempDir::new().unwrap();
307 let source = tmp.path().join("build/codex/plugins/reporting/skills");
308 fs::create_dir_all(source.join("daily-brief")).unwrap();
309 fs::write(source.join("daily-brief/SKILL.md"), "# daily brief\n").unwrap();
310
311 let item = classify_entry(
312 "codex",
313 tmp.path(),
314 &entry(
315 "reporting.skills-tree",
316 "build/codex/plugins/reporting/skills",
317 "skills/reporting",
318 true,
319 ),
320 )
321 .unwrap();
322
323 assert_eq!(item.link_mode, SkillSurfaceLinkMode::RecursiveFile);
324 assert_eq!(item.expected_codex_discoverable, CodexDiscoverability::No);
325 }
326
327 #[test]
328 fn classifies_non_skills_destination_as_not_applicable() {
329 let tmp = TempDir::new().unwrap();
330 let source = tmp
331 .path()
332 .join("targets/codex/plugins/reporting/.codex-plugin/plugin.json");
333 fs::create_dir_all(source.parent().unwrap()).unwrap();
334 fs::write(&source, "{}\n").unwrap();
335
336 let item = classify_entry(
337 "codex",
338 tmp.path(),
339 &entry(
340 "reporting.plugin-manifest",
341 "targets/codex/plugins/reporting/.codex-plugin/plugin.json",
342 "plugins/reporting/.codex-plugin/plugin.json",
343 false,
344 ),
345 )
346 .unwrap();
347
348 assert_eq!(item.link_mode, SkillSurfaceLinkMode::File);
349 assert_eq!(
350 item.expected_codex_discoverable,
351 CodexDiscoverability::NotApplicable
352 );
353 }
354}