Skip to main content

semora_cli/
bundle.rs

1//! Packing a folder into a tar for a bundle push.
2
3use std::path::Path;
4
5use anyhow::Context;
6
7/// Extensions the server accepts in a bundle.
8pub fn allowed(path: &str) -> bool {
9    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
10    path.contains('.')
11        && matches!(
12            ext.as_str(),
13            "md" | "markdown"
14                | "html"
15                | "htm"
16                | "css"
17                | "js"
18                | "mjs"
19                | "json"
20                | "svg"
21                | "png"
22                | "jpg"
23                | "jpeg"
24                | "gif"
25                | "webp"
26                | "avif"
27                | "bmp"
28                | "ico"
29                | "woff"
30                | "woff2"
31                | "ttf"
32                | "otf"
33        )
34}
35
36pub fn is_page(path: &str) -> bool {
37    let p = path.to_ascii_lowercase();
38    p.ends_with(".md") || p.ends_with(".markdown") || p.ends_with(".html") || p.ends_with(".htm")
39}
40
41/// Folders never worth uploading.
42fn skip_dir(name: &str) -> bool {
43    name.starts_with('.') || name == "node_modules"
44}
45
46pub struct Packed {
47    pub tar: Vec<u8>,
48    pub files: Vec<String>,
49    /// Files left out on purpose, with the reason.
50    pub skipped: Vec<(String, &'static str)>,
51}
52
53pub fn pack_dir(root: &Path) -> anyhow::Result<Packed> {
54    let mut files = Vec::new();
55    let mut skipped = Vec::new();
56    walk(root, "", &mut files, &mut skipped)?;
57    files.sort();
58    let mut builder = tar::Builder::new(Vec::new());
59    for rel in &files {
60        let data = std::fs::read(root.join(rel)).with_context(|| format!("reading {rel}"))?;
61        let mut h = tar::Header::new_gnu();
62        h.set_size(data.len() as u64);
63        h.set_mode(0o644);
64        h.set_entry_type(tar::EntryType::Regular);
65        h.set_mtime(0);
66        builder
67            .append_data(&mut h, rel, data.as_slice())
68            .with_context(|| format!("adding {rel}"))?;
69    }
70    let tar = builder.into_inner()?;
71    Ok(Packed {
72        tar,
73        files,
74        skipped,
75    })
76}
77
78fn walk(
79    root: &Path,
80    rel: &str,
81    files: &mut Vec<String>,
82    skipped: &mut Vec<(String, &'static str)>,
83) -> anyhow::Result<()> {
84    let dir = if rel.is_empty() {
85        root.to_path_buf()
86    } else {
87        root.join(rel)
88    };
89    let mut entries: Vec<_> = std::fs::read_dir(&dir)
90        .with_context(|| format!("reading {}", dir.display()))?
91        .collect::<Result<_, _>>()?;
92    entries.sort_by_key(|e| e.file_name());
93    for e in entries {
94        let name = e.file_name().to_string_lossy().into_owned();
95        let path = if rel.is_empty() {
96            name.clone()
97        } else {
98            format!("{rel}/{name}")
99        };
100        let ft = e.file_type()?;
101        if ft.is_symlink() {
102            skipped.push((path, "symbolic link"));
103        } else if ft.is_dir() {
104            if !skip_dir(&name) {
105                walk(root, &path, files, skipped)?;
106            }
107        } else if name.starts_with('.') {
108            // Hidden files stay local.
109        } else if !allowed(&path) {
110            skipped.push((path, "file type not served"));
111        } else {
112            files.push(path);
113        }
114    }
115    Ok(())
116}
117
118/// Relative links from a Markdown file to other local pages (for the bundle hint).
119pub fn local_page_links(md: &str) -> Vec<String> {
120    let mut out = Vec::new();
121    let mut rest = md;
122    while let Some(i) = rest.find("](") {
123        rest = &rest[i + 2..];
124        let end = rest.find([')', ' ', '\n']).unwrap_or(rest.len());
125        let href = rest[..end].trim_matches(['<', '>']);
126        let path = href.split(['#', '?']).next().unwrap_or("");
127        let external = href.contains("://")
128            || href.starts_with("mailto:")
129            || href.starts_with('#')
130            || href.starts_with('/');
131        if !external && is_page(path) {
132            out.push(href.to_string());
133        }
134        rest = &rest[end..];
135    }
136    out
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn packs_and_skips() {
145        let dir = tempfile::tempdir().unwrap();
146        let w = |p: &str| {
147            let full = dir.path().join(p);
148            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
149            std::fs::write(full, "x").unwrap();
150        };
151        for p in [
152            "index.md",
153            "a/b.png",
154            ".env",
155            ".git/HEAD",
156            "node_modules/m/i.js",
157            "notes.txt",
158            "a/.hidden.md",
159        ] {
160            w(p);
161        }
162        let packed = pack_dir(dir.path()).unwrap();
163        assert_eq!(packed.files, vec!["a/b.png", "index.md"]);
164        assert_eq!(
165            packed.skipped,
166            vec![("notes.txt".to_string(), "file type not served")]
167        );
168        let mut archive = tar::Archive::new(packed.tar.as_slice());
169        let names: Vec<String> = archive
170            .entries()
171            .unwrap()
172            .map(|e| {
173                e.unwrap()
174                    .path()
175                    .unwrap()
176                    .to_string_lossy()
177                    .replace('\\', "/")
178            })
179            .collect();
180        assert_eq!(names, vec!["a/b.png", "index.md"]);
181    }
182
183    #[test]
184    fn finds_local_page_links() {
185        let md = "[a](guide.md) [b](https://x/y.md) [c](img.png) [d](sub/page.html#top) [e](#here)";
186        assert_eq!(local_page_links(md), vec!["guide.md", "sub/page.html#top"]);
187    }
188}