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

tamada / totebag / 20120427115

11 Dec 2025 02:58AM UTC coverage: 80.274% (+0.3%) from 79.959%
20120427115

push

github

web-flow
Merge pull request #54 from tamada/release/v0.8.0

Release/v0.8.0

705 of 960 new or added lines in 20 files covered. (73.44%)

1526 of 1901 relevant lines covered (80.27%)

7.6 hits per line

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

91.18
/cli/src/cli.rs
1
use clap::{Parser, ValueEnum};
2
use std::{io::BufRead, path::PathBuf};
3

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

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

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

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

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

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

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

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

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

58
    #[cfg(debug_assertions)]
59
    #[clap(
60
        long = "generate-completion",
61
        hide = true,
62
        help = "Generate the completion files"
63
    )]
64
    pub generate_completion: bool,
65

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

77
    #[clap(long, help = "Overwrite existing files.")]
78
    pub overwrite: bool,
79

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

93
#[derive(Parser, Debug)]
94
pub struct ListerOpts {
95
    #[clap(
96
        short, long, value_name = "FORMAT", value_enum, ignore_case = true,
97
        default_value_t = OutputFormat::Default,
98
        help = "Specify the format for listing entries in the archive file."
99
    )]
100
    pub format: OutputFormat,
101
}
102

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

115
    #[clap(
116
        short = 'i',
117
        long = "ignore-types",
118
        value_name = "IGNORE_TYPES",
119
        value_delimiter = ',',
120
        help = "Specify the ignore type."
121
    )]
122
    pub ignores: Vec<IgnoreType>,
123

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

128
    #[clap(
129
        short = 'n',
130
        long = "no-recursive",
131
        help = "No recursive directory (archive mode).",
132
        default_value_t = false
133
    )]
134
    pub no_recursive: bool,
135
}
136

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

147
/// The log level.
148
#[derive(Parser, Debug, ValueEnum, Clone, PartialEq, Copy)]
149
pub enum LogLevel {
150
    /// The error level.
151
    Error,
152
    /// The warning level.
153
    Warn,
154
    /// The info level.
155
    Info,
156
    /// The debug level.
157
    Debug,
158
    /// The trace level.
159
    Trace,
160
}
161

162
fn compression_level(s: &str) -> core::result::Result<u8, String> {
10✔
163
    clap_num::number_range(s, 0, 9)
10✔
164
}
10✔
165

166
#[derive(Parser, Debug)]
167
struct ActualArgs {
168
    args: Vec<String>,
169
}
170

171
impl ActualArgs {}
172

173
impl CliOpts {
174
    pub(crate) fn find_mode(&self) -> Result<(Mode, Vec<String>)> {
8✔
175
        let args = normalize_args(self.args.clone())?;
8✔
176
        if args.is_empty() {
8✔
NEW
177
            Err(ToteError::NoArgumentsGiven)
×
178
        } else {
179
            match self.mode {
8✔
180
                RunMode::Auto => {
181
                    if totebag::format::match_all(&args) {
6✔
182
                        Ok(to_extract_config(self, args))
2✔
183
                    } else {
184
                        Ok(to_archive_config(self, args))
4✔
185
                    }
186
                }
NEW
187
                RunMode::Archive => Ok(to_archive_config(self, args)),
×
NEW
188
                RunMode::Extract => Ok(to_extract_config(self, args)),
×
189
                RunMode::List => Ok(to_list_config(self, args)),
2✔
190
            }
191
        }
192
    }
8✔
193
}
194

195
fn to_archive_config(opts: &CliOpts, args: Vec<String>) -> (Mode, Vec<String>) {
4✔
196
    let (dest, args) = if totebag::format::find(&args[0]).is_some() && opts.output.is_none() {
4✔
197
        (Some(args[0].clone().into()), args[1..].to_vec())
1✔
198
    } else {
199
        (None, args)
3✔
200
    };
201
    let config = totebag::ArchiveConfig::builder()
4✔
202
        .dest(dest.unwrap_or_else(|| PathBuf::from("totebag.zip")))
4✔
203
        .level(opts.archivers.level)
4✔
204
        .rebase_dir(opts.archivers.base_dir.clone())
4✔
205
        .overwrite(opts.overwrite)
4✔
206
        .no_recursive(opts.archivers.no_recursive)
4✔
207
        .ignore(opts.archivers.ignores.clone())
4✔
208
        .build();
4✔
209
    (Mode::Archive(config), args)
4✔
210
}
4✔
211

