cmprss 0.3.0

A compression multi-tool for the command line.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
use clap::Args;
use std::ffi::OsStr;
use std::fmt;
use std::io;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;

pub type Result<T = ()> = anyhow::Result<T>;

/// Enum to represent whether a compressor extracts to a file or directory by default
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractedTarget {
    /// Extract to a single file (e.g., gzip, bzip2, xz)
    FILE,
    /// Extract to a directory (e.g., zip, tar)
    DIRECTORY,
}

#[derive(Args, Debug)]
pub struct CommonArgs {
    /// Input file/directory
    #[arg(short, long)]
    pub input: Option<String>,

    /// Output file/directory
    #[arg(short, long)]
    pub output: Option<String>,

    /// Compress the input (default)
    #[arg(short, long)]
    pub compress: bool,

    /// Extract the input
    #[arg(short, long)]
    pub extract: bool,

    /// Decompress the input. Alias of --extract
    #[arg(short, long)]
    pub decompress: bool,

    /// List of I/O.
    /// This consists of all the inputs followed by the single output, with intelligent fallback to stdin/stdout.
    #[arg()]
    pub io_list: Vec<String>,

    /// Ignore pipes when inferring I/O
    #[arg(long)]
    pub ignore_pipes: bool,

    /// Ignore stdin when inferring I/O
    #[arg(long)]
    pub ignore_stdin: bool,

    /// Ignore stdout when inferring I/O
    #[arg(long)]
    pub ignore_stdout: bool,
}

/// Trait for validating compression levels for different compressors
#[allow(dead_code)]
pub trait CompressionLevelValidator {
    /// Get the minimum valid compression level
    fn min_level(&self) -> i32;

    /// Get the maximum valid compression level
    fn max_level(&self) -> i32;

    /// Get the default compression level
    fn default_level(&self) -> i32;

    /// Map special names to compression levels
    fn name_to_level(&self, name: &str) -> Option<i32>;

    /// Validate if a compression level is within the valid range
    fn is_valid_level(&self, level: i32) -> bool {
        level >= self.min_level() && level <= self.max_level()
    }

    /// Validate and clamp a compression level to the valid range
    fn validate_and_clamp_level(&self, level: i32) -> i32 {
        if level < self.min_level() {
            self.min_level()
        } else if level > self.max_level() {
            self.max_level()
        } else {
            level
        }
    }
}

/// Default implementation for most compressors (0-9 range)
#[derive(Debug, Clone, Copy)]
pub struct DefaultCompressionValidator;

impl CompressionLevelValidator for DefaultCompressionValidator {
    fn min_level(&self) -> i32 {
        0
    }
    fn max_level(&self) -> i32 {
        9
    }
    fn default_level(&self) -> i32 {
        6
    }

    fn name_to_level(&self, name: &str) -> Option<i32> {
        match name.to_lowercase().as_str() {
            "none" => Some(0),
            "fast" => Some(1),
            "best" => Some(9),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct CompressionLevel {
    pub level: i32,
}

impl Default for CompressionLevel {
    fn default() -> Self {
        CompressionLevel { level: 6 }
    }
}

impl FromStr for CompressionLevel {
    type Err = &'static str;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        // Check for an int
        if let Ok(level) = s.parse::<i32>() {
            return Ok(CompressionLevel { level });
        }

        // Try to parse special names
        let s = s.to_lowercase();
        match s.as_str() {
            "none" | "fast" | "best" => Ok(CompressionLevel {
                // We'll use the DefaultCompressionValidator values here
                // The actual compressor will interpret these values according to its own validator
                level: DefaultCompressionValidator.name_to_level(&s).unwrap(),
            }),
            _ => Err("Invalid compression level"),
        }
    }
}

#[derive(Args, Debug, Default, Clone, Copy)]
pub struct LevelArgs {
    /// Level of compression.
    /// `none`, `fast`, and `best` are mapped to appropriate values for each compressor.
    #[arg(long, default_value = "fast")]
    pub level: CompressionLevel,
}

/// Common interface for all compressor implementations
#[allow(unused_variables)]
pub trait Compressor: Send + Sync {
    /// Name of this Compressor
    fn name(&self) -> &str;

    /// Default extension for this Compressor
    fn extension(&self) -> &str {
        self.name()
    }

    /// Determine if this compressor extracts to a file or directory by default
    /// FILE compressors (like gzip, bzip2, xz) extract to a single file
    /// DIRECTORY compressors (like zip, tar) extract to a directory
    fn default_extracted_target(&self) -> ExtractedTarget {
        ExtractedTarget::FILE
    }

    /// Detect if the input is an archive of this type
    /// Just checks the extension by default
    /// Some compressors may overwrite this to do more advanced detection
    fn is_archive(&self, in_path: &Path) -> bool {
        if in_path.extension().is_none() {
            return false;
        }
        in_path.extension().unwrap() == self.extension()
    }

    /// Generate the default name for the compressed file
    fn default_compressed_filename(&self, in_path: &Path) -> String {
        format!(
            "{}.{}",
            in_path
                .file_name()
                .unwrap_or_else(|| OsStr::new("archive"))
                .to_str()
                .unwrap(),
            self.extension()
        )
    }

    /// Generate the default extracted filename
    fn default_extracted_filename(&self, in_path: &Path) -> String {
        if self.default_extracted_target() == ExtractedTarget::DIRECTORY {
            return ".".to_string();
        }

        // If the file has no extension, return the current directory
        if let Some(ext) = in_path.extension() {
            // If the file has the extension for this type, return the filename without the extension
            if let Some(ext_str) = ext.to_str()
                && ext_str == self.extension()
                && let Some(stem) = in_path.file_stem()
                && let Some(stem_str) = stem.to_str()
            {
                return stem_str.to_string();
            }
        }
        "archive".to_string()
    }

    fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result;

    fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result;
}

impl fmt::Debug for dyn Compressor {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Compressor {{ name: {} }}", self.name())
    }
}

/// Wrapper for Read + Send to allow Debug
pub struct ReadWrapper(pub Box<dyn Read + Send>);

impl Read for ReadWrapper {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        self.0.read(buf)
    }
}

