• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tamada / totebag / 20479814203

24 Dec 2025 06:16AM UTC coverage: 80.406%. First build
20479814203

push

github

tamada
feat: enhance archive format detection and improve documentation for archivers and extractors

56 of 65 new or added lines in 4 files covered. (86.15%)

1625 of 2021 relevant lines covered (80.41%)

7.92 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.56
/cli/src/cli.rs
1
use clap::{Parser, ValueEnum};
2
use totebag::format::default_format_detector;
3
use std::{io::BufRead, path::PathBuf};
4

5
use totebag::{ArchiveConfig, ExtractConfig, ListConfig};
6
use totebag::{IgnoreType, OutputFormat, Result, ToteError};
7

8
pub(crate) enum Mode {
9
    Archive(ArchiveConfig),
10
    Extract(ExtractConfig),
11
    List(ListConfig),
12
}
13

14
impl Mode {
15
    #[cfg(debug_assertions)]
16
    #[allow(unused)]
17
    pub(crate) fn mode(&self) -> String {
4✔
18
        match self {
4✔
19
            Self::Archive(_) => "archive",
2✔
20
            Self::Extract(_) => "extract",
1✔
21
            Self::List(_) => "list",
1✔
22
        }
23
        .to_string()
4✔
24
    }
4✔
25
}
26

27
#[derive(Debug, Clone, ValueEnum, PartialEq, Copy)]
28
pub(crate) enum RunMode {
29
    Auto,
30
    Archive,
31
    Extract,
32
    List,
33
}
34

35
#[derive(Parser, Debug)]
36
#[clap(
37
    bin_name = "totebag",
38
    version,
39
    author,
40
    about,
41
    arg_required_else_help = true
42
)]
43
pub(crate) struct CliOpts {
44
    #[clap(flatten)]
45
    pub extractors: ExtractorOpts,
46

47
    #[clap(flatten)]
48
    pub archivers: ArchiverOpts,
49

50
    #[clap(flatten)]
51
    pub listers: ListerOpts,
52

53
    #[clap(long = "log", help = "Specify the log level", default_value_t = LogLevel::Warn, ignore_case = true, value_enum)]
54
    pub loglevel: LogLevel,
55

56
    #[clap(short = 'm', long = "mode", default_value_t = RunMode::Auto, value_name = "MODE", required = false, ignore_case = true, value_enum, help = "Mode of operation.")]
57
    pub mode: RunMode,
58

59
    #[clap(short = 'F', long, value_name = "ARCHIVE_FORMAT", value_enum, ignore_case = true,
60
        help = "Specify the archive format for listing mode (default auto). available on list and extract modes.")]
61
    pub from: Option<ArchiveFormat>,
62

63
    #[cfg(debug_assertions)]
64
    #[clap(
65
        long = "generate-completion",
66
        hide = true,
67
        help = "Generate the completion files"
68
    )]
69
    pub generate_completion: bool,
70

71
    #[clap(
72
        short = 'o',
73
        short_alias = 'd',
74
        long = "output",
75
        alias = "dest",
76
        value_name = "DEST",
77
        required = false,
78
        help = "Output file in archive mode, or output directory in extraction mode"
79
    )]
80
    pub output: Option<PathBuf>,
81

82
    #[clap(long, help = "Overwrite existing files.")]
83
    pub overwrite: bool,
84

85
    #[clap(
86
        value_name = "ARGUMENTS",
87
        help = r###"List of files or directories to be processed.
88
'-' reads form stdin, and '@<filename>' reads from a file.
89
In archive mode, the resultant archive file name is determined by the following rule.
90
    - if output option is specified, use it.
91
    - if the first argument is the archive file name, use it.
92
    - otherwise, use the default name 'totebag.zip'.
93
The format is determined by the extension of the resultant file name."###
94
    )]
95
    pub args: Vec<String>,
96
}
97

98
#[derive(Parser, Debug)]
99
pub struct ListerOpts {
100
    #[clap(
101
        short = 'f', long, value_name = "FORMAT", value_enum, ignore_case = true,
102
        default_value_t = OutputFormat::Default,
103
        help = "Specify the format for listing entries in the archive file."
104
    )]
105
    pub output_format: OutputFormat,
106
}
107

108
#[derive(Parser, Debug)]
109
pub struct ArchiverOpts {
110
    #[clap(
111
        short = 'C',
112
        long = "dir",
113
        value_name = "DIR",
114
        required = false,
115
        default_value = ".",
116
        help = "Specify the base directory for archiving or extracting."
117
    )]
118
    pub base_dir: PathBuf,
119

