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

hmerritt / exifmeta / 26588100170

28 May 2026 04:33PM UTC coverage: 87.638% (-0.01%) from 87.65%
26588100170

push

github

hmerritt
fix: prefer .yml over .yaml

44 of 45 new or added lines in 2 files covered. (97.78%)

534 existing lines in 2 files now uncovered.

6288 of 7175 relevant lines covered (87.64%)

21.67 hits per line

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

92.77
/src/cli.rs
1
use std::path::PathBuf;
2

3
use clap::{Args, Parser, Subcommand, ValueEnum};
4

5
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
6
#[command(name = "exifmeta")]
7
#[command(about = "Read metadata.yml and write EXIF metadata to image files")]
8
#[command(version, propagate_version = true)]
9
pub struct Cli {
10
    #[arg(long, global = true, help = "Simulate actions without changing files")]
11
    pub dry_run: bool,
12

13
    #[command(subcommand)]
14
    pub command: Command,
15
}
16

17
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
18
pub enum Command {
19
    #[command(about = "Create a template metadata.yml file")]
20
    New(NewArgs),
21

22
    #[command(about = "Check metadata.yml is valid")]
23
    Check(CheckArgs),
24

25
    #[command(about = "Read and pretty-print the current EXIF data of an image file")]
26
    Read(ReadArgs),
27

28
    #[command(about = "Read metadata.yml and write EXIF data to target image files")]
29
    Write(WriteArgs),
30

31
    #[command(about = "Remove all existing EXIF metadata from target image files")]
32
    Strip(StripArgs),
33

34
    #[command(about = "Interactively browse folders and read image EXIF data")]
35
    Interactive(InteractiveArgs),
36
}
37

38
#[derive(Debug, Clone, Args, PartialEq, Eq)]
39
pub struct NewArgs {
40
    #[arg(value_name = "DIRECTORY", default_value = ".")]
41
    pub path: PathBuf,
42
}
43

44
#[derive(Debug, Clone, Args, PartialEq, Eq)]
45
pub struct CheckArgs {
46
    #[arg(value_name = "PATH")]
47
    pub path: Option<PathBuf>,
48
}
49

50
#[derive(Debug, Clone, Args, PartialEq, Eq)]
51
pub struct InteractiveArgs {
52
    #[arg(value_name = "DIRECTORY", default_value = ".")]
53
    pub path: PathBuf,
54
}
55

56
#[derive(Debug, Clone, Args, PartialEq, Eq)]
57
pub struct WriteArgs {
58
    #[arg(value_name = "METADATA_OR_TARGETS")]
59
    pub metadata_or_targets: Option<PathBuf>,
60

61
    #[arg(value_name = "TARGETS")]
62
    pub targets: Option<String>,
63

64
    #[arg(
65
        long,
66
        conflicts_with_all = ["keep", "remove", "privacy"],
67
        help = "Remove existing EXIF data before adding new data"
68
    )]
69
    pub strip: bool,
70

71
    #[arg(
72
        long,
73
        value_delimiter = ',',
74
        value_name = "TAGS",
75
        conflicts_with = "privacy",
76
        help = "Strip all EXIF tags except the comma-separated tag names before adding new data"
77
    )]
78
    pub keep: Vec<String>,
79

80
    #[arg(
81
        long,
82
        value_delimiter = ',',
83
        value_name = "TAGS",
84
        help = "Remove only the comma-separated EXIF tag names before adding new data"
85
    )]
86
    pub remove: Vec<String>,
87

88
    #[arg(
89
        long,
90
        conflicts_with = "keep",
91
        help = "Remove privacy-sensitive EXIF tags before adding new data"
92
    )]
93
    pub privacy: bool,
94

95
    #[arg(long, help = "Prevent overwriting existing EXIF data")]
96
    pub no_overwrite: bool,
97

98
    #[arg(
99
        short = 'e',
100
        long,
101
        value_delimiter = ',',
102
        value_name = "EXTENSIONS",
103
        help = "Restrict processing to comma-separated file extensions"
104
    )]
105
    pub extensions: Vec<String>,
106

107
    #[arg(long, help = "Find image files across all subdirectories")]
108
    pub recursive: bool,
109
}
110