impl fmt::Debug for ReadWrapper {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "ReadWrapper")
    }
}

/// Wrapper for Write + Send to allow Debug
pub struct WriteWrapper(pub Box<dyn Write + Send>);

impl Write for WriteWrapper {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        self.0.write(buf)
    }

    fn flush(&mut self) -> io::Result<()> {
        self.0.flush()
    }
}

impl fmt::Debug for WriteWrapper {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "WriteWrapper")
    }
}

/// Defines the possible inputs of a compressor
#[derive(Debug)]
pub enum CmprssInput {
    /// Path(s) to the input files.
    Path(Vec<PathBuf>),
    /// Input pipe
    Pipe(std::io::Stdin),
    /// In-memory reader (for piping between compressors)
    Reader(ReadWrapper),
}

/// Defines the possible outputs of a compressor
#[derive(Debug)]
pub enum CmprssOutput {
    Path(PathBuf),
    Pipe(std::io::Stdout),
    /// In-memory writer (for piping between compressors)
    Writer(WriteWrapper),
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    /// A simple implementation of the Compressor trait for testing
    struct TestCompressor;

    impl Compressor for TestCompressor {
        fn name(&self) -> &str {
            "test"
        }

        // We'll use the default implementation for extension() and other methods

        fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
            Ok(())
        }

        fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
            Ok(())
        }
    }

    /// A compressor that overrides the default extension
    struct CustomExtensionCompressor;

    impl Compressor for CustomExtensionCompressor {
        fn name(&self) -> &str {
            "custom"
        }

        fn extension(&self) -> &str {
            "cst"
        }

        fn compress(&self, _: CmprssInput, _: CmprssOutput) -> Result {
            Ok(())
        }

        fn extract(&self, _: CmprssInput, _: CmprssOutput) -> Result {
            Ok(())
        }
    }

    #[test]
    fn test_default_name_extension() {
        let compressor = TestCompressor;
        assert_eq!(compressor.name(), "test");
        assert_eq!(compressor.extension(), "test");
    }

    #[test]
    fn test_custom_extension() {
        let compressor = CustomExtensionCompressor;
        assert_eq!(compressor.name(), "custom");
        assert_eq!(compressor.extension(), "cst");
    }

    #[test]
    fn test_is_archive_detection() {
        use tempfile::tempdir;

        let compressor = TestCompressor;
        let temp_dir = tempdir().expect("Failed to create temp dir");

        // Test with matching extension
        let archive_path = temp_dir.path().join("archive.test");
        std::fs::File::create(&archive_path).expect("Failed to create test file");
        assert!(compressor.is_archive(&archive_path));

        // Test with non-matching extension
        let non_archive_path = temp_dir.path().join("archive.txt");
        std::fs::File::create(&non_archive_path).expect("Failed to create test file");
        assert!(!compressor.is_archive(&non_archive_path));

        // Test with no extension
        let no_ext_path = temp_dir.path().join("archive");
        std::fs::File::create(&no_ext_path).expect("Failed to create test file");
        assert!(!compressor.is_archive(&no_ext_path));
    }

    #[test]
    fn test_default_compressed_filename() {
        let compressor = TestCompressor;

        // Test with normal filename
        let path = Path::new("file.txt");
        assert_eq!(
            compressor.default_compressed_filename(path),
            "file.txt.test"
        );

        // Test with no extension
        let path = Path::new("file");
        assert_eq!(compressor.default_compressed_filename(path), "file.test");
    }

    #[test]
    fn test_default_extracted_filename() {
        let compressor = TestCompressor;

        // Test with matching extension
        let path = Path::new("archive.test");
        assert_eq!(compressor.default_extracted_filename(path), "archive");

        // Test with non-matching extension
        let path = Path::new("archive.txt");
        assert_eq!(compressor.default_extracted_filename(path), "archive");

        // Test with no extension
        let path = Path::new("archive");
        assert_eq!(compressor.default_extracted_filename(path), "archive");
    }

    #[test]
    fn test_compression_level_parsing() {
        // Test numeric levels
        assert_eq!(CompressionLevel::from_str("1").unwrap().level, 1);
        assert_eq!(CompressionLevel::from_str("9").unwrap().level, 9);

        // Test named levels
        let validator = DefaultCompressionValidator;
        assert_eq!(
            CompressionLevel::from_str("fast").unwrap().level,
            validator.name_to_level("fast").unwrap()
        );
        assert_eq!(
            CompressionLevel::from_str("best").unwrap().level,
            validator.name_to_level("best").unwrap()
        );

        // Test invalid values
        assert!(CompressionLevel::from_str("invalid").is_err());
    }

    #[test]
    fn test_compression_level_defaults() {
        let default_level = CompressionLevel::default();
        let validator = DefaultCompressionValidator;
        assert_eq!(default_level.level, validator.default_level());
    }

    #[test]
    fn test_default_compression_validator() {
        let validator = DefaultCompressionValidator;

        use crate::test_utils::test_compression_validator_helper;
        test_compression_validator_helper(
            &validator,
            0,       // min_level
            9,       // max_level
            6,       // default_level
            Some(1), // fast_name_level
            Some(9), // best_name_level
            Some(0), // none_name_level
        );
    }
}