120
    #[clap(
121
        short = 'i',
122
        long = "ignore-types",
123
        value_name = "IGNORE_TYPES",
124
        value_delimiter = ',',
125
        help = "Specify the ignore type."
126
    )]
127
    pub ignores: Vec<IgnoreType>,
128

129
    #[clap(short = 'L', long = "level", default_value_t = 5, help = r#"Specify the compression level. [default: 5] [possible values: 0-9 (none to finest)]
130
For more details of level of each compression method, see README."#, value_parser=compression_level)]
131
    pub level: u8,
132

133
    #[clap(
134
        short = 'n',
135
        long = "no-recursive",
136
        help = "No recursive directory (archive mode).",
137
        default_value_t = false
138
    )]
139
    pub no_recursive: bool,
140
}
141

142
#[derive(Parser, Debug)]
143
pub struct ExtractorOpts {
144
    #[clap(
145
        long = "to-archive-name-dir",
146
        help = "extract files to DEST/ARCHIVE_NAME directory (extract mode).",
147
        default_value_t = false
148
    )]
149
    pub to_archive_name_dir: bool,
150
}
151

152
#[derive(Parser, Debug, ValueEnum, Clone, PartialEq, Copy)]
153
pub enum ArchiveFormat {
154
    /// Detect the format by the file extension.
155
    Auto,
156
    /// Detect the format by the file signature (header bytes).
157
    Parse,
158
    Cab, Lha, Lzh, SevenZ, Rar, Tar, TarGz, TarBz2, TarXz, TarZstd, Zip,
159
    Tgz, Tbz2, Txz, Tzst, Tzstd, Jar, War, Ear,
160
}
161

162
/// The log level.
163
#[derive(Parser, Debug, ValueEnum, Clone, PartialEq, Copy)]
164
pub enum LogLevel {
165
    /// The error level.
166
    Error,
167
    /// The warning level.
168
    Warn,
169
    /// The info level.
170
    Info,
171
    /// The debug level.
172
    Debug,
173
    /// The trace level.
174
    Trace,
175
}
176

177
fn compression_level(s: &str) -> core::result::Result<u8, String> {
10✔
178
    clap_num::number_range(s, 0, 9)
10✔
179
}
10✔
180

181
#[derive(Parser, Debug)]
182
struct ActualArgs {
183
    args: Vec<String>,
184
}
185

186
impl ActualArgs {}
187

188
impl CliOpts {
189
    pub(crate) fn find_mode(&self) -> Result<(Mode, Vec<String>)> {
8✔
190
        let args = normalize_args(self.args.clone())?;
8✔
191
        if args.is_empty() {
8✔
192
            Err(ToteError::NoArgumentsGiven)
×
193
        } else {
194
            match self.mode {
8✔
195
                RunMode::Auto => {
196
                    let fd = default_format_detector();
6✔
197
                    if totebag::format::is_all_archive_file(&args, &fd) {
6✔
198
                        to_extract_config(self, args)
2✔
199
                    } else {
200
                        to_archive_config(self, args)
4✔
201
                    }
202
                }
203
                RunMode::Archive => to_archive_config(self, args),
×
204
                RunMode::Extract => to_extract_config(self, args),
×
205
                RunMode::List => to_list_config(self, args),
2✔
206
            }
207
        }
208
    }
8✔
209

210
    fn format_detector(&self) -> Result<Box<dyn totebag::format::FormatDetector>> {
4✔
211
        use totebag::format::{default_format_detector, fixed_format_detector, magic_number_format_detector};
212
        match self.from {
×
213
            Some(ArchiveFormat::Auto) | None => {
214
                Ok(default_format_detector())
4✔
215
            }
NEW
216
            Some(ArchiveFormat::Parse) => Ok(magic_number_format_detector()),
×
217
            Some(f) => {
×
218
                let name = format!("{f:?}");
×
NEW
219
                let format = totebag::format::find_format_by_name(name).ok_or_else(|| {
×
220
                    ToteError::UnsupportedFormat(format!(
×
221
                        "The specified archive format '{f:?}' is not supported."
×
222
                    ))
×
223
                })?;
×
NEW
224
                Ok(fixed_format_detector(format))
×
225
            }
226
        }
227
    }
4✔
228
}
229

230

