1use anyhow::Result;
8use std::path::{Path, PathBuf};
9
10use crate::checksum::checksum_in_memory;
11use crate::fs_util::is_transient;
12use crate::gateway::Filesystem;
13
14#[derive(Debug, Clone)]
16pub struct SkillFile {
17 pub rel_path: PathBuf,
19 pub bytes: Vec<u8>,
21}
22
23impl SkillFile {
24 pub fn text(rel_path: impl Into<PathBuf>, content: &str) -> Self {
32 Self {
33 rel_path: rel_path.into(),
34 bytes: content.as_bytes().to_vec(),
35 }
36 }
37}
38
39pub fn canonical_files(files: &[SkillFile]) -> Vec<&SkillFile> {
47 let mut out: Vec<&SkillFile> = files
48 .iter()
49 .filter(|f| {
50 !f.rel_path.components().any(|c| {
51 let s = c.as_os_str().to_string_lossy();
52 s.starts_with('.') || is_transient(&s)
53 })
54 })
55 .collect();
56 out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
57 out
58}
59
60pub fn checksum_bundled(files: &[SkillFile]) -> String {
65 let canonical = canonical_files(files);
66 checksum_in_memory(canonical.iter().map(|f| (f.rel_path.as_path(), f.bytes.as_slice())))
67}
68
69pub fn write_skill_files(dest_dir: &Path, files: &[SkillFile], fs: &dyn Filesystem) -> Result<()> {
76 fs.create_dir_all(dest_dir)?;
77 for file in files {
78 let dest = dest_dir.join(&file.rel_path);
79 if let Some(parent) = dest.parent() {
80 fs.create_dir_all(parent)?;
81 }
82 fs.write_bytes(&dest, &file.bytes)?;
83 }
84 Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90 use crate::gateway::fakes::FakeFilesystem;
91
92 fn make_file(rel: &str, bytes: &[u8]) -> SkillFile {
93 SkillFile {
94 rel_path: PathBuf::from(rel),
95 bytes: bytes.to_vec(),
96 }
97 }
98
99 #[test]
100 fn canonical_files_excludes_dotfiles() {
101 let files = vec![
102 make_file("SKILL.md", b"content"),
103 make_file(".hidden", b"hidden"),
104 make_file("scripts/.env", b"env"),
105 ];
106 let canonical = canonical_files(&files);
107 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
108 assert_eq!(names, vec!["SKILL.md"]);
109 }
110
111 #[test]
112 fn canonical_files_excludes_transient_dirs() {
113 let files = vec![
114 make_file("SKILL.md", b"content"),
115 make_file("node_modules/dep/index.js", b"vendor"),
116 make_file("__pycache__/tool.pyc", b"bytecode"),
117 make_file("scripts/tool.py", b"code"),
118 ];
119 let canonical = canonical_files(&files);
120 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
121 assert_eq!(names, vec!["SKILL.md", "scripts/tool.py"]);
122 }
123
124 #[test]
125 fn canonical_files_sorted_by_rel_path() {
126 let files = vec![
127 make_file("z.md", b"z"),
128 make_file("SKILL.md", b"skill"),
129 make_file("a.md", b"a"),
130 ];
131 let canonical = canonical_files(&files);
132 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
133 assert_eq!(names, vec!["SKILL.md", "a.md", "z.md"]);
134 }
135
136 #[test]
137 fn write_skill_files_creates_nested_dirs() {
138 let fs = FakeFilesystem::new();
139 let files = vec![
140 make_file("SKILL.md", b"# skill"),
141 make_file("scripts/tool.py", b"code"),
142 ];
143 write_skill_files(Path::new("/dest/my-skill"), &files, &fs).unwrap();
144 assert!(fs.file_exists(Path::new("/dest/my-skill/SKILL.md")));
145 assert!(fs.file_exists(Path::new("/dest/my-skill/scripts/tool.py")));
146 }
147
148 #[test]
149 fn checksum_bundled_matches_after_write() {
150 let fs = FakeFilesystem::new();
151 let files = vec![
152 make_file("SKILL.md", b"# skill"),
153 make_file("scripts/tool.py", b"code"),
154 ];
155 let expected = checksum_bundled(&files);
156 write_skill_files(Path::new("/dest/skill"), &files, &fs).unwrap();
157 let on_disk = crate::checksum::checksum_dir(Path::new("/dest/skill"), &fs).unwrap();
158 assert_eq!(expected, on_disk);
159 }
160}