use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::checksum::checksum_in_memory;
use crate::fs_util::is_transient;
use crate::gateway::Filesystem;
#[derive(Debug, Clone)]
pub struct SkillFile {
pub rel_path: PathBuf,
pub bytes: Vec<u8>,
}
impl SkillFile {
pub fn text(rel_path: impl Into<PathBuf>, content: &str) -> Self {
Self {
rel_path: rel_path.into(),
bytes: content.as_bytes().to_vec(),
}
}
}
pub fn canonical_files(files: &[SkillFile]) -> Vec<&SkillFile> {
let mut out: Vec<&SkillFile> = files
.iter()
.filter(|f| {
!f.rel_path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
s.starts_with('.') || is_transient(&s)
})
})
.collect();
out.sort_by(|a, b| {
crate::checksum::rel_path_key(&a.rel_path).cmp(&crate::checksum::rel_path_key(&b.rel_path))
});
out
}
pub fn checksum_bundled(files: &[SkillFile]) -> String {
let canonical = canonical_files(files);
checksum_in_memory(canonical.iter().map(|f| (f.rel_path.as_path(), f.bytes.as_slice())))
}
pub fn write_skill_files(dest_dir: &Path, files: &[SkillFile], fs: &dyn Filesystem) -> Result<()> {
fs.create_dir_all(dest_dir)?;
for file in files {
let dest = dest_dir.join(&file.rel_path);
if let Some(parent) = dest.parent() {
fs.create_dir_all(parent)?;
}
fs.write_bytes(&dest, &file.bytes)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gateway::fakes::FakeFilesystem;
fn make_file(rel: &str, bytes: &[u8]) -> SkillFile {
SkillFile {
rel_path: PathBuf::from(rel),
bytes: bytes.to_vec(),
}
}
#[test]
fn canonical_files_excludes_dotfiles() {
let files = vec![
make_file("SKILL.md", b"content"),
make_file(".hidden", b"hidden"),
make_file("scripts/.env", b"env"),
];
let canonical = canonical_files(&files);
let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
assert_eq!(names, vec!["SKILL.md"]);
}
#[test]
fn canonical_files_excludes_transient_dirs() {
let files = vec![
make_file("SKILL.md", b"content"),
make_file("node_modules/dep/index.js", b"vendor"),
make_file("__pycache__/tool.pyc", b"bytecode"),
make_file("scripts/tool.py", b"code"),
];
let canonical = canonical_files(&files);
let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
assert_eq!(names, vec!["SKILL.md", "scripts/tool.py"]);
}
#[test]
fn canonical_files_sorted_by_rel_path() {
let files = vec![
make_file("z.md", b"z"),
make_file("SKILL.md", b"skill"),
make_file("a.md", b"a"),
];
let canonical = canonical_files(&files);
let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
assert_eq!(names, vec!["SKILL.md", "a.md", "z.md"]);
}
#[test]
fn write_skill_files_creates_nested_dirs() {
let fs = FakeFilesystem::new();
let files = vec![
make_file("SKILL.md", b"# skill"),
make_file("scripts/tool.py", b"code"),
];
write_skill_files(Path::new("/dest/my-skill"), &files, &fs).unwrap();
assert!(fs.file_exists(Path::new("/dest/my-skill/SKILL.md")));
assert!(fs.file_exists(Path::new("/dest/my-skill/scripts/tool.py")));
}
#[test]
fn checksum_bundled_matches_after_write() {
let fs = FakeFilesystem::new();
let files = vec![
make_file("SKILL.md", b"# skill"),
make_file("scripts/tool.py", b"code"),
];
let expected = checksum_bundled(&files);
write_skill_files(Path::new("/dest/skill"), &files, &fs).unwrap();
let on_disk = crate::checksum::checksum_dir(Path::new("/dest/skill"), &fs).unwrap();
assert_eq!(expected, on_disk);
}
#[test]
fn canonical_files_string_sort_orders_dot_before_slash() {
let files = vec![
make_file("a/b", b"nested"),
make_file("a.b", b"dotted"),
make_file("a", b"bare"),
];
let canonical = canonical_files(&files);
let names: Vec<_> = canonical.iter().map(|f| f.rel_path.to_str().unwrap()).collect();
assert_eq!(names, vec!["a", "a.b", "a/b"]);
}
#[test]
fn checksum_bundled_matches_after_write_with_dot_slash_paths() {
let fs = FakeFilesystem::new();
let files = vec![
make_file("a/b", b"nested"),
make_file("a.b", b"dotted"),
make_file("SKILL.md", b"# skill"),
];
let expected = checksum_bundled(&files);
write_skill_files(Path::new("/dest/skill"), &files, &fs).unwrap();
let on_disk = crate::checksum::checksum_dir(Path::new("/dest/skill"), &fs).unwrap();
assert_eq!(
expected, on_disk,
"bundle and on-disk checksums must agree across .-vs-/ paths"
);
}
}