212
fn to_extract_config(opts: &CliOpts, args: Vec<String>) -> (Mode, Vec<String>) {
2✔
213
    let dest = opts.output.clone().unwrap_or_else(|| PathBuf::from("."));
2✔
214
    let config = totebag::ExtractConfig::builder()
2✔
215
        .overwrite(opts.overwrite)
2✔
216
        .use_archive_name_dir(opts.extractors.to_archive_name_dir)
2✔
217
        .dest(dest)
2✔
218
        .build();
2✔
219
    (Mode::Extract(config), args)
2✔
220
}
2✔
221

222
fn to_list_config(opts: &CliOpts, args: Vec<String>) -> (Mode, Vec<String>) {
2✔
223
    (
2✔
224
        Mode::List(totebag::ListConfig::new(opts.listers.format.clone())),
2✔
225
        args,
2✔
226
    )
2✔
227
}
2✔
228

229
pub(crate) fn normalize_args(args: Vec<String>) -> Result<Vec<String>> {
8✔
230
    let results = args
8✔
231
        .iter()
8✔
232
        .map(reads_file_or_stdin_if_needed)
8✔
233
        .collect::<Vec<Result<Vec<String>>>>();
8✔
234
    if results.iter().any(|r| r.is_err()) {
20✔
235
        let errs = results
×
236
            .into_iter()
×
237
            .filter(|r| r.is_err())
×
238
            .flat_map(|r| r.err())
×
239
            .collect::<Vec<ToteError>>();
×
240
        Err(ToteError::Array(errs))
×
241
    } else {
242
        let results = results
8✔
243
            .into_iter()
8✔
244
            .filter(|r| r.is_ok())
20✔
245
            .flat_map(|r| r.unwrap())
20✔
246
            .collect::<Vec<String>>();
8✔
247
        Ok(results)
8✔
248
    }
249
}
8✔
250

251
fn reads_file_or_stdin_if_needed<S: AsRef<str>>(s: S) -> Result<Vec<String>> {
20✔
252
    let s = s.as_ref();
20✔
253
    if s == "-" {
20✔
254
        reads_from_reader(std::io::stdin())
×
255
    } else if let Some(stripped_str) = s.strip_prefix('@') {
20✔
256
        reads_from_file(stripped_str)
3✔
257
    } else {
258
        Ok(vec![s.to_string()])
17✔
259
    }
260
}
20✔
261

262
fn reads_from_file<S: AsRef<str>>(s: S) -> Result<Vec<String>> {
3✔
263
    let path = PathBuf::from(s.as_ref());
3✔
264
    if !path.exists() {
3✔
NEW
265
        Err(ToteError::FileNotFound(path))
×
266
    } else {
267
        match std::fs::File::open(path) {
3✔
268
            Ok(f) => reads_from_reader(f),
3✔
NEW
269
            Err(e) => Err(ToteError::IO(e)),
×
270
        }
271
    }
272
}
3✔
273

274
fn reads_from_reader<R: std::io::Read>(r: R) -> Result<Vec<String>> {
3✔
275
    let results = std::io::BufReader::new(r)
3✔
276
        .lines()
3✔
277
        .map_while(|r| r.ok())
23✔
278
        .map(|line| line.trim().to_string())
23✔
279
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
23✔
280
        .collect::<Vec<String>>();
3✔
281
    Ok(results)
3✔
282
}
3✔
283

