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 the `/`-joined relative-path string for determinism.
46///
47/// The ordering keys on [`crate::checksum::rel_path_key`] — the same string
48/// `checksum_dir` uses — so an in-memory bundle checksum matches the on-disk
49/// checksum after [`write_skill_files`], including at the `.`-vs-`/` boundary
50/// (SPEC §5.1 / §11.4).
51pub 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
67/// Compute a checksum over the canonical (filtered, sorted) subset of `files`.
68///
69/// The result matches what `checksum_dir` would produce after [`write_skill_files`]
70/// writes the same files to disk.
71pub 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
76/// Write every file in `files` into `dest_dir`, creating parent directories as
77/// needed.
78///
79/// All files (including dotfiles and transient) are written — this mirrors what
80/// a normal directory copy does. [`canonical_files`] and [`checksum_bundled`]
81/// then exclude them from checksums, keeping parity with `checksum_dir`.
82pub 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        // SPEC §11.4: order by the `/`-joined string, not component-wise. Since
171        // '.' (0x2E) < '/' (0x2F), `a.b` sorts before `a/b`; the bare prefix `a`
172        // sorts before both. Component-wise `Path` ordering would put `a/b`
173        // before `a.b`, so this test fails against the old sort.
174        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        // The in-memory and on-disk checksums must still agree once the sort key
187        // spans the `.`-vs-`/` boundary — the parity the shared rel_path_key
188        // guarantees. (No bare `a` here: it would collide with the `a/` dir on
189        // write.)
190        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}