1use std::fs::File;
11use std::io::{self, Write};
12use std::path::{Path, PathBuf};
13
14use crate::error::PackError;
15use crate::manifest::{MANIFEST_NAME, Manifest, PACK_FORMAT_VERSION, Stats};
16use crate::rules::PackRules;
17use crate::scan::{self, Entry, EntryKind, Scan};
18
19pub const PAYLOAD_PREFIX: &str = "payload";
24
25pub const DEFAULT_COMPRESSION_LEVEL: i32 = 3;
31
32#[derive(Debug, Clone)]
34pub struct CreateOptions {
35 pub root: PathBuf,
37 pub out: PathBuf,
39 pub lds_version: String,
41 pub compression_level: i32,
43 pub rules: PackRules,
45 pub dry_run: bool,
51}
52
53impl CreateOptions {
54 pub fn new(root: impl Into<PathBuf>, out: impl Into<PathBuf>, lds_version: &str) -> Self {
56 Self {
57 root: root.into(),
58 out: out.into(),
59 lds_version: lds_version.to_string(),
60 compression_level: DEFAULT_COMPRESSION_LEVEL,
61 rules: PackRules::default(),
62 dry_run: false,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct CreateReport {
70 pub manifest: Manifest,
72 pub out_path: PathBuf,
74 pub compressed_bytes: u64,
76 pub dry_run: bool,
78}
79
80pub fn create(opts: &CreateOptions) -> Result<CreateReport, PackError> {
98 let root = canonical_or_self(&opts.root);
99 let scanned = scan::scan_with(&root, &opts.rules)?;
100
101 let manifest = build_manifest(&root, &scanned, &opts.lds_version);
102 let manifest_toml = manifest.to_toml()?;
103
104 if opts.dry_run {
105 return Ok(CreateReport {
108 manifest,
109 out_path: opts.out.clone(),
110 compressed_bytes: 0,
111 dry_run: true,
112 });
113 }
114
115 if let Some(parent) = opts.out.parent()
116 && !parent.as_os_str().is_empty()
117 {
118 std::fs::create_dir_all(parent)?;
119 }
120
121 let file = File::create(&opts.out)?;
122 let encoder = zstd::stream::Encoder::new(file, opts.compression_level)?;
123 let mut builder = tar::Builder::new(encoder);
124 builder.follow_symlinks(false);
127
128 write_manifest_entry(&mut builder, &manifest_toml)?;
129 for entry in &scanned.entries {
130 write_entry(&mut builder, entry)?;
131 }
132
133 let encoder = builder.into_inner()?;
134 let mut file = encoder.finish()?;
135 file.flush()?;
136
137 let compressed_bytes = file.metadata().map(|m| m.len()).unwrap_or(0);
138
139 Ok(CreateReport {
140 manifest,
141 out_path: opts.out.clone(),
142 compressed_bytes,
143 dry_run: false,
144 })
145}
146
147fn build_manifest(root: &Path, scanned: &Scan, lds_version: &str) -> Manifest {
149 let project_name = root
150 .file_name()
151 .map(|n| n.to_string_lossy().into_owned())
152 .unwrap_or_else(|| "project".to_string());
153
154 Manifest {
155 format_version: PACK_FORMAT_VERSION,
156 created_at: chrono::Utc::now().to_rfc3339(),
157 source_root: root.to_string_lossy().into_owned(),
158 project_name,
159 lds_version: lds_version.to_string(),
160 stats: Stats {
161 file_count: scanned.file_count(),
162 symlink_count: scanned.symlink_count(),
163 total_bytes: scanned.total_bytes(),
164 },
165 claude: scanned.claude.clone(),
166 skipped_cache: scanned.skipped_cache.clone(),
167 skipped_secret: scanned.skipped_secret.clone(),
168 symlinks: scanned.symlinks.clone(),
169 worktrees: scanned.worktrees.clone(),
170 }
171}
172
173fn write_manifest_entry<W: Write>(
176 builder: &mut tar::Builder<W>,
177 manifest_toml: &str,
178) -> Result<(), PackError> {
179 let bytes = manifest_toml.as_bytes();
180 let mut header = tar::Header::new_gnu();
181 header.set_size(bytes.len() as u64);
182 header.set_mode(0o644);
183 header.set_mtime(now_epoch_secs());
184 header.set_entry_type(tar::EntryType::Regular);
185 header.set_cksum();
186 builder.append_data(&mut header, MANIFEST_NAME, bytes)?;
187 Ok(())
188}
189
190fn write_entry<W: Write>(builder: &mut tar::Builder<W>, entry: &Entry) -> Result<(), PackError> {
192 let archive_path = format!("{PAYLOAD_PREFIX}/{}", entry.rel);
193
194 match entry.kind {
195 EntryKind::Dir => {
196 builder.append_dir(&archive_path, &entry.abs)?;
197 }
198 EntryKind::File => {
199 match File::open(&entry.abs) {
203 Ok(mut f) => builder.append_file(&archive_path, &mut f)?,
204 Err(e) if e.kind() == io::ErrorKind::NotFound => {
205 tracing::warn!("file vanished during pack, skipping: {}", entry.rel);
206 }
207 Err(e) => return Err(PackError::Io(e)),
208 }
209 }
210 EntryKind::Symlink => {
211 let target = std::fs::read_link(&entry.abs)?;
212 let mut header = tar::Header::new_gnu();
213 header.set_size(0);
214 header.set_mode(0o777);
215 header.set_mtime(now_epoch_secs());
216 header.set_entry_type(tar::EntryType::Symlink);
217 builder.append_link(&mut header, &archive_path, &target)?;
218 }
219 }
220
221 Ok(())
222}
223
224fn now_epoch_secs() -> u64 {
226 std::time::SystemTime::now()
227 .duration_since(std::time::UNIX_EPOCH)
228 .map(|d| d.as_secs())
229 .unwrap_or(0)
230}
231
232fn canonical_or_self(path: &Path) -> PathBuf {
234 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::inspect;
241 use std::fs;
242 use tempfile::TempDir;
243
244 fn touch(path: &Path, body: &str) {
245 if let Some(parent) = path.parent() {
246 fs::create_dir_all(parent).expect("mkdir");
247 }
248 fs::write(path, body).expect("write");
249 }
250
251 fn sample_project(root: &Path) {
252 touch(&root.join("src/main.rs"), "fn main() {}");
253 touch(&root.join(".git/HEAD"), "ref: refs/heads/main\n");
254 touch(&root.join("workspace/journal.md"), "# journal\n");
255 touch(&root.join(".env"), "SECRET=1\n");
256 touch(&root.join("target/debug/app"), "binary");
257 }
258
259 #[test]
262 fn test_create_writes_readable_archive() {
263 let dir = TempDir::new().expect("tempdir");
264 let root = dir.path().join("proj");
265 fs::create_dir_all(&root).expect("mkdir");
266 sample_project(&root);
267
268 let out = dir.path().join("out/proj.pack");
269 let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
270
271 assert!(out.is_file(), "archive should exist");
272 assert!(report.compressed_bytes > 0);
273 assert_eq!(report.manifest.project_name, "proj");
274 assert_eq!(report.manifest.format_version, PACK_FORMAT_VERSION);
275
276 let read_back = inspect::inspect(&out).expect("inspect");
277 assert_eq!(read_back.project_name, "proj");
278 assert_eq!(read_back.stats.file_count, report.manifest.stats.file_count);
279 }
280
281 #[test]
283 fn test_create_reports_but_omits_secrets() {
284 let dir = TempDir::new().expect("tempdir");
285 let root = dir.path().join("proj");
286 fs::create_dir_all(&root).expect("mkdir");
287 sample_project(&root);
288
289 let out = dir.path().join("proj.pack");
290 let report = create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
291
292 assert!(
293 report
294 .manifest
295 .skipped_secret
296 .iter()
297 .any(|s| s.path == ".env"),
298 "secret must be reported"
299 );
300
301 let names = crate::inspect::list_payload_paths(&out).expect("list");
302 assert!(
303 !names.iter().any(|n| n == ".env"),
304 "secret must not be in the payload"
305 );
306 assert!(names.iter().any(|n| n == "src/main.rs"));
307 assert!(names.iter().any(|n| n == ".git/HEAD"));
308 assert!(!names.iter().any(|n| n.starts_with("target/")));
309 }
310
311 #[test]
313 fn test_create_makes_parent_directory() {
314 let dir = TempDir::new().expect("tempdir");
315 let root = dir.path().join("proj");
316 fs::create_dir_all(&root).expect("mkdir");
317 touch(&root.join("a.txt"), "a");
318
319 let out = dir.path().join("deeply/nested/proj.pack");
320 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
321 assert!(out.is_file());
322 }
323
324 #[test]
326 fn test_dry_run_writes_nothing() {
327 let dir = TempDir::new().expect("tempdir");
328 let root = dir.path().join("proj");
329 fs::create_dir_all(&root).expect("mkdir");
330 sample_project(&root);
331
332 let out = dir.path().join("would-be.pack");
333 let mut opts = CreateOptions::new(&root, &out, "0.13.3");
334 opts.dry_run = true;
335
336 let report = create(&opts).expect("dry run should succeed");
337
338 assert!(report.dry_run);
339 assert_eq!(report.compressed_bytes, 0);
340 assert!(!out.exists(), "dry run must not write an archive");
341 assert!(report.manifest.stats.file_count > 0);
343 assert!(
344 report
345 .manifest
346 .skipped_secret
347 .iter()
348 .any(|s| s.path == ".env"),
349 "a dry run still reports what would be left behind"
350 );
351 }
352
353 #[test]
355 fn test_custom_secret_glob_reaches_the_archive() {
356 let dir = TempDir::new().expect("tempdir");
357 let root = dir.path().join("proj");
358 fs::create_dir_all(&root).expect("mkdir");
359 touch(&root.join("keep-me.txt"), "content");
360 touch(&root.join("my-app-keys.json"), "SECRET");
361 touch(&root.join("prod.vault"), "SECRET");
362
363 let out = dir.path().join("proj.pack");
364 let mut opts = CreateOptions::new(&root, &out, "0.13.3");
365 opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
366 secret_globs: vec!["my-app-keys.json".to_string(), "*.vault".to_string()],
367 ..Default::default()
368 })
369 .expect("rules compile");
370
371 let report = create(&opts).expect("create");
372
373 let skipped: Vec<&str> = report
374 .manifest
375 .skipped_secret
376 .iter()
377 .map(|s| s.path.as_str())
378 .collect();
379 assert!(skipped.contains(&"my-app-keys.json"));
380 assert!(skipped.contains(&"prod.vault"));
381
382 let payload = crate::inspect::list_payload_paths(&out).expect("list");
383 assert!(payload.iter().any(|p| p == "keep-me.txt"));
384 assert!(!payload.iter().any(|p| p == "my-app-keys.json"));
385 assert!(!payload.iter().any(|p| p == "prod.vault"));
386 }
387
388 #[test]
390 fn test_keep_carries_a_builtin_secret() {
391 let dir = TempDir::new().expect("tempdir");
392 let root = dir.path().join("proj");
393 fs::create_dir_all(&root).expect("mkdir");
394 touch(&root.join(".npmrc"), "registry=...");
395
396 let out = dir.path().join("proj.pack");
397 let mut opts = CreateOptions::new(&root, &out, "0.13.3");
398 opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
399 keep: vec![".npmrc".to_string()],
400 ..Default::default()
401 })
402 .expect("rules compile");
403
404 let report = create(&opts).expect("create");
405 assert!(
406 report.manifest.skipped_secret.is_empty(),
407 "keep must remove it from the skip list"
408 );
409 let payload = crate::inspect::list_payload_paths(&out).expect("list");
410 assert!(payload.iter().any(|p| p == ".npmrc"));
411 }
412
413 #[test]
415 fn test_custom_cache_dir_is_pruned() {
416 let dir = TempDir::new().expect("tempdir");
417 let root = dir.path().join("proj");
418 fs::create_dir_all(&root).expect("mkdir");
419 touch(&root.join("src/main.rs"), "fn main() {}");
420 touch(&root.join("dist/bundle.js"), "built");
421
422 let out = dir.path().join("proj.pack");
423 let mut opts = CreateOptions::new(&root, &out, "0.13.3");
424 opts.rules = crate::rules::PackRules::new(&crate::rules::RuleOverrides {
425 cache_dirs: vec!["dist".to_string()],
426 ..Default::default()
427 })
428 .expect("rules compile");
429
430 let report = create(&opts).expect("create");
431 assert!(
432 report
433 .manifest
434 .skipped_cache
435 .iter()
436 .any(|c| c.path == "dist"),
437 "custom cache must be recorded"
438 );
439 let payload = crate::inspect::list_payload_paths(&out).expect("list");
440 assert!(!payload.iter().any(|p| p.starts_with("dist")));
441 assert!(payload.iter().any(|p| p == "src/main.rs"));
442 }
443
444 #[test]
446 fn test_dist_is_packed_by_default() {
447 let dir = TempDir::new().expect("tempdir");
448 let root = dir.path().join("proj");
449 fs::create_dir_all(&root).expect("mkdir");
450 touch(&root.join("dist/hand-written.js"), "source");
451
452 let out = dir.path().join("proj.pack");
453 create(&CreateOptions::new(&root, &out, "0.13.3")).expect("create");
454
455 let payload = crate::inspect::list_payload_paths(&out).expect("list");
456 assert!(payload.iter().any(|p| p == "dist/hand-written.js"));
457 }
458
459 #[test]
461 fn test_create_rejects_non_directory() {
462 let dir = TempDir::new().expect("tempdir");
463 let file = dir.path().join("f.txt");
464 touch(&file, "x");
465 let out = dir.path().join("o.pack");
466 assert!(matches!(
467 create(&CreateOptions::new(&file, &out, "0.13.3")),
468 Err(PackError::NotADirectory(_))
469 ));
470 }
471}