231
fn to_archive_config(opts: &CliOpts, args: Vec<String>) -> Result<(Mode, Vec<String>)> {
4✔
232
    let fd = default_format_detector();
4✔
233
    let (dest, args) = if fd.detect(&PathBuf::from(&args[0])).is_some() && opts.output.is_none() {
4✔
234
        (Some(args[0].clone().into()), args[1..].to_vec())
1✔
235
    } else {
236
        (None, args)
3✔
237
    };
238
    let config = totebag::ArchiveConfig::builder()
4✔
239
        .dest(dest.unwrap_or_else(|| PathBuf::from("totebag.zip")))
4✔
240
        .level(opts.archivers.level)
4✔
241
        .rebase_dir(opts.archivers.base_dir.clone())
4✔
242
        .overwrite(opts.overwrite)
4✔
243
        .no_recursive(opts.archivers.no_recursive)
4✔
244
        .ignore(opts.archivers.ignores.clone())
4✔
245
        .build();
4✔
246
    Ok((Mode::Archive(config), args))
4✔
247
}
4✔
248

249
fn to_extract_config(opts: &CliOpts, args: Vec<String>) -> Result<(Mode, Vec<String>)> {
2✔
250
    let dest = opts.output.clone().unwrap_or_else(|| PathBuf::from("."));
2✔
251
    let config = totebag::ExtractConfig::builder()
2✔
252
        .overwrite(opts.overwrite)
2✔
253
        .use_archive_name_dir(opts.extractors.to_archive_name_dir)
2✔
254
        .dest(dest)
2✔
255
        .format_detector(opts.format_detector()?)
2✔
256
        .build();
2✔
257
    Ok((Mode::Extract(config), args))
2✔
258
}
2✔
259

260
fn to_list_config(opts: &CliOpts, args: Vec<String>) -> Result<(Mode, Vec<String>)> {
2✔
261
    let config = totebag::ListConfig::new(
2✔
262
        opts.listers.output_format.clone(),
2✔
263
        opts.format_detector()?,
2✔
264
    );
265
    Ok((Mode::List(config), args))
2✔
266
}
2✔
267

268
pub(crate) fn normalize_args(args: Vec<String>) -> Result<Vec<String>> {
8✔
269
    let results = args
8✔
270
        .iter()
8✔
271
        .map(reads_file_or_stdin_if_needed)
8✔
272
        .collect::<Vec<Result<Vec<String>>>>();
8✔
273
    if results.iter().any(|r| r.is_err()) {
20✔
274
        let errs = results
×
275
            .into_iter()
×
276
            .filter(|r| r.is_err())
×
277
            .flat_map(|r| r.err())
×
278
            .collect::<Vec<ToteError>>();
×
279
        Err(ToteError::Array(errs))
×
280
    } else {
281
        let results = results
8✔
282
            .into_iter()
8✔
283
            .filter(|r| r.is_ok())
20✔
284
            .flat_map(|r| r.unwrap())
20✔
285
            .collect::<Vec<String>>();
8✔
286
        Ok(results)
8✔
287
    }
288
}
8✔
289

290
fn reads_file_or_stdin_if_needed<S: AsRef<str>>(s: S) -> Result<Vec<String>> {
20✔
291
    let s = s.as_ref();
20✔
292
    if s == "-" {
20✔
293
        reads_from_reader(std::io::stdin())
×
294
    } else if let Some(stripped_str) = s.strip_prefix('@') {
20✔
295
        reads_from_file(stripped_str)
3✔
296
    } else {
297
        Ok(vec![s.to_string()])
17✔
298
    }
299
}
20✔
300

301
fn reads_from_file<S: AsRef<str>>(s: S) -> Result<Vec<String>> {
3✔
302
    let path = PathBuf::from(s.as_ref());
3✔
303
    if !path.exists() {
3✔
304
        Err(ToteError::FileNotFound(path))
×
305
    } else {
306
        match std::fs::File::open(path) {
3✔
307
            Ok(f) => reads_from_reader(f),
3✔
308
            Err(e) => Err(ToteError::IO(e)),
×
309
        }
310
    }
311
}
3✔
312

313
fn reads_from_reader<R: std::io::Read>(r: R) -> Result<Vec<String>> {
3✔
314
    let results = std::io::BufReader::new(r)
3✔
315
        .lines()
3✔
316
        .map_while(|r| r.ok())
23✔
317
        .map(|line| line.trim().to_string())
23✔
318
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
23✔
319
        .collect::<Vec<String>>();
3✔
320
    Ok(results)
3✔
321
}
3✔
322

