use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{bail, Context, Result};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
pub const MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
const SKIP_DIRS: [&str; 4] = ["node_modules", ".git", "target", "dist"];
const MANIFEST_FILE: &str = ".dotzuki-editor.json";
pub fn collect_project_files(root: &Path) -> Result<BTreeMap<String, String>> {
collect_project_files_with_caps(root, MAX_FILE_BYTES, MAX_TOTAL_BYTES)
}
pub fn serialize_bundle(files: &BTreeMap<String, String>) -> Result<String> {
let exported_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let bundle_json = serde_json::json!({
"dotzuki": {
"tool": "dotzuki-cli",
"version": env!("CARGO_PKG_VERSION"),
"exportedAt": exported_at,
},
"files": files,
});
serde_json::to_string(&bundle_json).context("failed to serialize bundle")
}
pub fn collect_project_files_with_caps(
root: &Path,
max_file: u64,
max_total: u64,
) -> Result<BTreeMap<String, String>> {
let mut files = BTreeMap::new();
let mut total: u64 = 0;
walk(root, "", &mut files, &mut total, max_file, max_total)?;
Ok(files)
}
#[allow(clippy::too_many_arguments)] fn walk(
dir: &Path,
rel: &str,
out: &mut BTreeMap<String, String>,
total: &mut u64,
max_file: u64,
max_total: u64,
) -> Result<()> {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.with_context(|| format!("failed to list {}", dir.display()))?
.collect::<std::io::Result<Vec<_>>>()
.with_context(|| format!("failed to list {}", dir.display()))?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let name = entry.file_name();
let Ok(name) = name.into_string() else {
continue; };
let full = entry.path();
let meta = std::fs::symlink_metadata(&full)
.with_context(|| format!("failed to stat {}", full.display()))?;
let child_rel = if rel.is_empty() {
name.clone()
} else {
format!("{rel}/{name}")
};
if meta.file_type().is_symlink() {
continue;
}
if meta.is_dir() {
if SKIP_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
walk(&full, &child_rel, out, total, max_file, max_total)?;
} else if meta.is_file() {
if name != MANIFEST_FILE && (name.starts_with('.') || name.ends_with(".bak")) {
continue;
}
let size = meta.len();
if size > max_file {
bail!("File too large: {child_rel} ({size} bytes, cap is {max_file})");
}
*total += size;
if *total > max_total {
bail!("Project too large to bundle (>{max_total} bytes uncompressed)");
}
let bytes = std::fs::read(&full)
.with_context(|| format!("failed to read {}", full.display()))?;
out.insert(child_rel, BASE64.encode(bytes));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
struct TestDir(std::path::PathBuf);
impl TestDir {
fn new(test: &str) -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"dotzuki-cli-bundle-{test}-{}-{id}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn write(root: &Path, rel: &str, bytes: &[u8]) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn collects_only_shippable_files_as_posix_base64() {
let tmp = TestDir::new("rules");
let root = tmp.0.as_path();
write(root, ".dotzuki-editor.json", b"{}");
write(root, "data/a.txt", b"hello");
write(root, "data/deep/b.bin", &[0, 1, 255]);
write(root, ".hidden", b"x");
write(root, ".hidden-dir/x", b"x");
write(root, "data/x.bak", b"x");
write(root, "node_modules/pkg/index.js", b"x");
write(root, ".git/config", b"x");
write(root, "target/debug/x", b"x");
write(root, "dist/web/index.html", b"x");
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("data"), root.join("link")).unwrap();
let files = collect_project_files(root).unwrap();
let keys: Vec<&str> = files.keys().map(|k| k.as_str()).collect();
assert_eq!(
keys,
[".dotzuki-editor.json", "data/a.txt", "data/deep/b.bin"]
);
assert_eq!(files["data/a.txt"], BASE64.encode(b"hello"));
assert_eq!(files["data/deep/b.bin"], BASE64.encode([0, 1, 255]));
}
#[test]
fn refuses_a_file_past_the_per_file_cap() {
let tmp = TestDir::new("filecap");
write(&tmp.0, "big.bin", &[0u8; 9]);
let err = collect_project_files_with_caps(&tmp.0, 8, 1024).unwrap_err();
assert!(err.to_string().contains("File too large: big.bin"), "{err}");
}
#[test]
fn refuses_a_project_past_the_total_cap() {
let tmp = TestDir::new("totalcap");
write(&tmp.0, "a.bin", &[0u8; 6]);
write(&tmp.0, "b.bin", &[0u8; 6]);
let err = collect_project_files_with_caps(&tmp.0, 1024, 10).unwrap_err();
assert!(err.to_string().contains("Project too large"), "{err}");
}
}