cardinal_kernel/pack/
builder.rs1use 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
10pub 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 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 let mut file_paths = collect_files(input_dir)?;
44
45 file_paths.sort();
47
48 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 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 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 let tar_data = create_tar_archive(input_dir, &file_paths, &manifest_toml)
80 .context("Failed to create tar archive")?;
81
82 let compressed = zstd::encode_all(&tar_data[..], 3)
84 .context("Failed to compress archive with zstd")?;
85
86 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
98fn 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 if entry.file_type().is_dir() {
110 continue;
111 }
112
113 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
126fn is_excluded(entry: &walkdir::DirEntry) -> bool {
128 let name = entry.file_name().to_string_lossy();
129
130 if name.starts_with('.') {
132 return true;
133 }
134
135 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 if name.ends_with('~') || name.ends_with(".bak") || name.ends_with(".swp") {
152 return true;
153 }
154
155 false
156}
157
158fn 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
175fn 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 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 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 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}