Skip to main content

cmx_core/
skill_fs.rs

1//! In-memory skill file representation and filesystem helpers.
2//!
3//! A [`BundledSkill`]'s files are carried in memory as [`SkillFile`] values.
4//! [`canonical_files`] filters and sorts them the same way `checksum_dir` does,
5//! so checksums computed from memory match checksums computed from disk.
6
7use 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/// A single file bundled inside a skill.
15#[derive(Debug, Clone)]
16pub struct SkillFile {
17    /// Path relative to the skill's root directory (e.g. `SKILL.md`, `scripts/tool.py`).
18    pub rel_path: PathBuf,
19    /// Raw file bytes.
20    pub bytes: Vec<u8>,
21}
22
23impl SkillFile {
24    /// Construct from text content, the common case for `include_str!` embeds:
25    ///
26    /// ```
27    /// # use cmx_core::skill_fs::SkillFile;
28    /// let f = SkillFile::text("references/workflows.md", "# Workflows\n");
29    /// assert_eq!(f.rel_path.to_str(), Some("references/workflows.md"));
30    /// ```
31    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
39/// Filter and sort `files` the same way `checksum_dir` processes a directory:
40///
41/// - Exclude files whose relative path contains a dotfile component (any
42///   component that starts with `'.'`).
43/// - Exclude files whose relative path contains a transient component
44///   (matched by [`is_transient`]).
45/// - Sort by `rel_path` for determinism.
46pub 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
60/// Compute a checksum over the canonical (filtered, sorted) subset of `files`.
61///
62/// The result matches what `checksum_dir` would produce after [`write_skill_files`]
63/// writes the same files to disk.
64pub 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
69/// Write every file in `files` into `dest_dir`, creating parent directories as
70/// needed.
71///
72/// All files (including dotfiles and transient) are written — this mirrors what
73/// a normal directory copy does. [`canonical_files`] and [`checksum_bundled`]
74/// then exclude them from checksums, keeping parity with `checksum_dir`.
75pub 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}