111
#[derive(Debug, Clone, Args, PartialEq, Eq)]
112
pub struct StripArgs {
113
    #[arg(value_name = "TARGETS")]
114
    pub targets: Option<String>,
115

116
    #[arg(
117
        long,
118
        value_delimiter = ',',
119
        value_name = "TAGS",
120
        conflicts_with = "privacy",
121
        help = "Strip all EXIF tags except the comma-separated tag names"
122
    )]
123
    pub keep: Vec<String>,
124

125
    #[arg(
126
        long,
127
        value_delimiter = ',',
128
        value_name = "TAGS",
129
        help = "Remove only the comma-separated EXIF tag names"
130
    )]
131
    pub remove: Vec<String>,
132

133
    #[arg(
134
        short = 'e',
135
        long,
136
        value_delimiter = ',',
137
        value_name = "EXTENSIONS",
138
        help = "Restrict processing to comma-separated file extensions"
139
    )]
140
    pub extensions: Vec<String>,
141

142
    #[arg(long, help = "Find image files across all subdirectories")]
143
    pub recursive: bool,
144

145
    #[arg(long, help = "Verify that no EXIF metadata remains after stripping")]
146
    pub verify: bool,
147

148
    #[arg(
149
        long,
150
        conflicts_with = "keep",
151
        help = "Remove privacy-sensitive EXIF tags while keeping harmless technical tags"
152
    )]
153
    pub privacy: bool,
154

155
    #[arg(long, help = "Emit a machine-readable JSON report")]
156
    pub json: bool,
157
}
158

159
#[derive(Debug, Clone, Args, PartialEq, Eq)]
160
pub struct ReadArgs {
161
    #[arg(value_name = "IMAGE")]
162
    pub image: PathBuf,
163

164
    #[arg(
165
        long,
166
        value_enum,
167
        default_value_t = ReadFormat::Pretty,
168
        help = "Choose the read output format"
169
    )]
170
    pub format: ReadFormat,
171
}
172

173
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
174
pub enum ReadFormat {
175
    Pretty,
176
    Raw,
177
}
178

