Skip to main content

semora_cli/
bundle.rs

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