1use anyhow::{Context, Result};
2use ignore::overrides::OverrideBuilder;
3use ignore::WalkBuilder;
4use std::fs::File;
5use std::io::BufWriter;
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8use std::path::PathBuf;
9use zip::write::SimpleFileOptions;
10use zip::CompressionMethod;
11
12pub struct ScanConfig {
14 pub root_path: PathBuf,
16 pub exclude_patterns: Vec<String>,
18}
19
20impl ScanConfig {
21 pub fn new(path: impl Into<PathBuf>, excludes: Vec<String>) -> Self {
23 Self {
24 root_path: path.into(),
25 exclude_patterns: excludes,
26 }
27 }
28}
29
30pub struct PackConfig {
31 pub root_path: PathBuf,
32 pub output_path: PathBuf,
33 pub compression_method: CompressionMethod,
34 pub compression_level: Option<i64>,
36}
37
38pub fn scan_files(config: &ScanConfig) -> Result<Vec<PathBuf>> {
63 let mut files = Vec::new();
64
65 let mut overrides = OverrideBuilder::new(&config.root_path);
66 for pattern in &config.exclude_patterns {
67 if let Some(whitelist_pattern) = pattern.strip_prefix('!') {
68 overrides
71 .add(whitelist_pattern)
72 .context("Invalid include pattern")?;
73 } else {
74 overrides
77 .add(&format!("!{}", pattern))
78 .context("Invalid exclude pattern")?;
79 }
80 }
81 let override_matched = overrides.build()?;
82
83 let walker = WalkBuilder::new(&config.root_path)
85 .standard_filters(true) .overrides(override_matched) .require_git(false) .hidden(false) .build();
90
91 for result in walker {
92 match result {
93 Ok(entry) => {
94 let path = entry.path();
95
96 if path.is_file() {
98 files.push(path.to_path_buf());
99 }
100 }
101 Err(err) => {
102 eprintln!("Scan warning: {}", err);
103 }
104 }
105 }
106
107 Ok(files)
108}
109
110pub fn pack_files<F>(files: &[PathBuf], config: &PackConfig, mut on_progress: F) -> Result<()>
150where
151 F: FnMut(&PathBuf, u64, u64) -> (),
152{
153 let file = File::create(&config.output_path)
154 .with_context(|| format!("Failed to create output file: {:?}", &config.output_path))?;
155
156 let buf_writer = BufWriter::with_capacity(1024 * 1024, file);
158 let mut zip = zip::ZipWriter::new(buf_writer);
159
160 let options = SimpleFileOptions::default()
162 .compression_method(CompressionMethod::Deflated)
163 .compression_level(config.compression_level)
164 .large_file(true); let mut total_processed_size: u64 = 0;
167
168 for path in files {
169 let relative_path = path.strip_prefix(&config.root_path).unwrap_or(path);
172
173 let path_str = relative_path.to_string_lossy().replace('\\', "/");
176
177 let mut f = File::open(path)?;
179 let metadata = f.metadata()?;
180
181 let permissions = if cfg!(unix) {
183 #[cfg(unix)]
184 {
185 metadata.permissions().mode()
186 }
187 #[cfg(not(unix))]
188 {
189 0o644 }
191 } else {
192 0o644
193 };
194
195 zip.start_file(path_str, options.clone().unix_permissions(permissions))?;
197
198 let current_file_size = metadata.len();
199
200 std::io::copy(&mut f, &mut zip)?;
202
203 total_processed_size += current_file_size;
204 on_progress(path, current_file_size, total_processed_size);
205 }
206
207 zip.finish()?;
209
210 Ok(())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use std::fs::{create_dir_all, File};
217 use std::io::{Read, Write};
218 use std::path::Path;
219 use tempfile::tempdir;
220 use zip::ZipArchive;
221
222 fn create_test_file(dir: &Path, name: &str, content: &[u8]) {
224 let path = dir.join(name);
225 if let Some(parent) = path.parent() {
226 create_dir_all(parent).unwrap();
227 }
228 let mut f = File::create(path).unwrap();
229 f.write_all(content).unwrap();
230 }
231
232 #[test]
233 fn test_scan_filtering_logic() {
234 let temp_dir = tempdir().unwrap();
236 let root = temp_dir.path();
237
238 create_test_file(root, "src/main.rs", b"fn main() {}");
242 create_test_file(root, "README.md", b"# Hello");
243 create_test_file(root, ".env", b"SECRET=123");
245
246 create_test_file(root, "target/debug/app.exe", b"binary");
248 create_test_file(root, "node_modules/react/index.js", b"module");
249 create_test_file(root, ".git/HEAD", b"ref: refs/heads/main");
250 create_test_file(root, ".vscode/settings.json", b"{}");
251
252 create_test_file(root, ".gitignore", b"*.log\n/temp/");
254 create_test_file(root, "error.log", b"error content"); create_test_file(root, "temp/cache.bin", b"cache"); let config = ScanConfig::new(root, vec![
259 String::from(".git"),
260 String::from("node_modules"),
261 String::from("target"),
262 String::from(".vscode"),
263 ]);
264 let files = scan_files(&config).expect("Scan failed");
265
266 let relative_paths: Vec<String> = files
269 .iter()
270 .map(|p| {
271 p.strip_prefix(root)
272 .unwrap()
273 .to_string_lossy()
274 .replace('\\', "/")
275 })
276 .collect();
277
278 assert!(
281 relative_paths.contains(&"src/main.rs".to_string()),
282 "Missing src/main.rs"
283 );
284 assert!(
285 relative_paths.contains(&"README.md".to_string()),
286 "Missing README.md"
287 );
288 assert!(relative_paths.contains(&".env".to_string()), "Missing .env");
289 assert!(
290 relative_paths.contains(&".gitignore".to_string()),
291 "Missing .gitignore"
292 ); assert!(
296 !relative_paths.iter().any(|p| p.contains("target")),
297 "Should exclude target"
298 );
299 assert!(
300 !relative_paths.iter().any(|p| p.contains("node_modules")),
301 "Should exclude node_modules"
302 );
303 assert!(
304 !relative_paths.iter().any(|p| p.contains(".git/")),
305 "Should exclude .git"
306 );
307 assert!(
308 !relative_paths.iter().any(|p| p.contains(".vscode")),
309 "Should exclude .vscode"
310 );
311
312 assert!(
314 !relative_paths.contains(&"error.log".to_string()),
315 "Should respect *.log in gitignore"
316 );
317 assert!(
318 !relative_paths.contains(&"temp/cache.bin".to_string()),
319 "Should respect /temp/ in gitignore"
320 );
321 }
322
323 #[test]
324 fn test_scan_whitelist_overrides() {
325 let temp_dir = tempdir().unwrap();
327 let root = temp_dir.path();
328
329 create_test_file(root, "include_me.mp4", b"video content");
331 create_test_file(root, "ignore_me.mp4", b"video content");
332
333 create_test_file(root, ".gitignore", b"*.mp4");
335
336 let config = ScanConfig::new(root, vec!["!include_me.mp4".to_string()]);
339
340 let files = scan_files(&config).expect("Scan failed");
341
342 let relative_paths: Vec<String> = files
344 .iter()
345 .map(|p| {
346 p.strip_prefix(root)
347 .unwrap()
348 .to_string_lossy()
349 .replace('\\', "/")
350 })
351 .collect();
352
353 dbg!(&relative_paths);
354
355 assert!(
356 relative_paths.contains(&"include_me.mp4".to_string()),
357 "Failed to include whitelisted file"
358 );
359
360 assert!(
361 !relative_paths.contains(&"ignore_me.mp4".to_string()),
362 "Should not include other mp4 files"
363 );
364 }
365
366 #[test]
367 fn test_pack_integrity_and_round_trip() {
368 let temp_dir = tempdir().unwrap();
370 let root = temp_dir.path();
371 let output_zip_path = temp_dir.path().join("test_archive.zip");
372
373 let file1_content = "Rust is awesome!";
375 let file2_content = vec![0u8; 1024 * 10]; create_test_file(root, "src/lib.rs", file1_content.as_bytes());
378 create_test_file(root, "assets/data.bin", &file2_content);
379
380 create_test_file(root, "a/b/c/d/deep.txt", b"Deep file");
382
383 let config = ScanConfig::new(root, vec![]);
385 let files = scan_files(&config).unwrap();
386 assert_eq!(files.len(), 3);
387
388 pack_files(
390 &files,
391 &PackConfig {
392 root_path: root.to_path_buf(),
393 output_path: output_zip_path.clone(),
394 compression_method: CompressionMethod::Deflated,
395 compression_level: None,
396 },
397 |_, _, _| {}, )
399 .expect("Packing failed");
400
401 assert!(output_zip_path.exists(), "Zip file was not created");
402
403 let zip_file = File::open(&output_zip_path).unwrap();
405 let mut archive = ZipArchive::new(zip_file).unwrap();
406
407 assert_eq!(archive.len(), 3);
409
410 let mut f1 = archive
412 .by_name("src/lib.rs")
413 .expect("src/lib.rs missing in zip");
414 let mut buffer = String::new();
415 f1.read_to_string(&mut buffer).unwrap();
416 assert_eq!(buffer, file1_content, "Content mismatch for src/lib.rs");
417 drop(f1); let f2 = archive
421 .by_name("assets/data.bin")
422 .expect("assets/data.bin missing");
423 assert_eq!(
424 f2.size(),
425 file2_content.len() as u64,
426 "Size mismatch for binary file"
427 );
428 drop(f2);
429
430 let filenames: Vec<_> = archive.file_names().collect();
433 assert!(
434 filenames.contains(&"a/b/c/d/deep.txt"),
435 "Deep path not preserved or normalized incorrectly"
436 );
437
438 }
443
444 #[test]
445 fn test_manual_exclude_patterns() {
446 let temp_dir = tempdir().unwrap();
448 let root = temp_dir.path();
449
450 create_test_file(root, "src/main.rs", b"code");
453 create_test_file(root, "assets/logo.png", b"image");
454 create_test_file(root, "docs/readme.txt", b"docs");
455
456 create_test_file(root, "assets/demo.mp4", b"heavy video"); create_test_file(root, "secrets/api_key.txt", b"super secret"); create_test_file(root, "secrets/nested/config.yaml", b"nested secret"); create_test_file(root, "backup.log", b"log file"); let excludes = vec![
465 "*.mp4".to_string(), "secrets".to_string(), "*.log".to_string(), ];
469
470 let config = ScanConfig::new(root, excludes);
471
472 let files = scan_files(&config).expect("Scan failed");
474
475 let relative_paths: Vec<String> = files
477 .iter()
478 .map(|p| {
479 p.strip_prefix(root)
480 .unwrap()
481 .to_string_lossy()
482 .replace('\\', "/")
483 })
484 .collect();
485
486 assert!(
488 relative_paths.contains(&"src/main.rs".to_string()),
489 "Standard file should be present"
490 );
491 assert!(
492 relative_paths.contains(&"assets/logo.png".to_string()),
493 "Non-excluded asset should be present"
494 );
495 assert!(
496 relative_paths.contains(&"docs/readme.txt".to_string()),
497 "Docs should be present"
498 );
499
500 assert!(
503 !relative_paths.iter().any(|p| p.ends_with(".mp4")),
504 "Failed to exclude .mp4 files"
505 );
506
507 assert!(
509 !relative_paths.iter().any(|p| p.starts_with("secrets/")),
510 "Failed to exclude secrets directory"
511 );
512
513 assert!(
515 !relative_paths.iter().any(|p| p.ends_with(".log")),
516 "Failed to exclude .log files"
517 );
518 }
519}