an efficient binary archive format
at main 307 lines 8.6 kB view raw
1use clap::{Parser, Subcommand}; 2use std::io::{self}; 3use std::path::PathBuf; 4use std::process; 5 6use bindle_file::{Bindle, Compress}; 7 8#[derive(Parser)] 9#[command(name = "bindle")] 10#[command(version = "1.0")] 11#[command(author = "zshipko")] 12#[command(about = "Append-only file collection")] 13struct Cli { 14 #[command(subcommand)] 15 command: Commands, 16} 17 18#[derive(Subcommand)] 19enum Commands { 20 /// List all entries in the archive 21 List { 22 /// Bindle archive file 23 #[arg(value_name = "BINDLE_FILE")] 24 bindle_file: PathBuf, 25 }, 26 27 /// Add a file to the archive 28 Add { 29 /// Bindle archive file 30 #[arg(value_name = "BINDLE_FILE")] 31 bindle_file: PathBuf, 32 33 /// Name of the entry inside the archive 34 name: String, 35 /// Path to the local file to read from (reads from stdin if omitted) 36 file_path: Option<PathBuf>, 37 /// Use zstd compression 38 #[arg(short, long)] 39 compress: bool, 40 /// Pass data directly as an argument 41 #[arg(short, long, conflicts_with = "file_path")] 42 data: Option<String>, 43 /// Run vacuum after adding 44 #[arg(long)] 45 vacuum: bool, 46 }, 47 48 #[command(visible_alias = "cat")] 49 /// Extract an entry's data 50 Read { 51 /// Bindle archive file 52 #[arg(value_name = "BINDLE_FILE")] 53 bindle_file: PathBuf, 54 /// Name of the entry to extract 55 name: String, 56 /// Output path 57 #[arg(short, long)] 58 output: Option<PathBuf>, 59 }, 60 61 /// Remove an entry from the archive 62 Remove { 63 /// Bindle archive file 64 #[arg(value_name = "BINDLE_FILE")] 65 bindle_file: PathBuf, 66 /// Name of the entry to remove 67 name: String, 68 /// Run vacuum after removing 69 #[arg(long)] 70 vacuum: bool, 71 }, 72 73 /// Pack an entire directory into the archive 74 Pack { 75 /// Bindle archive file 76 #[arg(value_name = "BINDLE_FILE")] 77 bindle_file: PathBuf, 78 /// Local directory to pack 79 #[arg(value_name = "SRC_DIR")] 80 src_dir: PathBuf, 81 /// Use zstd compression 82 #[arg(short, long)] 83 compress: bool, 84 /// Append to existing file 85 #[arg(short, long)] 86 append: bool, 87 /// Run vacuum after packing 88 #[arg(long)] 89 vacuum: bool, 90 }, 91 92 /// Unpack the archive to a local directory 93 Unpack { 94 /// Bindle archive file 95 #[arg(value_name = "BINDLE_FILE")] 96 bindle_file: PathBuf, 97 /// Destination directory 98 #[arg(value_name = "DEST_DIR")] 99 dest_dir: PathBuf, 100 }, 101 102 /// Reclaim space by removing shadowed/deleted data 103 Vacuum { 104 /// Bindle archive file 105 #[arg(value_name = "BINDLE_FILE")] 106 bindle_file: PathBuf, 107 }, 108} 109 110fn main() { 111 let cli = Cli::parse(); 112 113 if let Err(e) = handle_command(cli.command) { 114 eprintln!("ERROR {}", e); 115 process::exit(1); 116 } 117} 118 119fn handle_command(command: Commands) -> io::Result<()> { 120 let init = |path: PathBuf| match Bindle::open(&path) { 121 Ok(bindle) => bindle, 122 Err(e) => { 123 eprintln!("ERROR unable to open {}: {}", path.display(), e); 124 process::exit(1); 125 } 126 }; 127 128 let init_load = |path: PathBuf| match Bindle::load(&path) { 129 Ok(bindle) => bindle, 130 Err(e) => { 131 eprintln!("ERROR unable to open {}: {}", path.display(), e); 132 process::exit(1); 133 } 134 }; 135 136 match command { 137 Commands::List { bindle_file } => { 138 println!( 139 "{:<30} {:<12} {:<12} {:<10}", 140 "NAME", "SIZE", "PACKED", "RATIO" 141 ); 142 println!("{}", "-".repeat(70)); 143 if !bindle_file.exists() { 144 return Ok(()); 145 } 146 let b = init_load(bindle_file); 147 148 for (name, entry) in b.index().iter() { 149 let size = entry.uncompressed_size(); 150 let packed = entry.compressed_size(); 151 152 let ratio = if size > 0 { 153 (packed as f64 / size as f64) * 100.0 154 } else { 155 100.0 156 }; 157 158 println!("{:<30} {:<12} {:<12} {:.1}%", name, size, packed, ratio); 159 } 160 } 161 162 Commands::Add { 163 name, 164 file_path, 165 data: data_arg, 166 compress, 167 bindle_file, 168 vacuum, 169 } => { 170 let mut b = init(bindle_file.clone()); 171 let compress_mode = if compress { 172 Compress::Zstd 173 } else { 174 Compress::None 175 }; 176 177 // Determine data source and method: --data flag, file path, or stdin 178 let size = if let Some(d) = data_arg { 179 // Direct data from argument 180 let bytes = d.into_bytes(); 181 let len = bytes.len(); 182 b.add(&name, &bytes, compress_mode)?; 183 len 184 } else if let Some(path) = file_path { 185 // Use add_file to avoid loading entire file into memory 186 b.add_file(&name, &path, compress_mode)?; 187 std::fs::metadata(&path)?.len() as usize 188 } else { 189 // Stream from stdin using writer 190 let mut writer = b.writer(&name, compress_mode)?; 191 let size = io::copy(&mut io::stdin(), &mut writer)?; 192 writer.close()?; 193 size as usize 194 }; 195 196 println!( 197 "ADD '{}' -> {} ({} bytes)", 198 name, 199 bindle_file.display(), 200 size 201 ); 202 b.save()?; 203 204 if vacuum { 205 println!("VACUUM {}", bindle_file.display()); 206 b.vacuum()?; 207 } 208 209 println!("OK"); 210 } 211 212 Commands::Read { 213 name, 214 bindle_file, 215 output, 216 } => { 217 let b = init_load(bindle_file.clone()); 218 let res = if let Some(output) = &output { 219 b.read_to(name.as_str(), std::fs::File::create(output)?) 220 } else { 221 b.read_to(name.as_str(), io::stdout()) 222 }; 223 match res { 224 Ok(_n) => { 225 if output.is_some() { 226 println!("OK") 227 } 228 } 229 Err(e) => { 230 return Err(io::Error::new(io::ErrorKind::NotFound, e)); 231 } 232 } 233 } 234 235 Commands::Remove { 236 name, 237 bindle_file, 238 vacuum, 239 } => { 240 let mut b = init(bindle_file.clone()); 241 if b.remove(&name) { 242 println!("REMOVE '{}' from {}", name, bindle_file.display()); 243 b.save()?; 244 245 if vacuum { 246 println!("VACUUM {}", bindle_file.display()); 247 b.vacuum()?; 248 } 249 250 println!("OK"); 251 } else { 252 return Err(io::Error::new( 253 io::ErrorKind::NotFound, 254 format!("ERROR '{}' not found in {}", name, bindle_file.display()), 255 )); 256 } 257 } 258 259 Commands::Pack { 260 bindle_file, 261 src_dir, 262 compress, 263 append, 264 vacuum, 265 } => { 266 println!("PACK {} -> {}", src_dir.display(), bindle_file.display()); 267 let mut b = init(bindle_file.clone()); 268 if !append { 269 b.clear(); 270 } 271 b.pack( 272 src_dir, 273 if compress { 274 Compress::Zstd 275 } else { 276 Compress::None 277 }, 278 )?; 279 b.save()?; 280 281 if vacuum { 282 println!("VACUUM {}", bindle_file.display()); 283 b.vacuum()?; 284 } 285 286 println!("OK"); 287 } 288 289 Commands::Unpack { 290 bindle_file, 291 dest_dir, 292 } => { 293 println!("UNPACK {} -> {}", bindle_file.display(), dest_dir.display()); 294 let b = init_load(bindle_file); 295 b.unpack(dest_dir)?; 296 println!("OK"); 297 } 298 299 Commands::Vacuum { bindle_file } => { 300 println!("VACUUM {}", bindle_file.display()); 301 let mut b = init_load(bindle_file); 302 b.vacuum()?; 303 println!("OK"); 304 } 305 } 306 Ok(()) 307}