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> {
52 let mut out: Vec<&SkillFile> = files
53 .iter()
54 .filter(|f| {
55 !f.rel_path.components().any(|c| {
56 let s = c.as_os_str().to_string_lossy();
57 s.starts_with('.') || is_transient(&s)
58 })
59 })
60 .collect();
61 out.sort_by(|a, b| {
62 crate::checksum::rel_path_key(&a.rel_path).cmp(&crate::checksum::rel_path_key(&b.rel_path))
63 });
64 out
65}
66
67pub fn checksum_bundled(files: &[SkillFile]) -> String {
72 let canonical = canonical_files(files);
73 checksum_in_memory(canonical.iter().map(|f| (f.rel_path.as_path(), f.bytes.as_slice())))
74}
75
76pub fn write_skill_files(dest_dir: &Path, files: &[SkillFile], fs: &dyn Filesystem) -> Result<()> {
83 fs.create_dir_all(dest_dir)?;
84 for file in files {
85 let dest = dest_dir.join(&file.rel_path);
86 if let Some(parent) = dest.parent() {
87 fs.create_dir_all(parent)?;
88 }
89 fs.write_bytes(&dest, &file.bytes)?;
90 }
91 Ok(())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use crate::gateway::fakes::FakeFilesystem;
98
99 fn make_file(rel: &str, bytes: &[u8]) -> SkillFile {
100 SkillFile {
101 rel_path: PathBuf::from(rel),
102 bytes: bytes.to_vec(),
103 }
104 }
105
106 #[test]
107 fn canonical_files_excludes_dotfiles() {
108 let files = vec![
109 make_file("SKILL.md", b"content"),
110 make_file(".hidden", b"hidden"),
111 make_file("scripts/.env", b"env"),
112 ];
113 let canonical = canonical_files(&files);
114 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
115 assert_eq!(names, vec!["SKILL.md"]);
116 }
117
118 #[test]
119 fn canonical_files_excludes_transient_dirs() {
120 let files = vec![
121 make_file("SKILL.md", b"content"),
122 make_file("node_modules/dep/index.js", b"vendor"),
123 make_file("__pycache__/tool.pyc", b"bytecode"),
124 make_file("scripts/tool.py", b"code"),
125 ];
126 let canonical = canonical_files(&files);
127 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
128 assert_eq!(names, vec!["SKILL.md", "scripts/tool.py"]);
129 }
130
131 #[test]
132 fn canonical_files_sorted_by_rel_path() {
133 let files = vec![
134 make_file("z.md", b"z"),
135 make_file("SKILL.md", b"skill"),
136 make_file("a.md", b"a"),
137 ];
138 let canonical = canonical_files(&files);
139 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
140 assert_eq!(names, vec!["SKILL.md", "a.md", "z.md"]);
141 }
142
143 #[test]
144 fn write_skill_files_creates_nested_dirs() {
145 let fs = FakeFilesystem::new();
146 let files = vec![
147 make_file("SKILL.md", b"# skill"),
148 make_file("scripts/tool.py", b"code"),
149 ];
150 write_skill_files(Path::new("/dest/my-skill"), &files, &fs).unwrap();
151 assert!(fs.file_exists(Path::new("/dest/my-skill/SKILL.md")));
152 assert!(fs.file_exists(Path::new("/dest/my-skill/scripts/tool.py")));
153 }
154
155 #[test]
156 fn checksum_bundled_matches_after_write() {
157 let fs = FakeFilesystem::new();
158 let files = vec![
159 make_file("SKILL.md", b"# skill"),
160 make_file("scripts/tool.py", b"code"),
161 ];
162 let expected = checksum_bundled(&files);
163 write_skill_files(Path::new("/dest/skill"), &files, &fs).unwrap();
164 let on_disk = crate::checksum::checksum_dir(Path::new("/dest/skill"), &fs).unwrap();
165 assert_eq!(expected, on_disk);
166 }
167
168 #[test]
169 fn canonical_files_string_sort_orders_dot_before_slash() {
170 let files = vec![
175 make_file("a/b", b"nested"),
176 make_file("a.b", b"dotted"),
177 make_file("a", b"bare"),
178 ];
179 let canonical = canonical_files(&files);
180 let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
181 assert_eq!(names, vec!["a", "a.b", "a/b"]);
182 }
183
184 #[test]
185 fn checksum_bundled_matches_after_write_with_dot_slash_paths() {
186 let fs = FakeFilesystem::new();
191 let files = vec![
192 make_file("a/b", b"nested"),
193 make_file("a.b", b"dotted"),
194 make_file("SKILL.md", b"# skill"),
195 ];
196 let expected = checksum_bundled(&files);
197 write_skill_files(Path::new("/dest/skill"), &files, &fs).unwrap();
198 let on_disk = crate::checksum::checksum_dir(Path::new("/dest/skill"), &fs).unwrap();
199 assert_eq!(
200 expected, on_disk,
201 "bundle and on-disk checksums must agree across .-vs-/ paths"
202 );
203 }
204}