323
#[cfg(test)]
324
mod tests {
325
    use super::*;
326
    use clap::Parser;
327

328
    #[test]
329
    fn test_read_from_file1() {
1✔
330
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/archive_mode1.txt"]);
1✔
331
        let (mode, args) = cli.find_mode().unwrap();
1✔
332
        match mode {
1✔
333
            Mode::List(_) | Mode::Extract(_) => panic!("invalid mode"),
×
334
            Mode::Archive(config) => assert_eq!(
1✔
335
                config.dest_file().unwrap(),
1✔
336
                PathBuf::from("testdata/targets.tar.gz")
1✔
337
            ),
338
        }
339
        assert_eq!(
1✔
340
            args,
341
            vec!["src", "README.md", "LICENSE", "Cargo.toml", "Makefile.toml"]
1✔
342
        );
343
    }
1✔
344

345
    #[test]
346
    fn test_read_from_file2() {
1✔
347
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/archive_mode2.txt"]);
1✔
348
        let (mode, args) = cli.find_mode().unwrap();
1✔
349
        match mode {
1✔
350
            Mode::List(_) | Mode::Extract(_) => panic!("invalid mode"),
×
351
            Mode::Archive(config) => {
1✔
352
                assert_eq!(config.dest_file().unwrap(), PathBuf::from("totebag.zip"))
1✔
353
            }
354
        }
355
        assert_eq!(
1✔
356
            args,
357
            vec!["src", "README.md", "LICENSE", "Cargo.toml", "Makefile.toml"]
1✔
358
        );
359
    }
1✔
360

361
    #[test]
362
    fn test_read_from_file3() {
1✔
363
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/extract_mode.txt"]);
1✔
364
        let (mode, args) = cli.find_mode().unwrap();
1✔
365
        match mode {
1✔
366
            Mode::List(_) | Mode::Archive(_) => panic!("invalid mode"),
×
367
            Mode::Extract(config) => assert_eq!(config.dest, PathBuf::from(".")),
1✔
368
        }
369
        assert_eq!(args, vec!["testdata/test.cab", "testdata/test.tar"]);
1✔
370
    }
1✔
371

372
    #[test]
373
    fn test_find_mode_1() {
1✔
374
        let cli1 =
1✔
375
            CliOpts::parse_from(&["totebag_test", "src", "LICENSE", "README.md", "Cargo.toml"]);
1✔
376
        let (mode, args) = cli1.find_mode().unwrap();
1✔
377
        assert_eq!(mode.mode(), "archive");
1✔
378
        assert_eq!(args, vec!["src", "LICENSE", "README.md", "Cargo.toml"]);
1✔
379
    }
1✔
380

381
    #[test]
382
    fn test_find_mode_2() {
1✔
383
        let cli2 =
1✔
384
            CliOpts::parse_from(&["totebag_test", "src", "LICENSE", "README.md", "hoge.zip"]);
1✔
385
        let (mode, args) = cli2.find_mode().unwrap();
1✔
386
        assert_eq!(mode.mode(), "archive");
1✔
387
        assert_eq!(args, vec!["src", "LICENSE", "README.md", "hoge.zip"]);
1✔
388
    }
1✔
389

390
    #[test]
391
    fn test_find_mode_3() {
1✔
392
        let cli3 = CliOpts::parse_from(&[
1✔
393
            "totebag_test",
1✔
394
            "src.zip",
1✔
395
            "LICENSE.tar",
1✔
396
            "README.tar.bz2",
1✔
397
            "hoge.rar",
1✔
398
        ]);
1✔
399
        let (mode, args) = cli3.find_mode().unwrap();
1✔
400
        assert_eq!(mode.mode(), "extract");
1✔
401
        assert_eq!(
1✔
402
            args,
403
            vec!["src.zip", "LICENSE.tar", "README.tar.bz2", "hoge.rar"]
1✔
404
        );
405
    }
1✔
406

407
    #[test]
408
    fn test_find_mode_4() {
1✔
409
        let cli4 = CliOpts::parse_from(&[
1✔
410
            "totebag_test",
1✔
411
            "src.zip",
1✔
412
            "LICENSE.tar",
1✔
413
            "README.tar.bz2",
1✔
414
            "hoge.rar",
1✔
415
            "--mode",
1✔
416
            "list",
1✔
417
        ]);
1✔
418
        let (mode, args) = cli4.find_mode().unwrap();
1✔
419
        assert_eq!(mode.mode(), "list");
1✔
420
        assert_eq!(
1✔
421
            args,
422
            vec!["src.zip", "LICENSE.tar", "README.tar.bz2", "hoge.rar"]
1✔
423
        );
424
    }
1✔
425

426
    #[test]
427
    fn test_cli_parse_error() {
1✔
428
        let r = CliOpts::try_parse_from(&["totebag_test"]);
1✔
429
        assert!(r.is_err());
1✔
430
    }
1✔
431
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc