convco 0.7.0

Conventional commit tools
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
use std::{path::PathBuf, str::FromStr};

use clap::Parser;
#[cfg(feature = "completions")]
use clap_complete::aot::Shell as Shells;
use semver::{Prerelease, Version};

#[derive(Debug, Parser)]
#[clap(name = "convco", about = "Conventional commit tools", version)]
pub struct Opt {
    /// Run as if convco was started in <path> instead of the current working directory.
    #[clap(short = 'C', global = true)]
    pub path: Option<PathBuf>,
    #[clap(short = 'c', long = "config", global = true, env = "CONVCO_CONFIG")]
    pub config: Option<PathBuf>,
    #[clap(subcommand)]
    pub cmd: Command,
}

#[derive(Debug, Parser)]
pub enum Command {
    /// Actions for configuration
    Config(ConfigCommand),
    /// Verifies if all commits are conventional
    Check(CheckCommand),
    #[cfg(feature = "completions")]
    /// Generates shell completions
    Completions(CompletionsCommand),
    /// Writes out a changelog
    Changelog(ChangelogCommand),
    /// Show the current version
    Version(VersionCommand),
    /// Helps to make conventional commits.
    Commit(CommitCommand),
}

#[derive(Debug, Parser)]
pub struct ConfigCommand {
    /// Print out the default configuration instead of the current configuration.
    #[clap(short, long)]
    pub default: bool,
}

#[derive(Debug, Parser)]
pub struct VersionCommand {
    /// Prefix used in front of the semantic version
    #[clap(short, long, default_value = "v", env = "CONVCO_PREFIX")]
    pub prefix: String,
    /// Print prefix in front of the semantic version
    #[clap(long, visible_alias = "pp", env = "CONVCO_PRINT_PREFIX")]
    pub print_prefix: bool,
    /// Revision to show the version for
    #[clap(default_value = "HEAD")]
    pub rev: String,
    /// Get the next version
    #[clap(short, long)]
    pub bump: bool,
    /// Instead of printing out the bumped version, prints out one of: major, minor or patch
    #[clap(short, long, conflicts_with_all(&["major", "minor", "patch"]))]
    pub label: bool,
    /// Bump to a major release version, regardless of the conventional commits
    #[clap(long, env = "CONVCO_FORCE_MAJOR_BUMP")]
    pub major: bool,
    /// Bump to a minor release version, regardless of the conventional commits
    #[clap(long, env = "CONVCO_FORCE_MINOR_BUMP")]
    pub minor: bool,
    /// Bump to a patch release version, regardless of the conventional commits
    #[clap(long, env = "CONVCO_FORCE_PATCH_BUMP")]
    pub patch: bool,
    /// Suffix with a prerelease version. Requires --bump.
    #[clap(long, requires = "bump", default_value_t = Prerelease::new("").unwrap())]
    pub prerelease: Prerelease,
    /// Only commits that update those <pathspecs> will be taken into account. It is useful to support monorepos.
    /// Pathspecs are evaluated relative to the root of the repository.
    #[clap(short = 'P', long, env = "CONVCO_PATHS", value_delimiter = ',')]
    pub paths: Vec<String>,
    /// Print the commit-sha of the version instead of the semantic version
    #[clap(long)]
    pub commit_sha: bool,
    /// Ignore pre-release versions when finding the last version
    #[clap(long, env = "CONVCO_IGNORE_PRERELEASES")]
    pub ignore_prereleases: bool,
    /// If no version is found use this version for the first bump
    #[clap(long, env = "CONVCO_INITIAL_BUMP_VERSION")]
    pub initial_bump_version: Option<Version>,
    /// Treat major version zero as stable when calculating the next version. Requires --bump.
    #[clap(long, env = "CONVCO_TREAT_MAJOR_ZERO_AS_STABLE", requires = "bump")]
    pub treat_major_zero_as_stable: bool,
}

#[derive(Debug, Parser)]
pub struct CheckCommand {
    /// Start of the revwalk, can also be a commit range. Can be in the form `<commit>..<commit>`.
    #[clap(env = "CONVCO_REV")]
    pub rev: Option<String>,
    /// Limit the number of commits to check.
    #[clap(short, long = "max-count", env = "CONVCO_MAX_COUNT")]
    pub number: Option<usize>,
    /// Include conventional merge commits (commits with more than 1 parent) in the changelog.
    #[clap(long, env = "CONVCO_MERGES")]
    pub merges: bool,
    /// Follow only the first parent
    #[clap(long, env = "CONVCO_FIRST_PARENT")]
    pub first_parent: bool,
    /// Ignore commits created by `git revert` commands
    #[clap(long, env = "CONVCO_IGNORE_REVERTS")]
    pub ignore_reverts: bool,
    /// Ignore commits whose message matches the given regex pattern
    #[clap(long, env = "CONVCO_IGNORE_MESSAGE_PATTERN")]
    pub ignore_message_pattern: Vec<String>,
    /// Read a single commit message from stdin
    #[clap(long)]
    pub from_stdin: bool,
    /// String comments and whitespace from commit message
    /// This is similar to `git commit --cleanup=strip`
    #[clap(long, requires("from_stdin"))]
    pub strip: bool,
}

#[cfg(feature = "completions")]
#[derive(Debug, Parser)]
pub struct CompletionsCommand {
    /// Shell to generate completions for
    pub shell: Option<Shells>,
}

#[derive(Debug, Parser)]
pub struct ChangelogCommand {
    /// Prefix used in front of the semantic version.
    #[clap(short, long, default_value = "v", env = "CONVCO_PREFIX")]
    pub prefix: String,
    #[clap(default_value = "HEAD", env = "CONVCO_REV")]
    pub rev: String,
    #[clap(short, long, env = "CONVCO_SKIP_EMPTY")]
    pub skip_empty: bool,
    /// Limits the number of version tags to add in the changelog.
    #[clap(short, long, env = "CONVCO_MAX_VERSIONS")]
    pub max_versions: Option<usize>,
    /// Only print this number of minor versions.
    #[clap(long, default_value_t=u64::MAX, hide_default_value=true, env = "CONVCO_MAX_MINORS")]
    pub max_minors: u64,
    /// Only show this number of major versions.
    #[clap(long, default_value_t=u64::MAX, hide_default_value=true, env = "CONVCO_MAX_MAJORS")]
    pub max_majors: u64,
    /// Only show this number of patch versions.
    #[clap(long, default_value_t=u64::MAX, hide_default_value=true, env = "CONVCO_MAX_PATCHES")]
    pub max_patches: u64,
    /// Ignore pre-release versions when finding the last version
    #[clap(long, env = "CONVCO_IGNORE_PRERELEASES")]
    pub ignore_prereleases: bool,
    /// Do not generate links. Overrides linkReferences and linkCompare in the config.
    #[clap(short, long, env = "CONVCO_NO_LINKS")]
    pub no_links: bool,
    /// Include conventional merge commits (commits with more than 1 parent) in the changelog.
    #[clap(long, env = "CONVCO_MERGES")]
    pub merges: bool,
    /// Print hidden sections
    #[clap(long, env = "CONVCO_INCLUDE_HIDDEN_SECTIONS")]
    pub include_hidden_sections: bool,
    /// Only commits that update those <pathspecs> will be taken into account. It is useful to support monorepos.
    /// Pathspecs are evaluated relative to the root of the repository.
    #[clap(short = 'P', long, env = "CONVCO_PATHS", value_delimiter = ',')]
    pub paths: Vec<PathBuf>,
    /// Follow only the first parent of merge commits. Commits from the merged branche(s) will be discarded.
    #[clap(long, env = "CONVCO_FIRST_PARENT")]
    pub first_parent: bool,
    /// Max line length before wrapping.
    /// This only makes sense if the template makes use of `{{#word-wrap}}` blocks.
    #[clap(long, env = "CONVCO_LINE_LENGTH")]
    pub line_length: Option<usize>,
    /// Do not wrap lines.
    /// This only makes sense if the template makes use of `{{#word-wrap}}` blocks.
    #[clap(long, env = "CONVCO_NO_WRAP")]
    pub no_wrap: bool,
    /// Change the title for the unreleased commits.
    /// If a semantic version is given, the title will be prefixed.
    #[clap(short, long, default_value = "Unreleased", env = "CONVCO_UNRELEASED")]
    pub unreleased: String,
    /// Path to write the changelog to.
    #[clap(short, long, default_value = "-", env = "CONVCO_OUTPUT")]
    pub output: PathBuf,
}

#[derive(Debug, Parser)]
pub struct CommitCommand {
    /// A bug fix
    #[clap(long,
        conflicts_with_all(&["feat", "build", "chore", "ci", "docs", "style", "refactor", "perf", "test", "type"]),
    )]
    pub fix: bool,
    /// A new feature
    #[clap(long,
        conflicts_with_all(&["fix", "build", "chore", "ci", "docs", "style", "refactor", "perf", "test", "type"]),
    )]
    pub feat: bool,
    /// Changes that affect the build system or external dependencies
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "chore", "ci", "docs", "style", "refactor", "perf", "test", "type"]),
    )]
    pub build: bool,
    /// Other changes that don't modify src or test files
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "ci", "docs", "style", "refactor", "perf", "test", "type"]),
    )]
    pub chore: bool,
    /// Changes to CI configuration files and scripts
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "docs", "style", "refactor", "perf", "test", "type"]),
    )]
    pub ci: bool,
    /// Documentation only changes
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "style", "refactor", "perf", "test", "type"]),
    )]
    pub docs: bool,
    /// Changes that do not affect the meaning of the code (e.g. formatting)
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "docs", "refactor", "perf", "test", "type"]),
    )]
    pub style: bool,
    /// A code change that neither fixes a bug nor adds a feature
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "docs", "style", "perf", "test", "type"]),
    )]
    pub refactor: bool,
    /// A code change that improves performance
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "docs", "style", "refactor", "test", "type"]),
    )]
    pub perf: bool,
    /// Adding missing tests or correcting existing tests
    #[clap(long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "docs", "style", "refactor", "perf", "type"]),
    )]
    pub test: bool,
    /// Specify your own commit type
    #[clap(short, long,
        conflicts_with_all(&["feat", "fix", "build", "chore", "ci", "docs", "style", "refactor", "perf", "test"]),
    )]
    pub r#type: Option<String>,
    /// Specifies the scope of the message
    #[clap(short, long)]
    pub scope: Option<String>,
    /// Limit the number of commits scanned for scope history.
    #[clap(long, env = "CONVCO_SCOPE_HISTORY_LIMIT", default_value_t = 500)]
    pub scope_history_limit: usize,
    /// The first message will be the description. Other -m options will be used as the body.
    #[clap(short, long)]
    pub message: Vec<String>,
    /// Specify extra footers to the message
    #[clap(
        short,
        long,
        visible_alias = "trailer",
        value_name = "token>(=|:)<value"
    )]
    pub footers: Vec<Footer>,
    /// Introduces a breaking change
    #[clap(long)]
    pub breaking: bool,
    /// Interactive mode. Start the wizard if no type and description is given.
    #[clap(long, short, env = "CONVCO_INTERACTIVE")]
    pub interactive: bool,
    /// Runs `git add -N <PATH>`.
    /// An entry for the path is placed in the index with no content.
    /// This is useful in combination with --patch.
    #[clap(short = 'N', long, env = "CONVCO_INTENT_TO_ADD", value_delimiter = ',')]
    pub intent_to_add: Vec<PathBuf>,
    /// Runs `git add -p`.
    /// Interactively choose hunks of patch between the index and the work tree.
    #[clap(short, long, env = "CONVCO_PATCH")]
    pub patch: bool,
    /// Path to store the commit message to recover from in case of an error
    /// If the path is `$GIT_DIR/COMMIT_EDITMSG` convco will not call `git commit`
    #[clap(hide = true)]
    pub commit_msg_path: Option<PathBuf>,
    /// Extra arguments passed to the git commit command
    #[clap(last = true)]
    pub extra_args: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Footer(pub(crate) String, pub(crate) String);

impl From<(&str, &str)> for Footer {
    fn from(s: (&str, &str)) -> Self {
        Self(s.0.trim().to_owned(), s.1.trim().to_owned())
    }
}

impl FromStr for Footer {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut split = s.split(':');
        match (split.next(), split.next()) {
            (Some(k), Some(v)) => Ok((k, v).into()),
            _ => {
                let mut split = s.split('=');
                match (split.next(), split.next()) {
                    (Some(k), Some(v)) => Ok((k, v).into()),
                    _ => Err(format!("invalid footer: {}", s)),
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use super::*;

    #[test]
    fn version_accepts_prerelease_with_ignore_prereleases() {
        let opt = Opt::try_parse_from([
            "convco",
            "version",
            "--bump",
            "--prerelease",
            "rc",
            "--ignore-prereleases",
        ])
        .unwrap();

        let Command::Version(command) = opt.cmd else {
            panic!("expected version command");
        };

        assert_eq!(command.prerelease.as_str(), "rc");
        assert!(command.ignore_prereleases);
    }

    #[test]
    fn comma_delimited_vec_options_parse_multiple_values() {
        let opt =
            Opt::try_parse_from(["convco", "version", "--paths", "packages/app,packages/lib"])
                .unwrap();
        let Command::Version(command) = opt.cmd else {
            panic!("expected version command");
        };
        assert_eq!(command.paths, ["packages/app", "packages/lib"]);

        let opt = Opt::try_parse_from([
            "convco",
            "version",
            "--paths",
            "src",
            "--paths",
            ":(exclude)src/generated",
        ])
        .unwrap();
        let Command::Version(command) = opt.cmd else {
            panic!("expected version command");
        };
        assert_eq!(command.paths, ["src", ":(exclude)src/generated"]);

        let opt = Opt::try_parse_from([
            "convco",
            "changelog",
            "--paths",
            "packages/app,packages/lib",
        ])
        .unwrap();
        let Command::Changelog(command) = opt.cmd else {
            panic!("expected changelog command");
        };
        assert_eq!(
            command.paths,
            [PathBuf::from("packages/app"), PathBuf::from("packages/lib")]
        );

        let opt = Opt::try_parse_from([
            "convco",
            "changelog",
            "--paths",
            "src",
            "--paths",
            ":(exclude)src/generated",
        ])
        .unwrap();
        let Command::Changelog(command) = opt.cmd else {
            panic!("expected changelog command");
        };
        assert_eq!(
            command.paths,
            [
                PathBuf::from("src"),
                PathBuf::from(":(exclude)src/generated")
            ]
        );

        let opt = Opt::try_parse_from(["convco", "commit", "-N", "one.txt,two.txt"]).unwrap();
        let Command::Commit(command) = opt.cmd else {
            panic!("expected commit command");
        };
        assert_eq!(
            command.intent_to_add,
            [PathBuf::from("one.txt"), PathBuf::from("two.txt")]
        );
    }

    #[test]
    fn commit_scope_history_limit_defaults_and_accepts_override() {
        let opt = Opt::try_parse_from(["convco", "commit"]).unwrap();
        let Command::Commit(command) = opt.cmd else {
            panic!("expected commit command");
        };
        assert_eq!(command.scope_history_limit, 500);

        let opt = Opt::try_parse_from(["convco", "commit", "--scope-history-limit", "42"]).unwrap();
        let Command::Commit(command) = opt.cmd else {
            panic!("expected commit command");
        };
        assert_eq!(command.scope_history_limit, 42);
    }

    #[test]
    fn test_footer() {
        let footer: Footer = "Reviewed-by: Z".parse().unwrap();
        assert_eq!(Footer("Reviewed-by".into(), "Z".into()), footer);
    }

    #[test]
    fn test_footer2() {
        let footer: Footer = "Reviewed-by=Z".parse().unwrap();
        assert_eq!(Footer("Reviewed-by".into(), "Z".into()), footer);
    }

    #[test]
    fn test_footer_err_empty() {
        let err: String = "".parse::<Footer>().unwrap_err();
        assert_eq!(err, format!("invalid footer: {}", ""));
    }
}