Skip to main content

cardinal_kernel/pack/
builder.rs

1use anyhow::{Context, Result};
2use sha2::{Digest, Sha256};
3use std::fs::File;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6use walkdir::WalkDir;
7
8use super::metadata::{FileEntry, Manifest, PackMeta};
9
10/// Build a .ccpack file from a directory
11///
12/// # Arguments
13/// * `input_dir` - Path to the directory containing pack.toml, cards/, scripts/, etc.
14/// * `output_file` - Path where the .ccpack file will be written
15///
16/// # Returns
17/// Result indicating success or detailed error
18///
19/// # Process
20/// 1. Validate that pack.toml exists and parse it
21/// 2. Walk directory and collect all valid files (sorted)
22/// 3. Compute SHA-256 for each file
23/// 4. Generate manifest.toml
24/// 5. Create tar archive with all files + manifest
25/// 6. Compress with zstd
26pub fn build_pack<P: AsRef<Path>, Q: AsRef<Path>>(input_dir: P, output_file: Q) -> Result<()> {
27    let input_dir = input_dir.as_ref();
28    let output_file = output_file.as_ref();
29
30    // Step 1: Load and validate pack.toml
31    let pack_toml_path = input_dir.join("pack.toml");
32    if !pack_toml_path.exists() {
33        anyhow::bail!("pack.toml not found in {}", input_dir.display());
34    }
35
36    let pack_toml_content = std::fs::read_to_string(&pack_toml_path)
37        .with_context(|| format!("Failed to read pack.toml at {}", pack_toml_path.display()))?;
38
39    let pack_meta: PackMeta = toml::from_str(&pack_toml_content)
40        .with_context(|| format!("Failed to parse pack.toml at {}", pack_toml_path.display()))?;
41
42    // Step 2: Collect all files, excluding unwanted ones
43    let mut file_paths = collect_files(input_dir)?;
44
45    // Sort for deterministic builds
46    file_paths.sort();
47
48    // Step 3: Generate file entries with hashes
49    let mut file_entries = Vec::new();
50    for file_path in &file_paths {
51        let full_path = input_dir.join(file_path);
52        let metadata = std::fs::metadata(&full_path)
53            .with_context(|| format!("Failed to read metadata for {}", full_path.display()))?;
54
55        let size = metadata.len();
56        let hash = compute_sha256(&full_path)
57            .with_context(|| format!("Failed to compute hash for {}", full_path.display()))?;
58
59        // Normalize path to use forward slashes
60        let normalized_path = file_path.to_string_lossy().replace('\\', "/");
61
62        file_entries.push(FileEntry {
63            path: normalized_path,
64            size,
65            sha256: hash,
66        });
67    }
68
69    // Step 4: Create manifest
70    let manifest = Manifest {
71        pack: pack_meta.clone(),
72        files: file_entries,
73    };
74
75    let manifest_toml = toml::to_string_pretty(&manifest)
76        .context("Failed to serialize manifest to TOML")?;
77
78    // Step 5: Create tar archive
79    let tar_data = create_tar_archive(input_dir, &file_paths, &manifest_toml)
80        .context("Failed to create tar archive")?;
81
82    // Step 6: Compress with zstd
83    let compressed = zstd::encode_all(&tar_data[..], 3)
84        .context("Failed to compress archive with zstd")?;
85
86    // Write to output file
87    std::fs::write(output_file, compressed)
88        .with_context(|| format!("Failed to write output file {}", output_file.display()))?;
89
90    println!("✓ Pack built successfully: {}", output_file.display());
91    println!("  Pack ID: {}", pack_meta.pack_id);
92    println!("  Version: {}", pack_meta.version);
93    println!("  Files: {}", file_paths.len());
94
95    Ok(())
96}
97
98/// Collect all files from the input directory, excluding unwanted files
99fn collect_files(input_dir: &Path) -> Result<Vec<PathBuf>> {
100    let mut files = Vec::new();
101
102    for entry in WalkDir::new(input_dir)
103        .into_iter()
104        .filter_entry(|e| !is_excluded(e))
105    {
106        let entry = entry.context("Failed to read directory entry")?;
107
108        // Skip directories
109        if entry.file_type().is_dir() {
110            continue;
111        }
112
113        // Get relative path
114        let relative_path = entry
115            .path()
116            .strip_prefix(input_dir)
117            .context("Failed to compute relative path")?
118            .to_path_buf();
119
120        files.push(relative_path);
121    }
122
123    Ok(files)
124}
125
126/// Check if a directory entry should be excluded
127fn is_excluded(entry: &walkdir::DirEntry) -> bool {
128    let name = entry.file_name().to_string_lossy();
129
130    // Exclude hidden files and directories
131    if name.starts_with('.') {
132        return true;
133    }
134
135    // Exclude common editor/build artifacts
136    let excluded_names = [
137        "node_modules",
138        "target",
139        "dist",
140        "build",
141        "__pycache__",
142        ".DS_Store",
143        "Thumbs.db",
144    ];
145
146    if excluded_names.contains(&name.as_ref()) {
147        return true;
148    }
149
150    // Exclude backup files
151    if name.ends_with('~') || name.ends_with(".bak") || name.ends_with(".swp") {
152        return true;
153    }
154
155    false
156}
157
158/// Compute SHA-256 hash of a file
159fn compute_sha256(path: &Path) -> Result<String> {
160    let mut file = File::open(path)?;
161    let mut hasher = Sha256::new();
162    let mut buffer = [0u8; 8192];
163
164    loop {
165        let n = file.read(&mut buffer)?;
166        if n == 0 {
167            break;
168        }
169        hasher.update(&buffer[..n]);
170    }
171
172    Ok(format!("{:x}", hasher.finalize()))
173}
174
175/// Create a tar archive containing all files plus the generated manifest
176fn create_tar_archive(
177    input_dir: &Path,
178    file_paths: &[PathBuf],
179    manifest_toml: &str,
180) -> Result<Vec<u8>> {
181    let mut tar_data = Vec::new();
182    {
183        let mut tar = tar::Builder::new(&mut tar_data);
184
185        // Add all collected files
186        for file_path in file_paths {
187            let full_path = input_dir.join(file_path);
188            let mut file = File::open(&full_path)
189                .with_context(|| format!("Failed to open file {}", full_path.display()))?;
190
191            // Normalize path to use forward slashes
192            let normalized_path = file_path.to_string_lossy().replace('\\', "/");
193
194            tar.append_file(&normalized_path, &mut file)
195                .with_context(|| format!("Failed to add {} to archive", normalized_path))?;
196        }
197
198        // Add manifest.toml
199        let manifest_bytes = manifest_toml.as_bytes();
200        let mut header = tar::Header::new_gnu();
201        header.set_path("manifest.toml")?;
202        header.set_size(manifest_bytes.len() as u64);
203        header.set_mode(0o644);
204        header.set_cksum();
205
206        tar.append(&header, manifest_bytes)
207            .context("Failed to add manifest.toml to archive")?;
208
209        tar.finish().context("Failed to finalize tar archive")?;
210    }
211
212    Ok(tar_data)
213}