284
#[cfg(test)]
285
mod tests {
286
    use super::*;
287
    use clap::Parser;
288

289
    #[test]
290
    fn test_read_from_file1() {
1✔
291
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/archive_mode1.txt"]);
1✔
292
        let (mode, args) = cli.find_mode().unwrap();
1✔
293
        match mode {
1✔
NEW
294
            Mode::List(_) | Mode::Extract(_) => panic!("invalid mode"),
×
295
            Mode::Archive(config) => assert_eq!(
1✔
296
                config.dest_file().unwrap(),
1✔
297
                PathBuf::from("testdata/targets.tar.gz")
1✔
298
            ),
299
        }
300
        assert_eq!(
1✔
301
            args,
302
            vec!["src", "README.md", "LICENSE", "Cargo.toml", "Makefile.toml"]
1✔
303
        );
304
    }
1✔
305

306
    #[test]
307
    fn test_read_from_file2() {
1✔
308
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/archive_mode2.txt"]);
1✔
309
        let (mode, args) = cli.find_mode().unwrap();
1✔
310
        match mode {
1✔
NEW
311
            Mode::List(_) | Mode::Extract(_) => panic!("invalid mode"),
×
312
            Mode::Archive(config) => {
1✔
313
                assert_eq!(config.dest_file().unwrap(), PathBuf::from("totebag.zip"))
1✔
314
            }
315
        }
316
        assert_eq!(
1✔
317
            args,
318
            vec!["src", "README.md", "LICENSE", "Cargo.toml", "Makefile.toml"]
1✔
319
        );
320
    }
1✔
321

322
    #[test]
323
    fn test_read_from_file3() {
1✔
324
        let cli = CliOpts::parse_from(&["totebag_test", "@../testdata/files/extract_mode.txt"]);
1✔
325
        let (mode, args) = cli.find_mode().unwrap();
1✔
326
        match mode {
1✔
NEW
327
            Mode::List(_) | Mode::Archive(_) => panic!("invalid mode"),
×
328
            Mode::Extract(config) => assert_eq!(config.dest, PathBuf::from(".")),
1✔
329
        }
330
        assert_eq!(args, vec!["testdata/test.cab", "testdata/test.tar"]);
1✔
331
    }
1✔
332

333
    #[test]
334
    fn test_find_mode_1() {
1✔
335
        let cli1 =
1✔
336
            CliOpts::parse_from(&["totebag_test", "src", "LICENSE", "README.md", "Cargo.toml"]);
1✔
337
        let (mode, args) = cli1.find_mode().unwrap();
1✔
338
        assert_eq!(mode.mode(), "archive");
1✔
339
        assert_eq!(args, vec!["src", "LICENSE", "README.md", "Cargo.toml"]);
1✔
340
    }
1✔
341

342
    #[test]
343
    fn test_find_mode_2() {
1✔
344
        let cli2 =
1✔
345
            CliOpts::parse_from(&["totebag_test", "src", "LICENSE", "README.md", "hoge.zip"]);
1✔
346
        let (mode, args) = cli2.find_mode().unwrap();
1✔
347
        assert_eq!(mode.mode(), "archive");
1✔
348
        assert_eq!(args, vec!["src", "LICENSE", "README.md", "hoge.zip"]);
1✔
349
    }
1✔
350

351
    #[test]
352
    fn test_find_mode_3() {
1✔
353
        let cli3 = CliOpts::parse_from(&[
1✔
354
            "totebag_test",
1✔
355
            "src.zip",
1✔
356
            "LICENSE.tar",
1✔
357
            "README.tar.bz2",
1✔
358
            "hoge.rar",
1✔
359
        ]);
1✔
360
        let (mode, args) = cli3.find_mode().unwrap();
1✔
361
        assert_eq!(mode.mode(), "extract");
1✔
362
        assert_eq!(
1✔
363
            args,
364
            vec!["src.zip", "LICENSE.tar", "README.tar.bz2", "hoge.rar"]
1✔
365
        );
366
    }
1✔
367

368
    #[test]
369
    fn test_find_mode_4() {
1✔
370
        let cli4 = CliOpts::parse_from(&[
1✔
371
            "totebag_test",
1✔
372
            "src.zip",
1✔
373
            "LICENSE.tar",
1✔
374
            "README.tar.bz2",
1✔
375
            "hoge.rar",
1✔
376
            "--mode",
1✔
377
            "list",
1✔
378
        ]);
1✔
379
        let (mode, args) = cli4.find_mode().unwrap();
1✔
380
        assert_eq!(mode.mode(), "list");
1✔
381
        assert_eq!(
1✔
382
            args,
383
            vec!["src.zip", "LICENSE.tar", "README.tar.bz2", "hoge.rar"]
1✔
384
        );
385
    }
1✔
386

387
    #[test]
388
    fn test_cli_parse_error() {
1✔
389
        let r = CliOpts::try_parse_from(&["totebag_test"]);
1✔
390
        assert!(r.is_err());
1✔
391
    }
1✔
392
}
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