179
#[cfg(test)]
180
mod tests {
181
    use super::*;
182
    use clap::CommandFactory;
183

184
    #[test]
185
    fn help_lists_commands_in_documented_order() {
1✔
186
        let mut command = Cli::command();
1✔
187
        let help = command.render_help().to_string();
1✔
188
        let expected_order = ["new", "check", "read", "write", "strip", "interactive"];
1✔
189
        let mut previous_position = 0;
1✔
190

191
        for expected_command in expected_order {
6✔
192
            let position = help
6✔
193
                .find(&format!("  {expected_command}"))
6✔
194
                .unwrap_or_else(|| panic!("expected help to list {expected_command} command"));
6✔
195

196
            assert!(
6✔
197
                position >= previous_position,
6✔
198
                "expected {expected_command} to appear after previous command in help:\n{help}"
199
            );
200

201
            previous_position = position;
6✔
202
        }
203

204
        let help_position = help
1✔
205
            .find("  help")
1✔
206
            .expect("expected help to list generated help command");
1✔
207
        assert!(
1✔
208
            help_position >= previous_position,
1✔
209
            "expected generated help command to appear after app commands:\n{help}"
210
        );
211
    }
1✔
212

213
    #[test]
214
    fn parses_each_readme_command() {
1✔
215
        for command in ["new", "check", "write", "strip", "interactive"] {
5✔
216
            assert!(
5✔
217
                Cli::try_parse_from(["exifmeta", command]).is_ok(),
5✔
218
                "expected {command} to parse"
219
            );
220
        }
221

222
        assert!(Cli::try_parse_from(["exifmeta", "read", "image.jpg"]).is_ok());
1✔
223
    }
1✔
224

225
    #[test]
226
    fn parses_new_default_path() {
1✔
227
        let cli = Cli::try_parse_from(["exifmeta", "new"]).expect("new should parse");
1✔
228

229
        let Command::New(args) = cli.command else {
1✔
UNCOV
230
            panic!("expected new command");
×
231
        };
232

233
        assert_eq!(args.path, PathBuf::from("."));
1✔
234
    }
1✔
235

236
    #[test]
237
    fn parses_new_path() {
1✔
238
        let cli = Cli::try_parse_from(["exifmeta", "new", "some/other/directory"])
1✔
239
            .expect("new path should parse");
1✔
240

241
        let Command::New(args) = cli.command else {
1✔
UNCOV
242
            panic!("expected new command");
×
243
        };
244

245
        assert_eq!(args.path, PathBuf::from("some/other/directory"));
1✔
246
    }
1✔
247

248
    #[test]
249
    fn parses_interactive_default_path() {
1✔
250
        let cli =
1✔
251
            Cli::try_parse_from(["exifmeta", "interactive"]).expect("interactive should parse");
1✔
252

253
        let Command::Interactive(args) = cli.command else {
1✔
UNCOV
254
            panic!("expected interactive command");
×
255
        };
256

257
        assert_eq!(args.path, PathBuf::from("."));
1✔
258
    }
1✔
259

260
    #[test]
261
    fn parses_interactive_path() {
1✔
262
        let cli = Cli::try_parse_from(["exifmeta", "interactive", "some/photos"])
1✔
263
            .expect("interactive path should parse");
1✔
264

265
        let Command::Interactive(args) = cli.command else {
1✔
UNCOV
266
            panic!("expected interactive command");
×
267
        };
268

269
        assert_eq!(args.path, PathBuf::from("some/photos"));
1✔
270
    }
1✔
271

272
    #[test]
273
    fn parses_read_default_format() {
1✔
274
        let cli = Cli::try_parse_from(["exifmeta", "read", "image.jpg"])
1✔
275
            .expect("read command should parse");
1✔
276

277
        let Command::Read(args) = cli.command else {
1✔
UNCOV
278
            panic!("expected read command");
×
279
        };
280

281
        assert_eq!(args.image, PathBuf::from("image.jpg"));
1✔
282
        assert_eq!(args.format, ReadFormat::Pretty);
1✔
283
    }
1✔
284

285
    #[test]
286
    fn parses_read_raw_format() {
1✔
287
        let cli = Cli::try_parse_from(["exifmeta", "read", "image.jpg", "--format", "raw"])
1✔
288
            .expect("read raw format should parse");
1✔
289

290
        let Command::Read(args) = cli.command else {
1✔
UNCOV
291
            panic!("expected read command");
×
292
        };
293

294
        assert_eq!(args.format, ReadFormat::Raw);
1✔
295
    }
1✔
296

297
    #[test]
298
    fn rejects_invalid_read_format() {
1✔
299
        assert!(
1✔
300
            Cli::try_parse_from(["exifmeta", "read", "image.jpg", "--format", "json"]).is_err()
1✔
301
        );
302
    }
1✔
303

304
    #[test]
305
    fn rejects_write_flags_on_read() {
1✔
306
        assert!(Cli::try_parse_from(["exifmeta", "read", "image.jpg", "--recursive"]).is_err());
1✔
307
    }
1✔
308

309
    #[test]
310
    fn parses_write_flags() {
1✔
311
        let cli = Cli::try_parse_from([
1✔
312
            "exifmeta",
1✔
313
            "--dry-run",
1✔
314
            "write",
1✔
315
            "--strip",
1✔
316
            "--no-overwrite",
1✔
317
            "--extensions",
1✔
318
            "jpg,tiff",
1✔
319
            "--recursive",
1✔
320
        ])
1✔
321
        .expect("write flags should parse");
1✔
322

323
        assert!(cli.dry_run);
1✔
324

325
        let Command::Write(args) = cli.command else {
1✔
UNCOV
326
            panic!("expected write command");
×
327
        };
328

329
        assert!(args.strip);
1✔
330
        assert!(args.keep.is_empty());
1✔
331
        assert!(args.remove.is_empty());
1✔
332
        assert!(!args.privacy);
1✔
333
        assert!(args.no_overwrite);
1✔
334
        assert!(args.recursive);
1✔
335
        assert_eq!(args.extensions, ["jpg", "tiff"]);
1✔
336
    }
1✔
337

338
    #[test]
339
    fn parses_write_keep() {
1✔
340
        let cli = Cli::try_parse_from(["exifmeta", "write", "--keep", "Make,Model"])
1✔
341
            .expect("write keep should parse");
1✔
342

343
        let Command::Write(args) = cli.command else {
1✔
UNCOV
344
            panic!("expected write command");
×
345
        };
346

347
        assert!(!args.strip);
1✔
348
        assert_eq!(args.keep, ["Make", "Model"]);
1✔
349
        assert!(args.remove.is_empty());
1✔
350
        assert!(!args.privacy);
1✔
351
    }
1✔
352

353
    #[test]
354
    fn parses_write_repeated_remove_values() {
1✔
355
        let cli = Cli::try_parse_from([
1✔
356
            "exifmeta",
1✔
357
            "write",
1✔
358
            "--remove",
1✔
359
            "GPSLatitude,GPSLongitude",
1✔
360
            "--remove",
1✔
361
            "UserComment",
1✔
362
        ])
1✔
363
        .expect("write repeated remove values should parse");
1✔
364

365
        let Command::Write(args) = cli.command else {
1✔
UNCOV
366
            panic!("expected write command");
×
367
        };
368

369
        assert_eq!(args.remove, ["GPSLatitude", "GPSLongitude", "UserComment"]);
1✔
370
    }
1✔
371

372
    #[test]
373
    fn parses_write_remove_with_privacy() {
1✔
374
        let cli = Cli::try_parse_from(["exifmeta", "write", "--privacy", "--remove", "FNumber"])
1✔
375
            .expect("write remove should compose with privacy");
1✔
376

377
        let Command::Write(args) = cli.command else {
1✔
UNCOV
378
            panic!("expected write command");
×
379
        };
380

381
        assert!(args.privacy);
1✔
382
        assert_eq!(args.remove, ["FNumber"]);
1✔
383
    }
1✔
384

385
    #[test]
386
    fn rejects_conflicting_write_strip_modes() {
1✔
387
        assert!(Cli::try_parse_from(["exifmeta", "write", "--privacy", "--keep", "Make"]).is_err());
1✔
388
        assert!(Cli::try_parse_from(["exifmeta", "write", "--strip", "--keep", "Make"]).is_err());
1✔
389
        assert!(Cli::try_parse_from(["exifmeta", "write", "--strip", "--remove", "Make"]).is_err());
1✔
390
        assert!(Cli::try_parse_from(["exifmeta", "write", "--strip", "--privacy"]).is_err());
1✔
391
    }
1✔
392

393
    #[test]
394
    fn parses_strip_default_args() {
1✔
395
        let cli = Cli::try_parse_from(["exifmeta", "strip"]).expect("strip should parse");
1✔
396

397
        let Command::Strip(args) = cli.command else {
1✔
UNCOV
398
            panic!("expected strip command");
×
399
        };
400

401
        assert_eq!(args.targets, None);
1✔
402
        assert!(args.keep.is_empty());
1✔
403
        assert!(args.remove.is_empty());
1✔
404
        assert!(args.extensions.is_empty());
1✔
405
        assert!(!args.recursive);
1✔
406
        assert!(!args.verify);
1✔
407
        assert!(!args.privacy);
1✔
408
        assert!(!args.json);
1✔
409
    }
1✔
410

411
    #[test]
412
    fn parses_strip_flags() {
1✔
413
        let cli = Cli::try_parse_from([
1✔
414
            "exifmeta",
1✔
415
            "--dry-run",
1✔
416
            "strip",
1✔
417
            "photos/*.jpg",
1✔
418
            "--keep",
1✔
419
            "Make,Model",
1✔
420
            "--recursive",
1✔
421
            "--extensions",
1✔
422
            "jpg,png",
1✔
423
            "--verify",
1✔
424
            "--json",
1✔
425
        ])
1✔
426
        .expect("strip flags should parse");
1✔
427

428
        assert!(cli.dry_run);
1✔
429

430
        let Command::Strip(args) = cli.command else {
1✔
UNCOV
431
            panic!("expected strip command");
×
432
        };
433

434
        assert_eq!(args.targets, Some("photos/*.jpg".to_string()));
1✔
435
        assert_eq!(args.keep, ["Make", "Model"]);
1✔
436
        assert!(args.remove.is_empty());
1✔
437
        assert_eq!(args.extensions, ["jpg", "png"]);
1✔
438
        assert!(args.recursive);
1✔
439
        assert!(args.verify);
1✔
440
        assert!(!args.privacy);
1✔
441
        assert!(args.json);
1✔
442
    }
1✔
443

444
    #[test]
445
    fn parses_strip_repeated_remove_values() {
1✔
446
        let cli = Cli::try_parse_from([
1✔
447
            "exifmeta",
1✔
448
            "strip",
1✔
449
            "--remove",
1✔
450
            "GPSLatitude,GPSLongitude",
1✔
451
            "--remove",
1✔
452
            "UserComment",
1✔
453
        ])
1✔
454
        .expect("strip repeated remove values should parse");
1✔
455

456
        let Command::Strip(args) = cli.command else {
1✔
UNCOV
457
            panic!("expected strip command");
×
458
        };
459

460
        assert_eq!(args.remove, ["GPSLatitude", "GPSLongitude", "UserComment"]);
1✔
461
    }
1✔
462

463
    #[test]
464
    fn parses_strip_remove_with_keep() {
1✔
465
        let cli = Cli::try_parse_from(["exifmeta", "strip", "--keep", "Make", "--remove", "Model"])
1✔
466
            .expect("strip remove should compose with keep");
1✔
467

468
        let Command::Strip(args) = cli.command else {
1✔
UNCOV
469
            panic!("expected strip command");
×
470
        };
471

472
        assert_eq!(args.keep, ["Make"]);
1✔
473
        assert_eq!(args.remove, ["Model"]);
1✔
474
    }
1✔
475

476
    #[test]
477
    fn parses_strip_remove_with_privacy() {
1✔
478
        let cli = Cli::try_parse_from(["exifmeta", "strip", "--privacy", "--remove", "FNumber"])
1✔
479
            .expect("strip remove should compose with privacy");
1✔
480

481
        let Command::Strip(args) = cli.command else {
1✔
UNCOV
482
            panic!("expected strip command");
×
483
        };
484

485
        assert!(args.privacy);
1✔
486
        assert_eq!(args.remove, ["FNumber"]);
1✔
487
    }
1✔
488

489
    #[test]
490
    fn rejects_conflicting_strip_modes() {
1✔
491
        assert!(Cli::try_parse_from(["exifmeta", "strip", "--privacy", "--keep", "Make"]).is_err());
1✔
492
    }
1✔
493

494
    #[test]
495
    fn parses_global_dry_run_after_subcommand() {
1✔
496
        let cli = Cli::try_parse_from(["exifmeta", "check", "--dry-run"])
1✔
497
            .expect("global dry-run should parse after subcommands");
1✔
498

499
        assert!(cli.dry_run);
1✔
500
        assert_eq!(cli.command, Command::Check(CheckArgs { path: None }));
1✔
501
    }
1✔
502

503
    #[test]
504
    fn parses_check_default_path() {
1✔
505
        let cli = Cli::try_parse_from(["exifmeta", "check"]).expect("check should parse");
1✔
506

507
        let Command::Check(args) = cli.command else {
1✔
UNCOV
508
            panic!("expected check command");
×
509
        };
510

511
        assert_eq!(args.path, None);
1✔
512
    }
1✔
513

514
    #[test]
515
    fn parses_check_path() {
1✔
516
        let cli = Cli::try_parse_from(["exifmeta", "check", "some/metadata.yml"])
1✔
517
            .expect("check path should parse");
1✔
518

519
        let Command::Check(args) = cli.command else {
1✔
UNCOV
520
            panic!("expected check command");
×
521
        };
522

523
        assert_eq!(args.path, Some(PathBuf::from("some/metadata.yml")));
1✔
524
    }
1✔
525

526
    #[test]
527
    fn rejects_unknown_commands() {
1✔
528
        assert!(Cli::try_parse_from(["exifmeta", "unknown"]).is_err());
1✔
529
    }
1✔
530

531
    #[test]
532
    fn rejects_renamed_commands() {
1✔
533
        for command in ["run", "init", "validate"] {
3✔
534
            assert!(
3✔
535
                Cli::try_parse_from(["exifmeta", command]).is_err(),
3✔
536
                "expected old {command} command to be rejected"
537
            );
538
        }
539

540
        assert!(Cli::try_parse_from(["exifmeta", "inspect", "image.jpg"]).is_err());
1✔
541
    }
1✔
542
}
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