an efficient binary archive format
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}