├── .changelog ├── config.toml ├── unreleased │ └── .gitkeep ├── v0.1.0 │ └── summary.md ├── v0.1.1 │ ├── improvements │ │ └── 1-fix-formatting.md │ └── summary.md ├── v0.2.0 │ ├── breaking-changes │ │ └── 2-entry-groups.md │ ├── features │ │ └── 004-build-unreleased.md │ └── summary.md ├── v0.2.1 │ ├── features │ │ └── 6-add-component-flag.md │ └── summary.md ├── v0.3.0 │ ├── breaking-changes │ │ └── 2-hyphens.md │ └── summary.md ├── v0.4.0 │ ├── breaking-changes │ │ ├── 12-positional-args.md │ │ └── 13-entry-autogen.md │ └── summary.md ├── v0.4.1 │ ├── bug-fixes │ │ └── 19-component-name.md │ └── summary.md ├── v0.5.0 │ ├── breaking-changes │ │ └── 23-component-cfg.md │ └── summary.md ├── v0.5.1 │ ├── bug-fixes │ │ └── 38-escape-hash-sign.md │ └── summary.md ├── v0.6.0 │ ├── breaking-changes │ │ ├── 51-default-build-released-entries.md │ │ └── 60-release-version-flag.md │ ├── dependencies │ │ └── 49-switch-to-clap.md │ ├── features │ │ ├── 37-add-gitlab-support.md │ │ └── 47-prologue.md │ └── summary.md ├── v0.7.0 │ ├── features │ │ ├── 147-sort-changelog-entries.md │ │ ├── 148-sort-releases-by-date.md │ │ └── 149-local-dup-detection.md │ └── summary.md ├── v0.7.1 │ ├── enhancements │ │ └── 153-condense-find-dups-output.md │ └── summary.md ├── v0.7.2 │ └── summary.md └── v0.7.3 │ └── summary.md ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── rust.yml │ └── security.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── bin │ └── cli.rs ├── changelog.rs ├── changelog │ ├── change_set.rs │ ├── change_set_section.rs │ ├── component.rs │ ├── component_section.rs │ ├── config.rs │ ├── entry.rs │ ├── entry_path.rs │ ├── parsing_utils.rs │ └── release.rs ├── error.rs ├── fs_utils.rs ├── lib.rs ├── s11n.rs ├── s11n │ ├── from_str.rs │ └── optional_from_str.rs └── vcs.rs └── tests ├── full ├── config.toml ├── epilogue.md ├── expected-released-only.md ├── expected-sorted-by-entry-text.md ├── expected-sorted-by-release-date.md ├── expected.md ├── prologue.md ├── unreleased │ ├── features │ │ └── 45-travel.md │ └── improvements │ │ └── 43-eat-profile.md ├── v0.1.0 │ └── summary.md ├── v0.1.1 │ ├── bug-fixes │ │ └── 123-some-emergency-patch.md │ └── summary.md ├── v0.2.0-alpha │ ├── breaking-changes │ │ ├── 23-brown-drops.md │ │ ├── 25-tick-effect.md │ │ └── 26-eat-resort.md │ ├── improvements │ │ └── 21-hover-historian.md │ └── summary.md ├── v0.2.0-beta │ ├── features │ │ ├── 30-balance-garbage.md │ │ └── 33-spark-char.md │ ├── improvements │ │ └── 31-fan-shoe.md │ └── summary.md ├── v0.2.0 │ ├── breaking-changes │ │ ├── 41-tune-disaster.md │ │ └── 42-educate-specialist.md │ ├── features │ │ ├── 39-stir-engineer.md │ │ └── 40-attend-entry.md │ └── summary.md └── v0.2.1 │ ├── breaking-changes │ └── component2 │ │ ├── 73-gargle.md │ │ ├── 76-travel.md │ │ └── 80-laugh.md │ ├── features │ ├── 41-nibble.md │ ├── 42-carry.md │ ├── component1 │ │ ├── 44-fasten.md │ │ └── 45-hasten.md │ └── component2 │ │ ├── 50-waggle.md │ │ └── 53-drizzle.md │ └── summary.md └── integration.rs /.changelog/config.toml: -------------------------------------------------------------------------------- 1 | # The configuration file for unclog's changelog 2 | 3 | project_url = "https://github.com/informalsystems/unclog" 4 | -------------------------------------------------------------------------------- /.changelog/unreleased/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/informalsystems/unclog/326d2fd78a84b63ef594b0fb8169e1017f9dad66/.changelog/unreleased/.gitkeep -------------------------------------------------------------------------------- /.changelog/v0.1.0/summary.md: -------------------------------------------------------------------------------- 1 | The first release of `unclog`! 2 | 3 | Basic features include: 4 | 5 | * Building changelogs 6 | * Initialization of empty `.changelog` directories 7 | * Adding entries to the `unreleased` directory 8 | * Automating the process of releasing unreleased features 9 | 10 | See [README.md](README.md) for more details. 11 | -------------------------------------------------------------------------------- /.changelog/v0.1.1/improvements/1-fix-formatting.md: -------------------------------------------------------------------------------- 1 | * Fix the formatting of the rendered changelog to make the behaviour of joining 2 | paragraphs more predictable 3 | ([#1](https://github.com/informalsystems/unclog/pull/1)). 4 | -------------------------------------------------------------------------------- /.changelog/v0.1.1/summary.md: -------------------------------------------------------------------------------- 1 | A minor release that just focuses on improving output formatting. 2 | -------------------------------------------------------------------------------- /.changelog/v0.2.0/breaking-changes/2-entry-groups.md: -------------------------------------------------------------------------------- 1 | * Add support for grouping entries by way of their **component**. This refactors 2 | the interface for loading changelogs such that you first need to construct a 3 | `Project`, and then use the `Project` instance to read the changelog. 4 | **NOTE**: This interface is unstable and will most likely change. 5 | ([#2](https://github.com/informalsystems/unclog/issues/2)) 6 | -------------------------------------------------------------------------------- /.changelog/v0.2.0/features/004-build-unreleased.md: -------------------------------------------------------------------------------- 1 | * Added a `-u` or `--unreleased` flag to the `build` command to allow for only 2 | building the unreleased portion of the changelog 3 | ([#4](https://github.com/informalsystems/unclog/pull/4)) 4 | -------------------------------------------------------------------------------- /.changelog/v0.2.0/summary.md: -------------------------------------------------------------------------------- 1 | *22 June 2021* 2 | 3 | This release refactors some of the internals to provide support for grouping 4 | entries by way of their respective **components**. A "component" is effectively 5 | a module or sub-package within a project. More concretely, in a Rust project 6 | with multiple crates, a "component" is one of those crates. 7 | 8 | Right now, only Rust projects are really supported for this feature. If this 9 | would be useful to other types of projects, let us know and we'll look at adding 10 | such support. 11 | 12 | Having per-language support works around the need for a configuration file, 13 | letting the directory structures pack in as much meaning as possible. We could 14 | always, of course, simply add support for a configuration file in future, which 15 | could provide generic component support for any kind of project. 16 | 17 | Another useful feature provided in this release is the ability to only render 18 | unreleased changes. You can do so by running: 19 | 20 | ```bash 21 | unclog build --unreleased 22 | 23 | # Or 24 | unclog build -u 25 | ``` 26 | -------------------------------------------------------------------------------- /.changelog/v0.2.1/features/6-add-component-flag.md: -------------------------------------------------------------------------------- 1 | * Added the `--component` flag to the `add` command so that you can now specify 2 | a component when adding a new entry. 3 | ([#6](https://github.com/informalsystems/unclog/issues/6)) 4 | -------------------------------------------------------------------------------- /.changelog/v0.2.1/summary.md: -------------------------------------------------------------------------------- 1 | *23 July 2021* 2 | 3 | A minor release to augment the `add` command's functionality. 4 | -------------------------------------------------------------------------------- /.changelog/v0.3.0/breaking-changes/2-hyphens.md: -------------------------------------------------------------------------------- 1 | - Replace all asterisks with hyphens for Markdown-based bulleted lists (related 2 | to [#2](https://github.com/informalsystems/unclog/issues/2)) 3 | -------------------------------------------------------------------------------- /.changelog/v0.3.0/summary.md: -------------------------------------------------------------------------------- 1 | This is a minor breaking release that now favours the use of hyphens (`-`) in 2 | bulleted Markdown lists over asterisks (`*`). In future this will probably be 3 | configurable. 4 | -------------------------------------------------------------------------------- /.changelog/v0.4.0/breaking-changes/12-positional-args.md: -------------------------------------------------------------------------------- 1 | - All positional CLI arguments have now been replaced with flagged ones. See 2 | `unclog --help` and the project `README.md` for more details. 3 | ([#12](https://github.com/informalsystems/unclog/issues/12)) 4 | -------------------------------------------------------------------------------- /.changelog/v0.4.0/breaking-changes/13-entry-autogen.md: -------------------------------------------------------------------------------- 1 | - Unreleased entries can now automatically be added to changelogs from the CLI. 2 | This necessarily introduces configuration to be able to specify the project's 3 | GitHub URL ([#13](https://github.com/informalsystems/unclog/issues/13)) -------------------------------------------------------------------------------- /.changelog/v0.4.0/summary.md: -------------------------------------------------------------------------------- 1 | This version is a pretty major breaking change from the previous one. Some of 2 | the highlights: 3 | 4 | 1. Entries can now be automatically generated from the CLI. This is only 5 | available, however, for projects hosted on GitHub at the moment, since links 6 | to issues/pull requests need to be automatically generated. 7 | 2. A configuration file (`.changelog/config.toml`) can now be specified that 8 | allows you to override many of the default settings. See the `README.md` file 9 | for more details. 10 | 3. Components/submodules are no longer automatically detected and must be 11 | specified through the configuration file. This allows the greatest level of 12 | flexibility for all kinds of projects instead of limiting `unclog` to just 13 | Rust projects and implementing per-project-type component detection. 14 | 15 | -------------------------------------------------------------------------------- /.changelog/v0.4.1/bug-fixes/19-component-name.md: -------------------------------------------------------------------------------- 1 | - Fixed component name rendering 2 | ([#19](https://github.com/informalsystems/unclog/issues/19)) -------------------------------------------------------------------------------- /.changelog/v0.4.1/summary.md: -------------------------------------------------------------------------------- 1 | Just one minor bug fix relating to component rendering. 2 | -------------------------------------------------------------------------------- /.changelog/v0.5.0/breaking-changes/23-component-cfg.md: -------------------------------------------------------------------------------- 1 | - It is now required to add components to your `config.toml` 2 | file prior to creating entries referencing those components 3 | ([#23](https://github.com/informalsystems/unclog/issues/23)) -------------------------------------------------------------------------------- /.changelog/v0.5.0/summary.md: -------------------------------------------------------------------------------- 1 | *23 June 2022* 2 | 3 | This release includes a minor footgun guardrail and some minor improvements to 4 | the way I/O errors are reported. 5 | -------------------------------------------------------------------------------- /.changelog/v0.5.1/bug-fixes/38-escape-hash-sign.md: -------------------------------------------------------------------------------- 1 | - Escape \# in issue or PR number. 2 | ([\#38](https://github.com/informalsystems/unclog/issues/38)) 3 | -------------------------------------------------------------------------------- /.changelog/v0.5.1/summary.md: -------------------------------------------------------------------------------- 1 | *27 January 2023* 2 | 3 | A minor bug fix release with a small improvement to the way new entries are 4 | added from the CLI. 5 | -------------------------------------------------------------------------------- /.changelog/v0.6.0/breaking-changes/51-default-build-released-entries.md: -------------------------------------------------------------------------------- 1 | - When calling `unclog build`, unclog now only builds 2 | _released_ entries into the changelog. To build _all_ entries 3 | (including unreleased ones), please use `unclog build --all` 4 | ([\#51](https://github.com/informalsystems/unclog/issues/51)) -------------------------------------------------------------------------------- /.changelog/v0.6.0/breaking-changes/60-release-version-flag.md: -------------------------------------------------------------------------------- 1 | - When calling `unclog release`, the `--version` flag has been removed and 2 | has become a mandatory positional argument, e.g. `unclog release v0.1.0` 3 | ([\#60](https://github.com/informalsystems/unclog/pull/60)) -------------------------------------------------------------------------------- /.changelog/v0.6.0/dependencies/49-switch-to-clap.md: -------------------------------------------------------------------------------- 1 | - Switch from structopt to clap to remove dependency on now unmaintained 2 | `ansi_term` package, and update other dependencies where possible 3 | ([\#49](https://github.com/informalsystems/unclog/pull/49)) -------------------------------------------------------------------------------- /.changelog/v0.6.0/features/37-add-gitlab-support.md: -------------------------------------------------------------------------------- 1 | - Add support for GitLab repositories. 2 | ([#37](https://github.com/informalsystems/unclog/pull/37)) 3 | -------------------------------------------------------------------------------- /.changelog/v0.6.0/features/47-prologue.md: -------------------------------------------------------------------------------- 1 | - Add support for a prologue to be inserted at the beginning of the changelog 2 | ([\#47](https://github.com/informalsystems/unclog/issues/47)) -------------------------------------------------------------------------------- /.changelog/v0.6.0/summary.md: -------------------------------------------------------------------------------- 1 | *Mar 10, 2023* 2 | 3 | This release introduces a few CLI-breaking changes in order to improve user 4 | experience, so please review those breaking changes below carefully. In terms of 5 | non-breaking changes, unclog v0.6.0 now supports the insertion of an arbitrary 6 | prologue at the beginning of the changelog, in case you want some form of 7 | preamble to your changelog. 8 | 9 | Internally, the `structopt` package has been replaced by the latest version of 10 | `clap` to build unclog's CLI, since it appears to have a better support 11 | trajectory. 12 | 13 | Also, special thanks to @thaligar for adding support for GitLab projects! 14 | -------------------------------------------------------------------------------- /.changelog/v0.7.0/features/147-sort-changelog-entries.md: -------------------------------------------------------------------------------- 1 | - Add configuration file section `[change_set_sections]` with parameter 2 | `sort_entries_by` to sort entries in change set sections either by issue/PR 3 | number (`id`; default), or alphabetically (`entry-text`) 4 | ([\#147](https://github.com/informalsystems/unclog/pull/147)) 5 | -------------------------------------------------------------------------------- /.changelog/v0.7.0/features/148-sort-releases-by-date.md: -------------------------------------------------------------------------------- 1 | - Add ability to optionally sort releases by date 2 | to configuration - see the README for details 3 | ([\#148](https://github.com/informalsystems/unclog/pull/148)) -------------------------------------------------------------------------------- /.changelog/v0.7.0/features/149-local-dup-detection.md: -------------------------------------------------------------------------------- 1 | - Add CLI subcommand `find-duplicates` to assist in finding changelog entries 2 | that are duplicated across releases - see the README for more details 3 | ([\#149](https://github.com/informalsystems/unclog/pull/149)) -------------------------------------------------------------------------------- /.changelog/v0.7.0/summary.md: -------------------------------------------------------------------------------- 1 | *Dec 3, 2023* 2 | 3 | This release includes a few workflow enhancements that will hopefully make 4 | users' lives a little easier and cater for a few more variations of how 5 | changelogs are generated. 6 | -------------------------------------------------------------------------------- /.changelog/v0.7.1/enhancements/153-condense-find-dups-output.md: -------------------------------------------------------------------------------- 1 | - Condense `find-duplicates` output by not outputting `.changelog` 2 | directory path by default. To include the `.changelog` folder path, 3 | use the `--include-changelog-path` flag when calling `find-duplicates` 4 | ([\#153](https://github.com/informalsystems/unclog/pull/153)) -------------------------------------------------------------------------------- /.changelog/v0.7.1/summary.md: -------------------------------------------------------------------------------- 1 | *Dec 5, 2023* 2 | 3 | A small release to improve the readability of the `find-duplicates` command's 4 | output. 5 | -------------------------------------------------------------------------------- /.changelog/v0.7.2/summary.md: -------------------------------------------------------------------------------- 1 | *Feb 12, 2024* 2 | 3 | This release only provides a few dependency updates. 4 | -------------------------------------------------------------------------------- /.changelog/v0.7.3/summary.md: -------------------------------------------------------------------------------- 1 | *Feb 12, 2024* 2 | 3 | The v0.7.2 release was not completely published - this release fixes that. 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Automatically open PRs to update outdated deps 2 | # See https://docs.github.com/en/github/administering-a-repository/enabling-and-disabling-version-updates 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for cargo 7 | - package-ecosystem: "cargo" 8 | # Look for Cargo `.toml` and `.lock` files in the `root` directory 9 | directory: "/" 10 | # Check the cargo registry for updates every week 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Runs one last test prior to attempting to publish the crate to crates.io. If 2 | # the crate is published successfully, it creates a release and builds binaries 3 | # for macOS, Windows and Linux and attaches the binaries to the release. 4 | name: Release 5 | on: 6 | push: 7 | tags: 8 | - v[0-9]+.* 9 | 10 | jobs: 11 | publish-to-cratesio: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: dtolnay/rust-toolchain@stable 16 | # Last check prior to publishing 17 | - run: cargo test --all-features 18 | - run: cargo publish --token ${CRATES_TOKEN} 19 | env: 20 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} 21 | 22 | create-release: 23 | needs: publish-to-cratesio 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: taiki-e/create-gh-release-action@v1 28 | with: 29 | changelog: CHANGELOG.md 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | upload-assets: 34 | needs: publish-to-cratesio 35 | strategy: 36 | matrix: 37 | os: 38 | - ubuntu-latest 39 | - macos-latest 40 | - windows-latest 41 | runs-on: ${{ matrix.os }} 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: taiki-e/upload-rust-binary-action@v1 45 | with: 46 | bin: unclog 47 | # (optional) On which platform to distribute the `.tar.gz` file. 48 | # [default value: unix] 49 | # [possible values: all, unix, windows, none] 50 | tar: unix 51 | # (optional) On which platform to distribute the `.zip` file. 52 | # [default value: windows] 53 | # [possible values: all, unix, windows, none] 54 | zip: windows 55 | # (optional) Archive name (non-extension portion of filename) to be uploaded. 56 | # [default value: $bin-$target] 57 | # [possible values: the following variables and any string] 58 | # variables: 59 | # - $bin - Binary name (non-extension portion of filename). 60 | # - $target - Target triple. 61 | # - $tag - Tag of this release. 62 | archive: $bin-$tag-$target 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | CARGO_PROFILE_RELEASE_LTO: true 66 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | on: 3 | pull_request: 4 | merge_group: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | cleanup-runs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: rokroskar/workflow-run-cleanup-action@master 14 | env: 15 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 16 | if: "!startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/master'" 17 | 18 | fmt: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions-rs/toolchain@v1 23 | with: 24 | toolchain: stable 25 | override: true 26 | - uses: actions-rs/cargo@v1 27 | with: 28 | command: fmt 29 | args: --all -- --check 30 | 31 | clippy-check: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions-rs/toolchain@v1 36 | with: 37 | toolchain: stable 38 | components: clippy 39 | override: true 40 | - uses: actions-rs/clippy-check@v1 41 | with: 42 | name: clippy-results 43 | token: ${{ secrets.GITHUB_TOKEN }} 44 | args: --all-features --all-targets -- -Dwarnings -Drust-2018-idioms 45 | 46 | test: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v2 50 | - uses: actions-rs/toolchain@v1 51 | with: 52 | toolchain: stable 53 | override: true 54 | - uses: actions-rs/cargo@v1 55 | with: 56 | command: test 57 | args: --all-features 58 | -------------------------------------------------------------------------------- /.github/workflows/security.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | push: 6 | paths: 7 | - '**/Cargo.toml' 8 | - '**/Cargo.lock' 9 | 10 | jobs: 11 | security_audit: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | - uses: actions-rs/audit-check@v1 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.7.3 4 | 5 | *Feb 12, 2024* 6 | 7 | The v0.7.2 release was not completely published - this release fixes that. 8 | 9 | ## v0.7.2 10 | 11 | *Feb 12, 2024* 12 | 13 | This release only provides a few dependency updates. 14 | 15 | ## v0.7.1 16 | 17 | *Dec 5, 2023* 18 | 19 | A small release to improve the readability of the `find-duplicates` command's 20 | output. 21 | 22 | ### ENHANCEMENTS 23 | 24 | - Condense `find-duplicates` output by not outputting `.changelog` 25 | directory path by default. To include the `.changelog` folder path, 26 | use the `--include-changelog-path` flag when calling `find-duplicates` 27 | ([\#153](https://github.com/informalsystems/unclog/pull/153)) 28 | 29 | ## v0.7.0 30 | 31 | *Dec 3, 2023* 32 | 33 | This release includes a few workflow enhancements that will hopefully make 34 | users' lives a little easier and cater for a few more variations of how 35 | changelogs are generated. 36 | 37 | ### FEATURES 38 | 39 | - Add configuration file section `[change_set_sections]` with parameter 40 | `sort_entries_by` to sort entries in change set sections either by issue/PR 41 | number (`id`; default), or alphabetically (`entry-text`) 42 | ([\#147](https://github.com/informalsystems/unclog/pull/147)) 43 | - Add ability to optionally sort releases by date 44 | to configuration - see the README for details 45 | ([\#148](https://github.com/informalsystems/unclog/pull/148)) 46 | - Add CLI subcommand `find-duplicates` to assist in finding changelog entries 47 | that are duplicated across releases - see the README for more details 48 | ([\#149](https://github.com/informalsystems/unclog/pull/149)) 49 | 50 | ## v0.6.0 51 | 52 | *Mar 10, 2023* 53 | 54 | This release introduces a few CLI-breaking changes in order to improve user 55 | experience, so please review those breaking changes below carefully. In terms of 56 | non-breaking changes, unclog v0.6.0 now supports the insertion of an arbitrary 57 | prologue at the beginning of the changelog, in case you want some form of 58 | preamble to your changelog. 59 | 60 | Internally, the `structopt` package has been replaced by the latest version of 61 | `clap` to build unclog's CLI, since it appears to have a better support 62 | trajectory. 63 | 64 | Also, special thanks to @thaligar for adding support for GitLab projects! 65 | 66 | ### BREAKING CHANGES 67 | 68 | - When calling `unclog build`, unclog now only builds 69 | _released_ entries into the changelog. To build _all_ entries 70 | (including unreleased ones), please use `unclog build --all` 71 | ([\#51](https://github.com/informalsystems/unclog/issues/51)) 72 | - When calling `unclog release`, the `--version` flag has been removed and 73 | has become a mandatory positional argument, e.g. `unclog release v0.1.0` 74 | ([\#60](https://github.com/informalsystems/unclog/pull/60)) 75 | 76 | ### DEPENDENCIES 77 | 78 | - Switch from structopt to clap to remove dependency on now unmaintained 79 | `ansi_term` package, and update other dependencies where possible 80 | ([\#49](https://github.com/informalsystems/unclog/pull/49)) 81 | 82 | ### FEATURES 83 | 84 | - Add support for GitLab repositories. 85 | ([#37](https://github.com/informalsystems/unclog/pull/37)) 86 | - Add support for a prologue to be inserted at the beginning of the changelog 87 | ([\#47](https://github.com/informalsystems/unclog/issues/47)) 88 | 89 | ## v0.5.1 90 | 91 | *27 January 2023* 92 | 93 | A minor bug fix release with a small improvement to the way new entries are 94 | added from the CLI. 95 | 96 | ### BUG FIXES 97 | 98 | - Escape \# in issue or PR number. 99 | ([\#38](https://github.com/informalsystems/unclog/issues/38)) 100 | 101 | ## v0.5.0 102 | 103 | *23 June 2022* 104 | 105 | This release includes a minor footgun guardrail and some minor improvements to 106 | the way I/O errors are reported. 107 | 108 | ### BREAKING CHANGES 109 | 110 | - It is now required to add components to your `config.toml` 111 | file prior to creating entries referencing those components 112 | ([#23](https://github.com/informalsystems/unclog/issues/23)) 113 | 114 | ## v0.4.1 115 | 116 | Just one minor bug fix relating to component rendering. 117 | 118 | ### BUG FIXES 119 | 120 | - Fixed component name rendering 121 | ([#19](https://github.com/informalsystems/unclog/issues/19)) 122 | 123 | ## v0.4.0 124 | 125 | This version is a pretty major breaking change from the previous one. Some of 126 | the highlights: 127 | 128 | 1. Entries can now be automatically generated from the CLI. This is only 129 | available, however, for projects hosted on GitHub at the moment, since links 130 | to issues/pull requests need to be automatically generated. 131 | 2. A configuration file (`.changelog/config.toml`) can now be specified that 132 | allows you to override many of the default settings. See the `README.md` file 133 | for more details. 134 | 3. Components/submodules are no longer automatically detected and must be 135 | specified through the configuration file. This allows the greatest level of 136 | flexibility for all kinds of projects instead of limiting `unclog` to just 137 | Rust projects and implementing per-project-type component detection. 138 | 139 | ### BREAKING CHANGES 140 | 141 | - All positional CLI arguments have now been replaced with flagged ones. See 142 | `unclog --help` and the project `README.md` for more details. 143 | ([#12](https://github.com/informalsystems/unclog/issues/12)) 144 | - Unreleased entries can now automatically be added to changelogs from the CLI. 145 | This necessarily introduces configuration to be able to specify the project's 146 | GitHub URL ([#13](https://github.com/informalsystems/unclog/issues/13)) 147 | 148 | ## v0.3.0 149 | 150 | This is a minor breaking release that now favours the use of hyphens (`-`) in 151 | bulleted Markdown lists over asterisks (`*`). In future this will probably be 152 | configurable. 153 | 154 | ### BREAKING CHANGES 155 | 156 | - Replace all asterisks with hyphens for Markdown-based bulleted lists (related 157 | to [#2](https://github.com/informalsystems/unclog/issues/2)) 158 | 159 | ## v0.2.1 160 | 161 | *23 July 2021* 162 | 163 | A minor release to augment the `add` command's functionality. 164 | 165 | ### FEATURES 166 | 167 | * Added the `--component` flag to the `add` command so that you can now specify 168 | a component when adding a new entry. 169 | ([#6](https://github.com/informalsystems/unclog/issues/6)) 170 | 171 | ## v0.2.0 172 | 173 | *22 June 2021* 174 | 175 | This release refactors some of the internals to provide support for grouping 176 | entries by way of their respective **components**. A "component" is effectively 177 | a module or sub-package within a project. More concretely, in a Rust project 178 | with multiple crates, a "component" is one of those crates. 179 | 180 | Right now, only Rust projects are really supported for this feature. If this 181 | would be useful to other types of projects, let us know and we'll look at adding 182 | such support. 183 | 184 | Having per-language support works around the need for a configuration file, 185 | letting the directory structures pack in as much meaning as possible. We could 186 | always, of course, simply add support for a configuration file in future, which 187 | could provide generic component support for any kind of project. 188 | 189 | Another useful feature provided in this release is the ability to only render 190 | unreleased changes. You can do so by running: 191 | 192 | ```bash 193 | unclog build --unreleased 194 | 195 | # Or 196 | unclog build -u 197 | ``` 198 | 199 | ### BREAKING CHANGES 200 | 201 | * Add support for grouping entries by way of their **component**. This refactors 202 | the interface for loading changelogs such that you first need to construct a 203 | `Project`, and then use the `Project` instance to read the changelog. 204 | **NOTE**: This interface is unstable and will most likely change. 205 | ([#2](https://github.com/informalsystems/unclog/issues/2)) 206 | 207 | ### FEATURES 208 | 209 | * Added a `-u` or `--unreleased` flag to the `build` command to allow for only 210 | building the unreleased portion of the changelog 211 | ([#4](https://github.com/informalsystems/unclog/pull/4)) 212 | 213 | ## v0.1.1 214 | 215 | A minor release that just focuses on improving output formatting. 216 | 217 | ### IMPROVEMENTS 218 | 219 | * Fix the formatting of the rendered changelog to make the behaviour of joining 220 | paragraphs more predictable 221 | ([#1](https://github.com/informalsystems/unclog/pull/1)). 222 | 223 | ## v0.1.0 224 | 225 | The first release of `unclog`! 226 | 227 | Basic features include: 228 | 229 | * Building changelogs 230 | * Initialization of empty `.changelog` directories 231 | * Adding entries to the `unreleased` directory 232 | * Automating the process of releasing unreleased features 233 | 234 | See [README.md](README.md) for more details. 235 | 236 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "unclog" 3 | version = "0.7.3" 4 | authors = ["Thane Thomson "] 5 | edition = "2021" 6 | license = "Apache-2.0" 7 | homepage = "https://github.com/informalsystems/unclog" 8 | repository = "https://github.com/informalsystems/unclog" 9 | readme = "README.md" 10 | categories = ["development-tools"] 11 | keywords = ["changelog", "markdown"] 12 | description = """ 13 | unclog allows you to build your changelog from a collection of independent 14 | files. This helps prevent annoying and unnecessary merge conflicts when 15 | collaborating on shared codebases.""" 16 | 17 | [[bin]] 18 | name = "unclog" 19 | path = "src/bin/cli.rs" 20 | required-features = ["cli"] 21 | 22 | [features] 23 | default = ["cli"] 24 | cli = ["simplelog", "clap", "tempfile"] 25 | 26 | [dependencies] 27 | git2 = "0.19" 28 | handlebars = "5.1" 29 | log = "0.4" 30 | semver = "1.0" 31 | serde = { version = "1.0", features = ["derive"] } 32 | serde_json = "1.0" 33 | textwrap = "0.16" 34 | thiserror = "1.0" 35 | toml = "0.8" 36 | url = "2.5" 37 | 38 | simplelog = { version = "0.12", optional = true } 39 | clap = { version = "4.5", features = ["derive", "env"], optional = true } 40 | tempfile = { version = "3.10", optional = true } 41 | chrono = "0.4.38" 42 | comfy-table = "7.1.1" 43 | 44 | [dev-dependencies] 45 | env_logger = "0.11" 46 | lazy_static = "1.4" 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # unclog 2 | 3 | [![Crate][crate-image]][crate-link] 4 | [![Docs][docs-image]][docs-link] 5 | [![Build Status][build-image]][build-link] 6 | [![Apache 2.0 Licensed][license-image]][license-link] 7 | ![Rust Stable][rustc-image] 8 | 9 | **Unclog your changelog** 10 | 11 | Build your changelog from a structured collection of independent files in your 12 | project's `.changelog` folder. This helps avoid annoying merge conflicts when 13 | working on multiple PRs simultaneously. 14 | 15 | It's assumed your changelog will be output in **Markdown** format. 16 | 17 | ### Why not just use the Git commit history? 18 | 19 | Many other tools that provide similar functionality focus on extracting 20 | changelog entries from the project's Git commit history. Why don't we just do 21 | this? 22 | 23 | We find value in targeting different audiences with each kind of content, and 24 | being able to tailor content to each audience: Git commit histories for our 25 | *developers*, and changelogs for our *users*. 26 | 27 | ## Requirements 28 | 29 | - Tested using latest Rust stable 30 | - Git 31 | - Your project is hosted on GitHub or GitLab (for automatic changelog entry 32 | generation from the CLI) 33 | 34 | ## Installation 35 | 36 | ```bash 37 | # Install to ~/.cargo/bin/ 38 | cargo install unclog 39 | ``` 40 | 41 | Or you can build from source: 42 | 43 | ```bash 44 | cargo install --git https://github.com/informalsystems/unclog.git 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### Example `.changelog` folder 50 | 51 | An example layout for a project's `.changelog` folder is as follows: 52 | 53 | ``` 54 | .changelog/ - The project's .changelog folder, in the root of the repo. 55 | |__ unreleased/ - Changes to be released in the next version. 56 | | |__ breaking-changes/ - "BREAKING CHANGES" section entries. 57 | | | |__ 890-block.md - An entry in the "BREAKING CHANGES" section. 58 | | | 59 | | |__ bug-fixes/ - "BUG FIXES" section entries. 60 | | | |__ module1/ - "BUG FIXES" section entries specific to "module1". 61 | | | |__ 745-rename.md - An entry in the "BUG FIXES" section under "module1". 62 | | |__ features/ - "FEATURES" section entries. 63 | | | 64 | | |__ summary.md - A summary of the next release. 65 | | 66 | |__ v0.1.0/ - Changes released historically in v0.1.0. 67 | | |__ breaking-changes/ - "BREAKING CHANGES" section entries for v0.1.0. 68 | | | |__ 467-api.md - An entry in the "BREAKING CHANGES" section for v0.1.0. 69 | | | |__ 479-rpc.md - Another entry in the "BREAKING CHANGES" section for v0.1.0. 70 | | | 71 | | |__ bug-fixes/ - "BUG FIXES" section entries for v0.1.0. 72 | | | 73 | | |__ summary.md - A summary of release v0.1.0. 74 | | 75 | |__ epilogue.md - Any content to be added to the end of the generated CHANGELOG. 76 | ``` 77 | 78 | For a more detailed example, see the [`tests/full`](./tests/full) folder for 79 | the primary integration test that uses the most features/functionality. The 80 | file [`tests/full/expected.md`](./tests/full/expected.md) is the expected 81 | output when building the files in `tests/full`. 82 | 83 | ### CLI 84 | 85 | ```bash 86 | # Detailed information regarding usage. 87 | unclog -h 88 | ``` 89 | 90 | #### Initializing a changelog 91 | 92 | ```bash 93 | # Creates a ".changelog" folder in the current directory. 94 | unclog init 95 | 96 | # Creates a ".changelog" folder in the current directory, and also copies your 97 | # existing CHANGELOG.md into it as an epilogue (to be appended at the end of 98 | # the final changelog built by unclog). 99 | unclog init -e CHANGELOG.md 100 | 101 | # Automatically generate a `config.toml` file for your changelog, inferring as 102 | # many settings as possible from the environment. (Right now this mainly infers 103 | # your GitHub project URL, if it's a GitHub project) 104 | unclog init -g 105 | ``` 106 | 107 | #### Adding a new unreleased entry 108 | 109 | There are two ways of adding a new entry: 110 | 111 | 1. Entirely through the CLI 112 | 2. By way of your default `$EDITOR` 113 | 114 | To add an entry entirely through the CLI: 115 | 116 | ```bash 117 | # First ensure your config.toml file contains the project URL: 118 | echo 'project_url = "https://github.com/org/project"' >> .changelog/config.toml 119 | 120 | # Add a new entry whose associated GitHub issue number is 23. 121 | # Word wrapping will automatically be applied at the boundary specified in your 122 | # `config.toml` file. 123 | unclog add --id some-new-feature \ 124 | --issue 23 \ 125 | --section breaking-changes \ 126 | --message "Some *new* feature" 127 | 128 | # Same as above, but with shortened parameters 129 | unclog add -i some-new-feature \ 130 | -n 23 \ 131 | -s breaking-changes \ 132 | -m "Some *new* feature" 133 | 134 | # If your project uses components/sub-modules 135 | unclog add -i some-new-feature \ 136 | -n 23 \ 137 | -c submodule \ 138 | -s breaking-changes \ 139 | -m "Some *new* feature" 140 | ``` 141 | 142 | To add an entry with your favourite `$EDITOR`: 143 | 144 | ```bash 145 | # First ensure that your $EDITOR environment variable is configured, or you can 146 | # manually specify an editor binary path via the --editor flag. 147 | # 148 | # This will launch your configured editor and, if you add any content to the 149 | # feature file it will be added to 150 | # ".changelog/unreleased/features/23-some-new-feature.md". 151 | # 152 | # The convention is that you *must* prepend the issue/PR number to which the 153 | # change refers to the entry ID (i.e. 23-some-new-feature relates to issue 23). 154 | unclog add --section features --id 23-some-new-feature 155 | 156 | # Add another feature in a different section 157 | unclog add -s breaking-changes -i 24-break-the-api 158 | ``` 159 | 160 | The format of an entry is currently recommended as the following (in Markdown): 161 | 162 | ```markdown 163 | - A user-oriented description of the change ([#123](https://github.com/someone/someproject/issues/123)) 164 | ``` 165 | 166 | The `#123` and its corresponding link is ideally a link to the issue being 167 | resolved. If there's no issue, then reference the PR. 168 | 169 | #### Building a changelog 170 | 171 | ```bash 172 | # Run from your project's directory to build your '.changelog' folder. 173 | # Builds your CHANGELOG.md and writes it to stdout. Does not build any 174 | # unreleased entries. 175 | unclog build 176 | 177 | # Only render unreleased changes (returns an error if none) 178 | unclog build --unreleased-only 179 | unclog build -u 180 | 181 | # Build all entries, both released and unreleased. 182 | unclog build --all 183 | unclog build -a 184 | 185 | # Save the output as your new CHANGELOG.md file. 186 | # NOTE: All logging output goes to stderr. 187 | unclog build > CHANGELOG.md 188 | 189 | # Increase output logging verbosity on stderr and build your `.changelog` 190 | # folder. 191 | unclog -v build 192 | 193 | # Get help 194 | unclog --help 195 | ``` 196 | 197 | #### Releasing a new version's change set 198 | 199 | ```bash 200 | # Moves all entries in your ".changelog/unreleased" folder to 201 | # ".changelog/v0.2.0" and ensures the ".changelog/unreleased" folder is empty. 202 | unclog release v0.2.0 203 | ``` 204 | 205 | ### Components/Submodules 206 | 207 | If your project has components or submodules to it, referencing them when 208 | creating changelog entries allows you to group entries for one component 209 | together. For example: 210 | 211 | ```bash 212 | unclog add -i some-new-feature \ 213 | -n 23 \ 214 | -c submodule \ 215 | -s breaking-changes \ 216 | -m "Some *new* feature" 217 | ``` 218 | 219 | would result in an entry being created in 220 | `.changelog/unreleased/submodule/breaking-changes/23-some-new-feature.md` which, 221 | when rendered, would look like: 222 | 223 | ```markdown 224 | - [submodule](./submodule) 225 | - Some *new* feature ([#23](https://github.com/org/project/issues/23)) 226 | ``` 227 | 228 | NB: As of v0.5.0, you must first define your components in your configuration 229 | file (see below) prior to attempting to add an entry that references any of your 230 | components. Otherwise `unclog` will fail. This is to ensure that people don't 231 | add entries for incorrectly named or non-existent components. 232 | 233 | ### Duplicate detection 234 | 235 | `unclog` has a convenience method to assist in finding duplicate entries across 236 | releases on your local branch (as per [\#81], a future version aims to provide 237 | this _across_ Git repository branches). 238 | 239 | ```bash 240 | # List all the duplicate entries across releases. 241 | unclog find-duplicates 242 | ``` 243 | 244 | ### Configuration 245 | 246 | Certain `unclog` settings can be overridden through the use of a configuration 247 | file in `.changelog/config.toml`. The following TOML shows all of the defaults 248 | for the configuration. If you don't have a `.changelog/config.toml` file, all of 249 | the defaults will be assumed. 250 | 251 | ```toml 252 | # The GitHub URL for your project. 253 | # 254 | # This is mainly necessary if you need to automatically generate changelog 255 | # entries directly from the CLI. Right now we only support GitHub, but if 256 | # anyone wants GitLab support please let us know and we'll try implement it 257 | # too. 258 | project_url = "https://github.com/org/project" 259 | 260 | # The file to use as a Handlebars template for changes added directly through 261 | # the CLI. 262 | # 263 | # Assumes that relative paths are relative to the `.changelog` folder. If this 264 | # file does not exist, a default template will be used. 265 | change_template = "change-template.md" 266 | 267 | # The number of characters at which to wrap entries automatically added from 268 | # the CLI. 269 | wrap = 80 270 | 271 | # The heading right at the beginning of the changelog. 272 | heading = "# CHANGELOG" 273 | 274 | # What style of bullet to use for the instances where unclog has to generate 275 | # bullets for you. Can be "-" or "*". 276 | bullet_style = "-" 277 | 278 | # The message to output when your changelog has no entries yet. 279 | empty_msg = "Nothing to see here! Add some entries to get started." 280 | 281 | # The name of the file (relative to the `.changelog` directory) to use as an 282 | # epilogue for your changelog (will be appended as-is to the end of your 283 | # generated changelog). 284 | epilogue_filename = "epilogue.md" 285 | 286 | # Sort releases by the given property/properties. Possible values include: 287 | # - `version` : Sort releases by semantic version. 288 | # - `date` : Sort releases by release date. 289 | # 290 | # This is an array, as one could potentially first sort by date and then version 291 | # in cases where multiple releases were cut on the same date. 292 | # 293 | # Release dates are currently parsed from release summaries, and are expected to 294 | # be located on the first line of the release summary. 295 | sort_releases_by = ["version"] 296 | 297 | # Release date formats to expect in the release summary, in order of precedence. 298 | # 299 | # See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for 300 | # possible format specifiers. 301 | release_date_formats = [ 302 | # "*December 1, 2023* 303 | "*%B %d, %Y*", 304 | # "*Dec 1, 2023* 305 | "*%b %d, %Y*", 306 | # "2023-12-01" (ISO format) 307 | "%F", 308 | ] 309 | 310 | 311 | # Settings relating to unreleased changelog entries. 312 | [unreleased] 313 | 314 | # The name of the folder containing unreleased entries, relative to the 315 | # `.changelog` folder. 316 | folder = "unreleased" 317 | 318 | # The heading to use for the unreleased entries section. 319 | heading = "## Unreleased" 320 | 321 | 322 | # Settings relating to sets (groups) of changes in the changelog. For example, a 323 | # particular version of the software (e.g. "v1.0.0") is typically a change set. 324 | [change_sets] 325 | 326 | # The filename containing a summary of the intended changes. Relative to the 327 | # change set folder (e.g. `.changelog/unreleased/breaking-changes/summary.md`). 328 | summary_filename = "summary.md" 329 | 330 | # The extension of files in a change set. 331 | entry_ext = "md" 332 | 333 | 334 | # Settings relating to all sections within a change set. For example, the 335 | # "BREAKING CHANGES" section for a particular release is a change set section. 336 | [change_set_sections] 337 | 338 | # Sort entries by a particular property. Possible values include: 339 | # - `id` : The issue/PR number (the default value). 340 | # - `entry-text` : The entry text itself. 341 | sort_entries_by = "id" 342 | 343 | 344 | # Settings related to components/sub-modules. Only relevant if you make use of 345 | # components/sub-modules. 346 | [components] 347 | 348 | # The title to use for the section of entries not relating to a specific 349 | # component. 350 | general_entries_title = "General" 351 | 352 | # The number of spaces to inject before each component-related entry. 353 | entry_indent = 2 354 | 355 | # The components themselves. Each component has a name (used when rendered 356 | # to Markdown) and a path relative to the project folder (i.e. relative to 357 | # the parent of the `.changelog` folder). 358 | [components.all] 359 | component1 = { name = "Component 1", path = "component1" } 360 | docs = { name = "Documentation", path = "docs" } 361 | ``` 362 | 363 | ### As a Library 364 | 365 | By default, the `cli` feature is enabled, which builds the CLI. To use `unclog` 366 | as a library instead without the CLI: 367 | 368 | ```toml 369 | [dependencies] 370 | unclog = { version = "0.6", default-features = false } 371 | ``` 372 | 373 | ## License 374 | 375 | Copyright © 2021- Informal Systems and contributors 376 | 377 | Licensed under the Apache License, Version 2.0 (the "License"); 378 | you may not use the files in this repository except in compliance with the License. 379 | You may obtain a copy of the License at 380 | 381 | https://www.apache.org/licenses/LICENSE-2.0 382 | 383 | Unless required by applicable law or agreed to in writing, software 384 | distributed under the License is distributed on an "AS IS" BASIS, 385 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 386 | See the License for the specific language governing permissions and 387 | limitations under the License. 388 | 389 | [crate-image]: https://img.shields.io/crates/v/unclog.svg 390 | [crate-link]: https://crates.io/crates/unclog 391 | [docs-image]: https://docs.rs/unclog/badge.svg 392 | [docs-link]: https://docs.rs/unclog/ 393 | [build-image]: https://github.com/informalsystems/unclog/workflows/Rust/badge.svg 394 | [build-link]: https://github.com/informalsystems/unclog/actions?query=workflow%3ARust 395 | [audit-image]: https://github.com/informalsystems/unclog/workflows/Audit-Check/badge.svg 396 | [audit-link]: https://github.com/informalsystems/unclog/actions?query=workflow%3AAudit-Check 397 | [license-image]: https://img.shields.io/badge/license-Apache2.0-blue.svg 398 | [license-link]: https://github.com/informalsystems/unclog/blob/main/LICENSE 399 | [rustc-image]: https://img.shields.io/badge/rustc-stable-blue.svg 400 | [\#81]: https://github.com/informalsystems/unclog/issues/81 401 | -------------------------------------------------------------------------------- /src/bin/cli.rs: -------------------------------------------------------------------------------- 1 | //! `unclog` helps you build your changelog. 2 | 3 | use clap::{Parser, Subcommand, ValueEnum}; 4 | use log::error; 5 | use simplelog::{ColorChoice, LevelFilter, TermLogger, TerminalMode}; 6 | use std::path::{Path, PathBuf}; 7 | use unclog::{Changelog, Config, Error, PlatformId, Result}; 8 | 9 | const RELEASE_SUMMARY_TEMPLATE: &str = r#" 14 | "#; 15 | 16 | const ADD_CHANGE_TEMPLATE: &str = r#" 21 | "#; 22 | 23 | const DEFAULT_CHANGELOG_DIR: &str = ".changelog"; 24 | const DEFAULT_CONFIG_FILENAME: &str = "config.toml"; 25 | 26 | #[derive(Parser)] 27 | #[command(author, version, about, long_about = None)] 28 | struct Opt { 29 | /// The path to the changelog folder. 30 | #[arg(short, long, default_value = DEFAULT_CHANGELOG_DIR)] 31 | path: PathBuf, 32 | 33 | /// The path to the changelog configuration file. If a relative path is 34 | /// provided, it is assumed this is relative to the `path` parameter. If no 35 | /// configuration file exists, defaults will be used for all parameters. 36 | #[arg(short, long, default_value = DEFAULT_CONFIG_FILENAME)] 37 | config_file: PathBuf, 38 | 39 | /// Increase output logging verbosity to DEBUG level. 40 | #[arg(short, long)] 41 | verbose: bool, 42 | 43 | /// Suppress all output logging (overrides `--verbose`). 44 | #[arg(short, long)] 45 | quiet: bool, 46 | 47 | #[command(subcommand)] 48 | cmd: Command, 49 | } 50 | 51 | #[derive(Subcommand)] 52 | enum Command { 53 | /// Create and initialize a fresh .changelog folder. 54 | Init { 55 | /// The path to a prologue to optionally prepend to the changelog. 56 | #[arg(name = "prologue", short, long)] 57 | maybe_prologue_path: Option, 58 | 59 | /// The path to an epilogue to optionally append to the new changelog. 60 | #[arg(name = "epilogue", short, long)] 61 | maybe_epilogue_path: Option, 62 | 63 | /// Automatically generate a `config.toml` file for your changelog, 64 | /// inferring parameters from your environment. This is the same as 65 | /// running `unclog generate-config` after `unclog init`. 66 | #[arg(short, long)] 67 | gen_config: bool, 68 | 69 | /// If automatically generating configuration, the Git remote from which 70 | /// to infer the project URL. 71 | #[arg(short, long, default_value = "origin")] 72 | remote: String, 73 | }, 74 | /// Automatically generate a configuration file, attempting to infer as many 75 | /// parameters as possible from your project's environment. 76 | GenerateConfig { 77 | /// The Git remote from which to infer the project URL. 78 | #[arg(short, long, default_value = "origin")] 79 | remote: String, 80 | 81 | /// Overwrite any existing configuration file. 82 | #[arg(short, long)] 83 | force: bool, 84 | }, 85 | /// Add a change to the unreleased set of changes. 86 | Add { 87 | /// The path to the editor to use to edit the details of the change. 88 | #[arg(long, env = "EDITOR")] 89 | editor: PathBuf, 90 | 91 | /// The component to which this entry should be added. 92 | #[arg(name = "component", short, long)] 93 | maybe_component: Option, 94 | 95 | /// The ID of the section to which the change must be added (e.g. 96 | /// "breaking-changes"). 97 | #[arg(short, long)] 98 | section: String, 99 | 100 | /// The ID of the change to add, which should include the number of the 101 | /// issue or PR to which the change applies (e.g. "820-change-api"). 102 | #[arg(short, long)] 103 | id: String, 104 | 105 | /// The issue number associated with this change, if any. Only relevant 106 | /// if the `--message` flag is also provided. Only one of the 107 | /// `--issue-no` or `--pull-request` flags can be specified at a time. 108 | #[arg(name = "issue_no", short = 'n', long = "issue-no")] 109 | maybe_issue_no: Option, 110 | 111 | /// The number of the pull request associated with this change, if any. 112 | /// Only relevant if the `--message` flag is also provided. Only one of 113 | /// the `--issue-no` or `--pull-request` flags can be specified at a 114 | /// time. 115 | #[arg(name = "pull_request", short, long = "pull-request")] 116 | maybe_pull_request: Option, 117 | 118 | /// If specified, the change will automatically be generated from the 119 | /// default change template. Requires a project URL to be specified in 120 | /// the changelog configuration file. 121 | #[arg(name = "message", short, long)] 122 | maybe_message: Option, 123 | }, 124 | /// Searches for duplicate entries across releases in this changelog. 125 | FindDuplicates { 126 | /// Include the changelog path (usually ".changelog") in entry paths 127 | /// when listing them. 128 | #[arg(long)] 129 | include_changelog_path: bool, 130 | 131 | /// The format to use when writing the duplicates to stdout. 132 | #[arg(value_enum, short, long, default_value = "simple")] 133 | format: DuplicatesOutputFormat, 134 | }, 135 | /// Build the changelog from the input path and write the output to stdout. 136 | Build { 137 | /// Render all changes, including released and unreleased ones. 138 | #[arg(short, long)] 139 | all: bool, 140 | /// Only render unreleased changes. 141 | #[arg(short, long)] 142 | unreleased_only: bool, 143 | }, 144 | /// Release any unreleased features. 145 | Release { 146 | /// The path to the editor to use to edit the release summary. 147 | #[arg(long, env = "EDITOR")] 148 | editor: PathBuf, 149 | 150 | /// The version string to use for the new release (e.g. "v0.1.0"). 151 | version: String, 152 | }, 153 | } 154 | 155 | #[derive(Debug, Clone, Default, Copy, ValueEnum)] 156 | enum DuplicatesOutputFormat { 157 | /// A simple table with no borders. 158 | #[default] 159 | Simple, 160 | /// A table with borders made of ASCII characters. 161 | AsciiTable, 162 | } 163 | 164 | fn main() { 165 | let opt: Opt = Opt::parse(); 166 | TermLogger::init( 167 | if opt.quiet { 168 | LevelFilter::Off 169 | } else if opt.verbose { 170 | LevelFilter::Debug 171 | } else { 172 | LevelFilter::Info 173 | }, 174 | Default::default(), 175 | TerminalMode::Stderr, 176 | ColorChoice::Auto, 177 | ) 178 | .unwrap(); 179 | 180 | let config_path = if opt.config_file.is_relative() { 181 | opt.path.join(opt.config_file) 182 | } else { 183 | opt.config_file 184 | }; 185 | let config = Config::read_from_file(&config_path).unwrap(); 186 | 187 | let result = match opt.cmd { 188 | Command::Init { 189 | maybe_prologue_path, 190 | maybe_epilogue_path, 191 | gen_config, 192 | remote, 193 | } => init_changelog( 194 | &config, 195 | &opt.path, 196 | &config_path, 197 | maybe_prologue_path, 198 | maybe_epilogue_path, 199 | gen_config, 200 | &remote, 201 | ), 202 | Command::GenerateConfig { remote, force } => { 203 | Changelog::generate_config(&config_path, opt.path, remote, force) 204 | } 205 | Command::Build { 206 | all, 207 | unreleased_only, 208 | } => build_changelog(&config, &opt.path, all, unreleased_only), 209 | Command::Add { 210 | editor, 211 | maybe_component, 212 | section, 213 | id, 214 | maybe_issue_no, 215 | maybe_pull_request, 216 | maybe_message, 217 | } => match maybe_message { 218 | Some(message) => match maybe_issue_no { 219 | Some(issue_no) => match maybe_pull_request { 220 | Some(_) => Err(Error::EitherIssueNoOrPullRequest), 221 | None => Changelog::add_unreleased_entry_from_template( 222 | &config, 223 | &opt.path, 224 | §ion, 225 | maybe_component, 226 | &id, 227 | PlatformId::Issue(issue_no), 228 | &message, 229 | ), 230 | }, 231 | None => match maybe_pull_request { 232 | Some(pull_request) => Changelog::add_unreleased_entry_from_template( 233 | &config, 234 | &opt.path, 235 | §ion, 236 | maybe_component, 237 | &id, 238 | PlatformId::PullRequest(pull_request), 239 | &message, 240 | ), 241 | None => Err(Error::MissingIssueNoOrPullRequest), 242 | }, 243 | }, 244 | None => add_unreleased_entry_with_editor( 245 | &config, 246 | &editor, 247 | &opt.path, 248 | §ion, 249 | maybe_component, 250 | &id, 251 | ), 252 | }, 253 | Command::FindDuplicates { 254 | include_changelog_path, 255 | format, 256 | } => find_duplicates(&config, &opt.path, include_changelog_path, format), 257 | Command::Release { editor, version } => { 258 | prepare_release(&config, &editor, &opt.path, &version) 259 | } 260 | }; 261 | if let Err(e) = result { 262 | error!("Failed: {}", e); 263 | std::process::exit(1); 264 | } 265 | } 266 | 267 | fn init_changelog( 268 | config: &Config, 269 | path: &Path, 270 | config_path: &Path, 271 | maybe_prologue_path: Option, 272 | maybe_epilogue_path: Option, 273 | gen_config: bool, 274 | remote: &str, 275 | ) -> Result<()> { 276 | Changelog::init_dir(config, path, maybe_prologue_path, maybe_epilogue_path)?; 277 | if gen_config { 278 | Changelog::generate_config(config_path, path, remote, true) 279 | } else { 280 | Ok(()) 281 | } 282 | } 283 | 284 | fn build_changelog(config: &Config, path: &Path, all: bool, unreleased_only: bool) -> Result<()> { 285 | if all && unreleased_only { 286 | return Err(Error::CommandLine( 287 | "cannot combine --all and --unreleased-only flags when building the changelog" 288 | .to_string(), 289 | )); 290 | } 291 | let changelog = Changelog::read_from_dir(config, path)?; 292 | log::info!("Success!"); 293 | if unreleased_only { 294 | println!("{}", changelog.render_unreleased(config)?); 295 | } else if all { 296 | println!("{}", changelog.render_all(config)); 297 | } else { 298 | println!("{}", changelog.render_released(config)); 299 | } 300 | Ok(()) 301 | } 302 | 303 | fn add_unreleased_entry_with_editor( 304 | config: &Config, 305 | editor: &Path, 306 | path: &Path, 307 | section: &str, 308 | component: Option, 309 | id: &str, 310 | ) -> Result<()> { 311 | let entry_path = Changelog::get_entry_path( 312 | config, 313 | path, 314 | &config.unreleased.folder, 315 | section, 316 | component.clone(), 317 | id, 318 | ); 319 | if std::fs::metadata(&entry_path).is_ok() { 320 | return Err(Error::FileExists(entry_path.display().to_string())); 321 | } 322 | 323 | let tmpdir = 324 | tempfile::tempdir().map_err(|e| Error::Io(Path::new("tempdir").to_path_buf(), e))?; 325 | let tmpfile_path = tmpdir.path().join("entry.md"); 326 | std::fs::write(&tmpfile_path, ADD_CHANGE_TEMPLATE) 327 | .map_err(|e| Error::Io(tmpfile_path.clone(), e))?; 328 | 329 | // Run the user's editor and wait for the process to exit 330 | let _ = std::process::Command::new(editor) 331 | .arg(&tmpfile_path) 332 | .status() 333 | .map_err(|e| Error::Subprocess(editor.to_str().unwrap().to_string(), e))?; 334 | 335 | // Check if the temporary file's content's changed, and that it's not empty 336 | let tmpfile_content = std::fs::read_to_string(&tmpfile_path) 337 | .map_err(|e| Error::Io(tmpfile_path.to_path_buf(), e))?; 338 | if tmpfile_content.is_empty() || tmpfile_content == ADD_CHANGE_TEMPLATE { 339 | log::info!("No changes to entry - not adding new entry to changelog"); 340 | return Ok(()); 341 | } 342 | 343 | Changelog::add_unreleased_entry(config, path, section, component, id, &tmpfile_content) 344 | } 345 | 346 | fn find_duplicates( 347 | config: &Config, 348 | path: &Path, 349 | include_changelog_path: bool, 350 | output_format: DuplicatesOutputFormat, 351 | ) -> Result<()> { 352 | let changelog = Changelog::read_from_dir(config, path)?; 353 | let dups = changelog.find_duplicates_across_releases(); 354 | if dups.is_empty() { 355 | log::info!("No duplicates found"); 356 | return Ok(()); 357 | } 358 | log::info!("Found {} duplicate(s)", dups.len()); 359 | 360 | let mut table = comfy_table::Table::new(); 361 | table.load_preset(match output_format { 362 | DuplicatesOutputFormat::Simple => comfy_table::presets::NOTHING, 363 | DuplicatesOutputFormat::AsciiTable => comfy_table::presets::ASCII_FULL, 364 | }); 365 | 366 | for (entry_path_a, entry_path_b) in dups { 367 | let base_path = if include_changelog_path { 368 | path.to_owned() 369 | } else { 370 | PathBuf::new() 371 | }; 372 | table.add_row(vec![ 373 | base_path.join(entry_path_a.as_path(config)).display(), 374 | base_path.join(entry_path_b.as_path(config)).display(), 375 | ]); 376 | } 377 | println!("{table}"); 378 | Ok(()) 379 | } 380 | 381 | fn prepare_release(config: &Config, editor: &Path, path: &Path, version: &str) -> Result<()> { 382 | // Add the summary to the unreleased folder, since we'll be moving it to 383 | // the new release folder 384 | let summary_path = path 385 | .join(&config.unreleased.folder) 386 | .join(&config.change_sets.summary_filename); 387 | // If the summary doesn't exist, try to create it 388 | if std::fs::metadata(&summary_path).is_err() { 389 | std::fs::write(&summary_path, RELEASE_SUMMARY_TEMPLATE) 390 | .map_err(|e| Error::Io(summary_path.clone(), e))?; 391 | } 392 | 393 | // Run the user's editor and wait for the process to exit 394 | let _ = std::process::Command::new(editor) 395 | .arg(&summary_path) 396 | .status() 397 | .map_err(|e| Error::Subprocess(editor.to_str().unwrap().to_string(), e))?; 398 | 399 | // Check if the file's contents have changed - if not, don't continue with 400 | // the release 401 | let summary_content = 402 | std::fs::read_to_string(&summary_path).map_err(|e| Error::Io(summary_path.clone(), e))?; 403 | if summary_content.is_empty() || summary_content == RELEASE_SUMMARY_TEMPLATE { 404 | log::info!("No changes to release summary - not creating a new release"); 405 | return Ok(()); 406 | } 407 | 408 | Changelog::prepare_release_dir(config, path, version) 409 | } 410 | -------------------------------------------------------------------------------- /src/changelog.rs: -------------------------------------------------------------------------------- 1 | //! Our model for a changelog. 2 | 3 | mod change_set; 4 | mod change_set_section; 5 | mod component; 6 | mod component_section; 7 | pub mod config; 8 | mod entry; 9 | mod entry_path; 10 | mod parsing_utils; 11 | mod release; 12 | 13 | pub use change_set::ChangeSet; 14 | pub use change_set_section::ChangeSetSection; 15 | pub use component::Component; 16 | pub use component_section::ComponentSection; 17 | pub use entry::Entry; 18 | pub use entry_path::{ 19 | ChangeSetComponentPath, ChangeSetSectionPath, EntryChangeSetPath, EntryPath, EntryReleasePath, 20 | }; 21 | pub use release::Release; 22 | use serde_json::json; 23 | 24 | use crate::changelog::config::SortReleasesBy; 25 | use crate::changelog::parsing_utils::{extract_release_version, trim_newlines}; 26 | use crate::fs_utils::{ 27 | self, ensure_dir, path_to_str, read_and_filter_dir, read_to_string_opt, rm_gitkeep, 28 | }; 29 | use crate::vcs::{from_git_repo, try_from, GenericProject}; 30 | use crate::{Error, PlatformId, Result}; 31 | use config::Config; 32 | use log::{debug, info, warn}; 33 | use std::collections::HashSet; 34 | use std::fs; 35 | use std::path::{Path, PathBuf}; 36 | 37 | use self::change_set::ChangeSetIter; 38 | 39 | const DEFAULT_CHANGE_TEMPLATE: &str = 40 | "{{{ bullet }}} {{{ message }}} ([\\#{{ change_id }}]({{{ change_url }}}))"; 41 | 42 | /// A log of changes for a specific project. 43 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 44 | pub struct Changelog { 45 | /// Unreleased changes don't have version information associated with them. 46 | pub maybe_unreleased: Option, 47 | /// An ordered list of releases' changes. 48 | pub releases: Vec, 49 | /// Any additional content that must appear at the beginning of the 50 | /// changelog. 51 | pub prologue: Option, 52 | /// Any additional content that must appear at the end of the changelog 53 | /// (e.g. historical changelog content prior to switching to `unclog`). 54 | pub epilogue: Option, 55 | } 56 | 57 | impl Changelog { 58 | /// Checks whether this changelog is empty. 59 | pub fn is_empty(&self) -> bool { 60 | self.maybe_unreleased 61 | .as_ref() 62 | .map_or(true, ChangeSet::is_empty) 63 | && self.releases.iter().all(|r| r.changes.is_empty()) 64 | && self.prologue.as_ref().map_or(true, String::is_empty) 65 | && self.epilogue.as_ref().map_or(true, String::is_empty) 66 | } 67 | 68 | /// Renders the full changelog to a string. 69 | pub fn render_all(&self, config: &Config) -> String { 70 | self.render(config, true) 71 | } 72 | 73 | /// Renders all released versions' entries, excluding unreleased ones. 74 | pub fn render_released(&self, config: &Config) -> String { 75 | self.render(config, false) 76 | } 77 | 78 | fn render(&self, config: &Config, render_unreleased: bool) -> String { 79 | let mut paragraphs = vec![config.heading.clone()]; 80 | if self.is_empty() { 81 | paragraphs.push(config.empty_msg.clone()); 82 | } else { 83 | if let Some(prologue) = self.prologue.as_ref() { 84 | paragraphs.push(prologue.clone()); 85 | } 86 | if render_unreleased { 87 | if let Ok(unreleased_paragraphs) = self.unreleased_paragraphs(config) { 88 | paragraphs.extend(unreleased_paragraphs); 89 | } 90 | } 91 | self.releases 92 | .iter() 93 | .for_each(|r| paragraphs.push(r.render(config))); 94 | if let Some(epilogue) = self.epilogue.as_ref() { 95 | paragraphs.push(epilogue.clone()); 96 | } 97 | } 98 | format!("{}\n", paragraphs.join("\n\n")) 99 | } 100 | 101 | /// Renders just the unreleased changes to a string. 102 | pub fn render_unreleased(&self, config: &Config) -> Result { 103 | Ok(self.unreleased_paragraphs(config)?.join("\n\n")) 104 | } 105 | 106 | fn unreleased_paragraphs(&self, config: &Config) -> Result> { 107 | if let Some(unreleased) = self.maybe_unreleased.as_ref() { 108 | if !unreleased.is_empty() { 109 | return Ok(vec![ 110 | config.unreleased.heading.clone(), 111 | unreleased.render(config), 112 | ]); 113 | } 114 | } 115 | Err(Error::NoUnreleasedEntries) 116 | } 117 | 118 | /// Initialize a new (empty) changelog in the given path. 119 | /// 120 | /// Creates the target folder if it doesn't exist, and optionally copies an 121 | /// epilogue into it. 122 | pub fn init_dir, R: AsRef, E: AsRef>( 123 | config: &Config, 124 | path: P, 125 | maybe_prologue_path: Option, 126 | maybe_epilogue_path: Option, 127 | ) -> Result<()> { 128 | let path = path.as_ref(); 129 | // Ensure the desired path exists. 130 | ensure_dir(path)?; 131 | 132 | // Optionally copy a prologue into the target path. 133 | let maybe_prologue_path = maybe_prologue_path.as_ref(); 134 | if let Some(pp) = maybe_prologue_path { 135 | let new_prologue_path = path.join(&config.prologue_filename); 136 | if !fs_utils::file_exists(&new_prologue_path) { 137 | fs::copy(pp, &new_prologue_path) 138 | .map_err(|e| Error::Io(pp.as_ref().to_path_buf(), e))?; 139 | info!( 140 | "Copied prologue from {} to {}", 141 | path_to_str(pp), 142 | path_to_str(&new_prologue_path), 143 | ); 144 | } else { 145 | info!( 146 | "Prologue file already exists, not copying: {}", 147 | path_to_str(&new_prologue_path) 148 | ); 149 | } 150 | } 151 | 152 | // Optionally copy an epilogue into the target path. 153 | let maybe_epilogue_path = maybe_epilogue_path.as_ref(); 154 | if let Some(ep) = maybe_epilogue_path { 155 | let new_epilogue_path = path.join(&config.epilogue_filename); 156 | if !fs_utils::file_exists(&new_epilogue_path) { 157 | fs::copy(ep, &new_epilogue_path) 158 | .map_err(|e| Error::Io(ep.as_ref().to_path_buf(), e))?; 159 | info!( 160 | "Copied epilogue from {} to {}", 161 | path_to_str(ep), 162 | path_to_str(&new_epilogue_path), 163 | ); 164 | } else { 165 | info!( 166 | "Epilogue file already exists, not copying: {}", 167 | path_to_str(&new_epilogue_path) 168 | ); 169 | } 170 | } 171 | // We want an empty unreleased directory with a .gitkeep file 172 | Self::init_empty_unreleased_dir(config, path)?; 173 | 174 | info!("Success!"); 175 | Ok(()) 176 | } 177 | 178 | /// Attempts to generate a configuration file for the changelog in the given 179 | /// path, inferring as many parameters as possible from its environment. 180 | pub fn generate_config(config_path: P, path: Q, remote: S, force: bool) -> Result<()> 181 | where 182 | P: AsRef, 183 | Q: AsRef, 184 | S: AsRef, 185 | { 186 | let config_path = config_path.as_ref(); 187 | if fs_utils::file_exists(config_path) { 188 | if !force { 189 | return Err(Error::ConfigurationFileAlreadyExists(path_to_str( 190 | config_path, 191 | ))); 192 | } else { 193 | warn!( 194 | "Overwriting configuration file: {}", 195 | path_to_str(config_path) 196 | ); 197 | } 198 | } 199 | 200 | let path = fs::canonicalize(path.as_ref()) 201 | .map_err(|e| Error::Io(path.as_ref().to_path_buf(), e))?; 202 | let parent = path 203 | .parent() 204 | .ok_or_else(|| Error::NoParentFolder(path_to_str(&path)))?; 205 | let git_folder = parent.join(".git"); 206 | 207 | let maybe_git_project = if fs_utils::dir_exists(git_folder) { 208 | Some(from_git_repo(parent, remote.as_ref())?) 209 | } else { 210 | warn!("Parent folder of changelog directory is not a Git repository. Cannot infer whether it is a GitHub project."); 211 | None 212 | }; 213 | 214 | let config = Config { 215 | maybe_project_url: maybe_git_project.map(|gp| gp.url()), 216 | ..Config::default() 217 | }; 218 | config.write_to_file(config_path) 219 | } 220 | 221 | /// Attempt to read a full changelog from the given directory. 222 | pub fn read_from_dir

(config: &Config, path: P) -> Result 223 | where 224 | P: AsRef, 225 | { 226 | let path = path.as_ref(); 227 | info!( 228 | "Attempting to load changelog from directory: {}", 229 | path.display() 230 | ); 231 | let meta = fs::metadata(path).map_err(|e| Error::Io(path.to_path_buf(), e))?; 232 | if !meta.is_dir() { 233 | return Err(Error::ExpectedDir(fs_utils::path_to_str(path))); 234 | } 235 | let unreleased = 236 | ChangeSet::read_from_dir_opt(config, path.join(&config.unreleased.folder))?; 237 | debug!("Scanning for releases in {}", path.display()); 238 | let release_dirs = read_and_filter_dir(path, |e| release_dir_filter(config, e))?; 239 | let mut releases = release_dirs 240 | .into_iter() 241 | .map(|path| Release::read_from_dir(config, path)) 242 | .collect::>>()?; 243 | // Sort releases by version in descending order (newest to oldest). 244 | releases.sort_by(|a, b| { 245 | for sort_by in &config.sort_releases_by.0 { 246 | match sort_by { 247 | SortReleasesBy::Version => { 248 | if a.version == b.version { 249 | continue; 250 | } 251 | return a.version.cmp(&b.version).reverse(); 252 | } 253 | SortReleasesBy::Date => { 254 | // If either date is missing, skip to the next search 255 | // criterion. 256 | if a.maybe_date.is_none() || b.maybe_date.is_none() { 257 | continue; 258 | } 259 | if a.maybe_date == b.maybe_date { 260 | continue; 261 | } 262 | return a.maybe_date.cmp(&b.maybe_date).reverse(); 263 | } 264 | } 265 | } 266 | // Fall back to sorting by version if no sort configuration is 267 | // provided. 268 | a.version.cmp(&b.version).reverse() 269 | }); 270 | let prologue = read_to_string_opt(path.join(&config.prologue_filename))? 271 | .map(|p| trim_newlines(&p).to_owned()); 272 | let epilogue = read_to_string_opt(path.join(&config.epilogue_filename))? 273 | .map(|e| trim_newlines(&e).to_owned()); 274 | Ok(Self { 275 | maybe_unreleased: unreleased, 276 | releases, 277 | prologue, 278 | epilogue, 279 | }) 280 | } 281 | 282 | /// Adds a changelog entry with the given ID to the specified section in 283 | /// the `unreleased` folder. 284 | pub fn add_unreleased_entry( 285 | config: &Config, 286 | path: P, 287 | section: S, 288 | maybe_component: Option, 289 | id: I, 290 | content: O, 291 | ) -> Result<()> 292 | where 293 | P: AsRef, 294 | S: AsRef, 295 | C: AsRef, 296 | I: AsRef, 297 | O: AsRef, 298 | { 299 | let path = path.as_ref(); 300 | let unreleased_path = path.join(&config.unreleased.folder); 301 | ensure_dir(&unreleased_path)?; 302 | let section = section.as_ref(); 303 | let section_path = unreleased_path.join(section); 304 | ensure_dir(§ion_path)?; 305 | let mut entry_dir = section_path; 306 | if let Some(component) = maybe_component { 307 | let component = component.as_ref(); 308 | if !config.components.all.contains_key(component) { 309 | return Err(Error::ComponentNotDefined(component.to_string())); 310 | } 311 | entry_dir = entry_dir.join(component); 312 | ensure_dir(&entry_dir)?; 313 | } 314 | let entry_path = entry_dir.join(entry_id_to_filename(config, id)); 315 | // We don't want to overwrite any existing entries 316 | if fs::metadata(&entry_path).is_ok() { 317 | return Err(Error::FileExists(path_to_str(&entry_path))); 318 | } 319 | fs::write(&entry_path, content.as_ref()).map_err(|e| Error::Io(entry_path.clone(), e))?; 320 | info!("Wrote entry to: {}", path_to_str(&entry_path)); 321 | Ok(()) 322 | } 323 | 324 | /// Attempts to add an unreleased changelog entry from the given parameters, 325 | /// rendering them through the change template specified in the 326 | /// configuration file. 327 | /// 328 | /// The change template is assumed to be in [Handlebars] format. 329 | /// 330 | /// [Handlebars]: https://handlebarsjs.com/ 331 | pub fn add_unreleased_entry_from_template( 332 | config: &Config, 333 | path: &Path, 334 | section: &str, 335 | component: Option, 336 | id: &str, 337 | platform_id: PlatformId, 338 | message: &str, 339 | ) -> Result<()> { 340 | let rendered_change = Self::render_unreleased_entry_from_template( 341 | config, 342 | path, 343 | section, 344 | component.clone(), 345 | id, 346 | platform_id, 347 | message, 348 | )?; 349 | let mut id = id.to_owned(); 350 | if !id.starts_with(&format!("{}-", platform_id.id())) { 351 | id = format!("{}-{}", platform_id.id(), id); 352 | debug!("Automatically prepending platform ID to change ID: {}", id); 353 | } 354 | Self::add_unreleased_entry(config, path, section, component, &id, rendered_change) 355 | } 356 | 357 | /// Renders an unreleased changelog entry from the given parameters to a 358 | /// string, making use of the change template specified in the configuration 359 | /// file. 360 | /// 361 | /// The change template is assumed to be in [Handlebars] format. 362 | /// 363 | /// [Handlebars]: https://handlebarsjs.com/ 364 | pub fn render_unreleased_entry_from_template( 365 | config: &Config, 366 | path: &Path, 367 | section: &str, 368 | component: Option, 369 | id: &str, 370 | platform_id: PlatformId, 371 | message: &str, 372 | ) -> Result { 373 | let project_url = config 374 | .maybe_project_url 375 | .as_ref() 376 | .ok_or(Error::MissingProjectUrl)?; 377 | // We only support GitHub and GitLab projects at the moment 378 | let git_project = try_from(project_url)?; 379 | let mut change_template_file = PathBuf::from(&config.change_template); 380 | if change_template_file.is_relative() { 381 | change_template_file = path.join(change_template_file); 382 | } 383 | info!( 384 | "Loading change template from: {}", 385 | fs_utils::path_to_str(&change_template_file) 386 | ); 387 | let change_template = fs_utils::read_to_string_opt(&change_template_file)? 388 | .unwrap_or_else(|| DEFAULT_CHANGE_TEMPLATE.to_owned()); 389 | debug!("Loaded change template:\n{}", change_template); 390 | let mut hb = handlebars::Handlebars::new(); 391 | hb.register_template_string("change", change_template) 392 | .map_err(|e| Error::HandlebarsTemplateLoad(e.to_string()))?; 393 | 394 | let (platform_id_field, platform_id_val) = match platform_id { 395 | PlatformId::Issue(issue) => ("issue", issue), 396 | PlatformId::PullRequest(pull_request) => ("pull_request", pull_request), 397 | }; 398 | let template_params = json!({ 399 | "project_url": git_project.to_string(), 400 | "section": section, 401 | "component": component, 402 | "id": id, 403 | platform_id_field: platform_id_val, 404 | "message": message, 405 | "change_url": git_project.change_url(platform_id)?.to_string(), 406 | "change_id": platform_id.id(), 407 | "bullet": config.bullet_style.to_string(), 408 | }); 409 | debug!( 410 | "Template parameters: {}", 411 | serde_json::to_string_pretty(&template_params)? 412 | ); 413 | let rendered_change = hb 414 | .render("change", &template_params) 415 | .map_err(|e| Error::HandlebarsTemplateRender(e.to_string()))?; 416 | let wrapped_rendered = textwrap::wrap( 417 | &rendered_change, 418 | textwrap::Options::new(config.wrap as usize) 419 | .subsequent_indent(" ") 420 | .break_words(false) 421 | .word_separator(textwrap::WordSeparator::AsciiSpace), 422 | ) 423 | .join("\n"); 424 | debug!("Rendered wrapped change:\n{}", wrapped_rendered); 425 | Ok(wrapped_rendered) 426 | } 427 | 428 | /// Compute the file system path to the entry with the given parameters. 429 | pub fn get_entry_path( 430 | config: &Config, 431 | path: P, 432 | release: R, 433 | section: S, 434 | component: Option, 435 | id: I, 436 | ) -> PathBuf 437 | where 438 | P: AsRef, 439 | R: AsRef, 440 | S: AsRef, 441 | C: AsRef, 442 | I: AsRef, 443 | { 444 | let mut path = path.as_ref().join(release.as_ref()).join(section.as_ref()); 445 | if let Some(component) = component { 446 | path = path.join(component.as_ref()); 447 | } 448 | path.join(entry_id_to_filename(config, id)) 449 | } 450 | 451 | /// Moves the `unreleased` folder from our changelog to a directory whose 452 | /// name is the given version. 453 | pub fn prepare_release_dir, S: AsRef>( 454 | config: &Config, 455 | path: P, 456 | version: S, 457 | ) -> Result<()> { 458 | let path = path.as_ref(); 459 | let version = version.as_ref(); 460 | 461 | // Validate the version 462 | let _ = semver::Version::parse(extract_release_version(version)?)?; 463 | 464 | let version_path = path.join(version); 465 | // The target version path must not yet exist 466 | if fs::metadata(&version_path).is_ok() { 467 | return Err(Error::DirExists(path_to_str(&version_path))); 468 | } 469 | 470 | let unreleased_path = path.join(&config.unreleased.folder); 471 | // The unreleased folder must exist 472 | if fs::metadata(&unreleased_path).is_err() { 473 | return Err(Error::ExpectedDir(path_to_str(&unreleased_path))); 474 | } 475 | 476 | fs::rename(&unreleased_path, &version_path) 477 | .map_err(|e| Error::Io(unreleased_path.clone(), e))?; 478 | info!( 479 | "Moved {} to {}", 480 | path_to_str(&unreleased_path), 481 | path_to_str(&version_path) 482 | ); 483 | // We no longer need a .gitkeep in the release directory, if there is one 484 | rm_gitkeep(&version_path)?; 485 | 486 | Self::init_empty_unreleased_dir(config, path) 487 | } 488 | 489 | fn init_empty_unreleased_dir(config: &Config, path: &Path) -> Result<()> { 490 | let unreleased_dir = path.join(&config.unreleased.folder); 491 | ensure_dir(&unreleased_dir)?; 492 | let unreleased_gitkeep = unreleased_dir.join(".gitkeep"); 493 | fs::write(&unreleased_gitkeep, "").map_err(|e| Error::Io(unreleased_gitkeep.clone(), e))?; 494 | debug!("Wrote {}", path_to_str(&unreleased_gitkeep)); 495 | Ok(()) 496 | } 497 | 498 | /// Facilitates iteration through all entries in this changelog, producing 499 | /// [`EntryPath`] instances such that one can trace the full path to each 500 | /// entry. The order in which entries are produced is the order in which 501 | /// they will be rendered if the changelog is built. 502 | pub fn entries(&self) -> ChangelogEntryIter<'_> { 503 | if let Some(unreleased) = &self.maybe_unreleased { 504 | if let Some(change_set_iter) = ChangeSetIter::new(unreleased) { 505 | return ChangelogEntryIter { 506 | changelog: self, 507 | state: ChangelogEntryIterState::Unreleased(change_set_iter), 508 | }; 509 | } 510 | } 511 | if let Some(releases_iter) = ReleasesIter::new(self) { 512 | ChangelogEntryIter { 513 | changelog: self, 514 | state: ChangelogEntryIterState::Released(releases_iter), 515 | } 516 | } else { 517 | ChangelogEntryIter { 518 | changelog: self, 519 | state: ChangelogEntryIterState::Empty, 520 | } 521 | } 522 | } 523 | 524 | /// Returns a list of entries that are the same across releases within this 525 | /// changelog. Effectively compares just the entries themselves without 526 | /// regard for the release, section, component, etc. 527 | pub fn find_duplicates_across_releases(&self) -> Vec<(EntryPath<'_>, EntryPath<'_>)> { 528 | let mut dups = Vec::new(); 529 | let mut already_found = HashSet::new(); 530 | 531 | for path_a in self.entries() { 532 | for path_b in self.entries() { 533 | if path_a == path_b { 534 | continue; 535 | } 536 | if path_a.entry() == path_b.entry() && !already_found.contains(&(path_a, path_b)) { 537 | dups.push((path_a, path_b)); 538 | already_found.insert((path_a, path_b)); 539 | already_found.insert((path_b, path_a)); 540 | } 541 | } 542 | } 543 | dups 544 | } 545 | } 546 | 547 | #[derive(Debug, Clone)] 548 | pub struct ChangelogEntryIter<'a> { 549 | changelog: &'a Changelog, 550 | state: ChangelogEntryIterState<'a>, 551 | } 552 | 553 | impl<'a> Iterator for ChangelogEntryIter<'a> { 554 | type Item = EntryPath<'a>; 555 | 556 | fn next(&mut self) -> Option { 557 | let entry_release_path = self.state.next_entry_path(self.changelog)?; 558 | Some(EntryPath { 559 | changelog: self.changelog, 560 | release_path: entry_release_path, 561 | }) 562 | } 563 | } 564 | 565 | #[derive(Debug, Clone)] 566 | enum ChangelogEntryIterState<'a> { 567 | Empty, 568 | Unreleased(ChangeSetIter<'a>), 569 | Released(ReleasesIter<'a>), 570 | } 571 | 572 | impl<'a> ChangelogEntryIterState<'a> { 573 | fn next_entry_path(&mut self, changelog: &'a Changelog) -> Option> { 574 | match self { 575 | Self::Empty => None, 576 | Self::Unreleased(change_set_iter) => match change_set_iter.next() { 577 | Some(entry_path) => Some(EntryReleasePath::Unreleased(entry_path)), 578 | None => { 579 | *self = ChangelogEntryIterState::Released(ReleasesIter::new(changelog)?); 580 | self.next_entry_path(changelog) 581 | } 582 | }, 583 | Self::Released(releases_iter) => { 584 | let (release, entry_path) = releases_iter.next()?; 585 | Some(EntryReleasePath::Released(release, entry_path)) 586 | } 587 | } 588 | } 589 | } 590 | 591 | #[derive(Debug, Clone)] 592 | struct ReleasesIter<'a> { 593 | releases: &'a Vec, 594 | id: usize, 595 | // Change set iterator for the current release. 596 | change_set_iter: ChangeSetIter<'a>, 597 | } 598 | 599 | impl<'a> ReleasesIter<'a> { 600 | fn new(changelog: &'a Changelog) -> Option { 601 | let releases = &changelog.releases; 602 | let first_release = releases.first()?; 603 | Some(Self { 604 | releases, 605 | id: 0, 606 | change_set_iter: ChangeSetIter::new(&first_release.changes)?, 607 | }) 608 | } 609 | } 610 | 611 | impl<'a> Iterator for ReleasesIter<'a> { 612 | type Item = (&'a Release, EntryChangeSetPath<'a>); 613 | 614 | fn next(&mut self) -> Option { 615 | let mut release = self.releases.get(self.id)?; 616 | match self.change_set_iter.next() { 617 | Some(entry_path) => Some((release, entry_path)), 618 | None => { 619 | let mut maybe_change_set_iter = None; 620 | while maybe_change_set_iter.is_none() { 621 | self.id += 1; 622 | release = self.releases.get(self.id)?; 623 | maybe_change_set_iter = ChangeSetIter::new(&release.changes); 624 | } 625 | // Safety: the above while loop will cause the function to exit 626 | // if we run out of releases. The while loop will only otherwise 627 | // terminate and hit this line if 628 | // maybe_change_set_iter.is_none() is false. 629 | self.change_set_iter = maybe_change_set_iter.unwrap(); 630 | self.next() 631 | } 632 | } 633 | } 634 | } 635 | 636 | fn entry_id_to_filename>(config: &Config, id: S) -> String { 637 | format!("{}.{}", id.as_ref(), config.change_sets.entry_ext) 638 | } 639 | 640 | fn release_dir_filter(config: &Config, entry: fs::DirEntry) -> Option> { 641 | let file_name = entry.file_name(); 642 | let file_name = file_name.to_string_lossy(); 643 | let meta = match entry.metadata() { 644 | Ok(m) => m, 645 | Err(e) => return Some(Err(Error::Io(entry.path(), e))), 646 | }; 647 | if meta.is_dir() && file_name != config.unreleased.folder { 648 | Some(Ok(entry.path())) 649 | } else { 650 | None 651 | } 652 | } 653 | -------------------------------------------------------------------------------- /src/changelog/change_set.rs: -------------------------------------------------------------------------------- 1 | use crate::changelog::fs_utils::{read_and_filter_dir, read_to_string_opt}; 2 | use crate::changelog::parsing_utils::trim_newlines; 3 | use crate::{ChangeSetSection, Config, EntryChangeSetPath, Error, Result}; 4 | use log::debug; 5 | use std::fs; 6 | use std::path::{Path, PathBuf}; 7 | 8 | use super::change_set_section::ChangeSetSectionIter; 9 | 10 | /// A set of changes, either associated with a release or not. 11 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 12 | pub struct ChangeSet { 13 | /// An optional high-level summary of the set of changes. 14 | pub maybe_summary: Option, 15 | /// The sections making up the change set. 16 | pub sections: Vec, 17 | } 18 | 19 | impl ChangeSet { 20 | /// Returns true if this change set has no summary and no entries 21 | /// associated with it. 22 | pub fn is_empty(&self) -> bool { 23 | self.maybe_summary.as_ref().map_or(true, String::is_empty) && self.are_sections_empty() 24 | } 25 | 26 | /// Returns whether or not all the sections are empty. 27 | pub fn are_sections_empty(&self) -> bool { 28 | self.sections.iter().all(ChangeSetSection::is_empty) 29 | } 30 | 31 | /// Attempt to read a single change set from the given directory. 32 | pub fn read_from_dir

(config: &Config, path: P) -> Result 33 | where 34 | P: AsRef, 35 | { 36 | let path = path.as_ref(); 37 | debug!("Loading change set from {}", path.display()); 38 | let summary = read_to_string_opt(path.join(&config.change_sets.summary_filename))? 39 | .map(|s| trim_newlines(&s).to_owned()); 40 | let section_dirs = read_and_filter_dir(path, change_set_section_filter)?; 41 | let mut sections = section_dirs 42 | .into_iter() 43 | .map(|path| ChangeSetSection::read_from_dir(config, path)) 44 | .collect::>>()?; 45 | // Sort sections alphabetically 46 | sections.sort_by(|a, b| a.title.cmp(&b.title)); 47 | Ok(Self { 48 | maybe_summary: summary, 49 | sections, 50 | }) 51 | } 52 | 53 | /// Attempt to read a single change set from the given directory, like 54 | /// [`ChangeSet::read_from_dir`], but return `Option::None` if the 55 | /// directory does not exist. 56 | pub fn read_from_dir_opt

(config: &Config, path: P) -> Result> 57 | where 58 | P: AsRef, 59 | { 60 | let path = path.as_ref(); 61 | // The path doesn't exist 62 | if fs::metadata(path).is_err() { 63 | return Ok(None); 64 | } 65 | Self::read_from_dir(config, path).map(Some) 66 | } 67 | 68 | pub fn render(&self, config: &Config) -> String { 69 | let mut paragraphs = Vec::new(); 70 | if let Some(summary) = self.maybe_summary.as_ref() { 71 | paragraphs.push(summary.clone()); 72 | } 73 | self.sections 74 | .iter() 75 | .filter(|s| !s.is_empty()) 76 | .for_each(|s| paragraphs.push(s.render(config))); 77 | paragraphs.join("\n\n") 78 | } 79 | } 80 | 81 | #[derive(Debug, Clone)] 82 | pub struct ChangeSetIter<'a> { 83 | change_set: &'a ChangeSet, 84 | section_id: usize, 85 | section_iter: ChangeSetSectionIter<'a>, 86 | } 87 | 88 | impl<'a> ChangeSetIter<'a> { 89 | pub(crate) fn new(change_set: &'a ChangeSet) -> Option { 90 | let first_section = change_set.sections.first()?; 91 | Some(Self { 92 | change_set, 93 | section_id: 0, 94 | section_iter: ChangeSetSectionIter::new(first_section)?, 95 | }) 96 | } 97 | } 98 | 99 | impl<'a> Iterator for ChangeSetIter<'a> { 100 | type Item = EntryChangeSetPath<'a>; 101 | 102 | fn next(&mut self) -> Option { 103 | let _ = self.change_set.sections.get(self.section_id)?; 104 | match self.section_iter.next() { 105 | Some(section_path) => Some(EntryChangeSetPath { 106 | change_set: self.change_set, 107 | section_path, 108 | }), 109 | None => { 110 | let mut maybe_section_iter = None; 111 | while maybe_section_iter.is_none() { 112 | self.section_id += 1; 113 | let section = self.change_set.sections.get(self.section_id)?; 114 | maybe_section_iter = ChangeSetSectionIter::new(section); 115 | } 116 | // Safety: the above while loop will cause the function to exit 117 | // if we run out of sections. The while loop will only otherwise 118 | // terminate and hit this line if maybe_section_iter.is_none() 119 | // is false. 120 | self.section_iter = maybe_section_iter.unwrap(); 121 | self.next() 122 | } 123 | } 124 | } 125 | } 126 | 127 | fn change_set_section_filter(entry: fs::DirEntry) -> Option> { 128 | let meta = match entry.metadata() { 129 | Ok(m) => m, 130 | Err(e) => return Some(Err(Error::Io(entry.path(), e))), 131 | }; 132 | if meta.is_dir() { 133 | Some(Ok(entry.path())) 134 | } else { 135 | None 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/changelog/change_set_section.rs: -------------------------------------------------------------------------------- 1 | use crate::changelog::component_section::package_section_filter; 2 | use crate::changelog::entry::read_entries_sorted; 3 | use crate::changelog::fs_utils::{entry_filter, path_to_str, read_and_filter_dir}; 4 | use crate::{ 5 | ChangeSetComponentPath, ChangeSetSectionPath, ComponentSection, Config, Entry, Error, Result, 6 | }; 7 | use log::debug; 8 | use std::ffi::OsStr; 9 | use std::path::Path; 10 | 11 | use super::component_section::ComponentSectionIter; 12 | 13 | /// A single section in a set of changes. 14 | /// 15 | /// For example, the "FEATURES" or "BREAKING CHANGES" section. 16 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 17 | pub struct ChangeSetSection { 18 | /// Original ID of this change set section (the folder name). 19 | pub id: String, 20 | /// A short, descriptive title for this section (e.g. "BREAKING CHANGES"). 21 | pub title: String, 22 | /// General entries in the change set section. 23 | pub entries: Vec, 24 | /// Entries associated with a specific component/package/submodule. 25 | pub component_sections: Vec, 26 | } 27 | 28 | impl ChangeSetSection { 29 | /// Returns whether or not this section is empty. 30 | pub fn is_empty(&self) -> bool { 31 | self.entries.is_empty() && self.component_sections.is_empty() 32 | } 33 | 34 | /// Attempt to read a single change set section from the given directory. 35 | pub fn read_from_dir

(config: &Config, path: P) -> Result 36 | where 37 | P: AsRef, 38 | { 39 | let path = path.as_ref(); 40 | debug!("Loading section {}", path.display()); 41 | let id = path 42 | .file_name() 43 | .and_then(OsStr::to_str) 44 | .ok_or_else(|| Error::CannotObtainName(path_to_str(path)))? 45 | .to_owned(); 46 | let title = change_set_section_title(&id); 47 | let component_section_dirs = read_and_filter_dir(path, package_section_filter)?; 48 | let mut component_sections = component_section_dirs 49 | .into_iter() 50 | .map(|path| ComponentSection::read_from_dir(config, path)) 51 | .collect::>>()?; 52 | // Component sections must be sorted by ID 53 | component_sections.sort_by(|a, b| a.id.cmp(&b.id)); 54 | let entry_files = read_and_filter_dir(path, |e| entry_filter(config, e))?; 55 | let entries = read_entries_sorted(entry_files, config)?; 56 | Ok(Self { 57 | id, 58 | title, 59 | entries, 60 | component_sections, 61 | }) 62 | } 63 | 64 | /// Render this change set section to a string using the given 65 | /// configuration. 66 | pub fn render(&self, config: &Config) -> String { 67 | let mut lines = Vec::new(); 68 | // If we have no package sections 69 | if self.component_sections.is_empty() { 70 | // Just collect the entries as-is 71 | lines.extend( 72 | self.entries 73 | .iter() 74 | .map(|e| e.to_string()) 75 | .collect::>(), 76 | ); 77 | } else { 78 | // If we do have package sections, however, we need to collect the 79 | // general entries into their own sub-section. 80 | if !self.entries.is_empty() { 81 | // For example: 82 | // - General 83 | lines.push(format!( 84 | "{} {}", 85 | config.bullet_style, config.components.general_entries_title 86 | )); 87 | // Now we indent all general entries. 88 | lines.extend(indent_entries( 89 | &self.entries, 90 | config.components.entry_indent, 91 | config.components.entry_indent + 2, 92 | )); 93 | } 94 | // Component-specific sections are already indented 95 | lines.extend( 96 | self.component_sections 97 | .iter() 98 | .map(|ps| ps.render(config)) 99 | .collect::>(), 100 | ); 101 | } 102 | format!("### {}\n\n{}", self.title, lines.join("\n")) 103 | } 104 | } 105 | 106 | #[derive(Debug, Clone)] 107 | pub struct ChangeSetSectionIter<'a> { 108 | section: &'a ChangeSetSection, 109 | state: ChangeSetSectionIterState<'a>, 110 | } 111 | 112 | impl<'a> ChangeSetSectionIter<'a> { 113 | pub(crate) fn new(section: &'a ChangeSetSection) -> Option { 114 | if !section.entries.is_empty() { 115 | Some(Self { 116 | section, 117 | state: ChangeSetSectionIterState::General(0), 118 | }) 119 | } else { 120 | Some(Self { 121 | section, 122 | state: ChangeSetSectionIterState::ComponentSection(ComponentSectionsIter::new( 123 | section, 124 | )?), 125 | }) 126 | } 127 | } 128 | } 129 | 130 | impl<'a> Iterator for ChangeSetSectionIter<'a> { 131 | type Item = ChangeSetSectionPath<'a>; 132 | 133 | fn next(&mut self) -> Option { 134 | match &mut self.state { 135 | ChangeSetSectionIterState::General(entry_id) => { 136 | match self.section.entries.get(*entry_id) { 137 | Some(entry) => { 138 | // Next entry in the general section. 139 | *entry_id += 1; 140 | Some(ChangeSetSectionPath { 141 | change_set_section: self.section, 142 | component_path: ChangeSetComponentPath::General(entry), 143 | }) 144 | } 145 | // Move on to the component sections. 146 | None => { 147 | self.state = ChangeSetSectionIterState::ComponentSection( 148 | ComponentSectionsIter::new(self.section)?, 149 | ); 150 | self.next() 151 | } 152 | } 153 | } 154 | ChangeSetSectionIterState::ComponentSection(component_sections_iter) => { 155 | Some(ChangeSetSectionPath { 156 | change_set_section: self.section, 157 | component_path: component_sections_iter.next()?, 158 | }) 159 | } 160 | } 161 | } 162 | } 163 | 164 | #[derive(Debug, Clone)] 165 | enum ChangeSetSectionIterState<'a> { 166 | General(usize), 167 | ComponentSection(ComponentSectionsIter<'a>), 168 | } 169 | 170 | #[derive(Debug, Clone)] 171 | struct ComponentSectionsIter<'a> { 172 | sections: &'a Vec, 173 | section_id: usize, 174 | section_iter: ComponentSectionIter<'a>, 175 | } 176 | 177 | impl<'a> ComponentSectionsIter<'a> { 178 | fn new(change_set_section: &'a ChangeSetSection) -> Option { 179 | // Return an iterator for the first non-empty section. 180 | for (section_id, section) in change_set_section.component_sections.iter().enumerate() { 181 | if let Some(section_iter) = ComponentSectionIter::new(section) { 182 | return Some(Self { 183 | sections: &change_set_section.component_sections, 184 | section_id, 185 | section_iter, 186 | }); 187 | } 188 | } 189 | None 190 | } 191 | } 192 | 193 | impl<'a> Iterator for ComponentSectionsIter<'a> { 194 | type Item = ChangeSetComponentPath<'a>; 195 | 196 | fn next(&mut self) -> Option { 197 | let section = self.sections.get(self.section_id)?; 198 | match self.section_iter.next() { 199 | Some(entry) => Some(ChangeSetComponentPath::Component(section, entry)), 200 | None => { 201 | // Find the next non-empty component section. 202 | let mut maybe_section_iter = None; 203 | while maybe_section_iter.is_none() { 204 | self.section_id += 1; 205 | let section = self.sections.get(self.section_id)?; 206 | maybe_section_iter = ComponentSectionIter::new(section); 207 | } 208 | // Safety: the above while loop will cause the function to exit 209 | // if we run out of component sections. The while loop will only 210 | // otherwise terminate and hit this line if 211 | // maybe_section_iter.is_none() is false. 212 | self.section_iter = maybe_section_iter.unwrap(); 213 | self.next() 214 | } 215 | } 216 | } 217 | } 218 | 219 | fn change_set_section_title>(s: S) -> String { 220 | s.as_ref().to_owned().replace('-', " ").to_uppercase() 221 | } 222 | 223 | // Indents the given string according to `indent` and `overflow_indent` 224 | // assuming that the string contains one or more bulleted entries in Markdown. 225 | fn indent_bulleted_str(s: &str, indent: u8, overflow_indent: u8) -> Vec { 226 | s.split('\n') 227 | .map(|line| { 228 | let line_trimmed = line.trim(); 229 | let i = if line_trimmed.starts_with('*') || line_trimmed.starts_with('-') { 230 | indent 231 | } else { 232 | overflow_indent 233 | }; 234 | format!( 235 | "{}{}", 236 | (0..i).map(|_| " ").collect::>().join(""), 237 | line_trimmed 238 | ) 239 | }) 240 | .collect::>() 241 | } 242 | 243 | pub(crate) fn indent_entries(entries: &[Entry], indent: u8, overflow_indent: u8) -> Vec { 244 | entries 245 | .iter() 246 | .flat_map(|e| indent_bulleted_str(e.to_string().as_str(), indent, overflow_indent)) 247 | .collect::>() 248 | } 249 | 250 | #[cfg(test)] 251 | mod test { 252 | use super::{change_set_section_title, indent_bulleted_str}; 253 | 254 | #[test] 255 | fn change_set_section_title_generation() { 256 | let cases = vec![ 257 | ("breaking-changes", "BREAKING CHANGES"), 258 | ("features", "FEATURES"), 259 | ("improvements", "IMPROVEMENTS"), 260 | ("removed", "REMOVED"), 261 | ]; 262 | 263 | for (s, expected) in cases { 264 | let actual = change_set_section_title(s); 265 | assert_eq!(expected, actual); 266 | } 267 | } 268 | 269 | #[test] 270 | fn entry_indentation() { 271 | let cases = vec![ 272 | ( 273 | "- Just a single-line entry.", 274 | " - Just a single-line entry.", 275 | ), 276 | ( 277 | r#"- A multi-line entry 278 | which overflows onto the next line."#, 279 | r#" - A multi-line entry 280 | which overflows onto the next line."#, 281 | ), 282 | ( 283 | r#"- A complex multi-line entry 284 | - Which not only has multiple bulleted items 285 | which could overflow 286 | - It also has bulleted items which underflow"#, 287 | r#" - A complex multi-line entry 288 | - Which not only has multiple bulleted items 289 | which could overflow 290 | - It also has bulleted items which underflow"#, 291 | ), 292 | ]; 293 | 294 | for (s, expected) in cases { 295 | let actual = indent_bulleted_str(s, 2, 4).join("\n"); 296 | assert_eq!(expected, actual); 297 | } 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/changelog/component.rs: -------------------------------------------------------------------------------- 1 | //! Components/sub-modules of a project. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::path::PathBuf; 5 | 6 | /// A single component of a project. 7 | #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] 8 | pub struct Component { 9 | /// The name of the component. 10 | pub name: String, 11 | /// Optional path of the component relative to the project path. 12 | #[serde(rename = "path")] 13 | pub maybe_path: Option, 14 | } 15 | -------------------------------------------------------------------------------- /src/changelog/component_section.rs: -------------------------------------------------------------------------------- 1 | use crate::changelog::change_set_section::indent_entries; 2 | use crate::changelog::entry::read_entries_sorted; 3 | use crate::changelog::fs_utils::{entry_filter, path_to_str, read_and_filter_dir}; 4 | use crate::{Config, Entry, Error, Result}; 5 | use log::{debug, warn}; 6 | use std::ffi::OsStr; 7 | use std::fs; 8 | use std::path::{Path, PathBuf}; 9 | 10 | /// A section of entries related to a specific component/submodule/package. 11 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 12 | pub struct ComponentSection { 13 | /// The ID of the component. 14 | pub id: String, 15 | /// The name of the component. 16 | pub name: String, 17 | /// The path to the component, from the root of the project, if any. 18 | /// Pre-computed and ready to render. 19 | pub maybe_path: Option, 20 | /// The entries associated with the component. 21 | pub entries: Vec, 22 | } 23 | 24 | impl ComponentSection { 25 | /// Returns whether or not this section is empty (it's considered empty 26 | /// when it has no entries). 27 | pub fn is_empty(&self) -> bool { 28 | self.entries.is_empty() 29 | } 30 | 31 | /// Attempt to load this component section from the given directory. 32 | pub fn read_from_dir

(config: &Config, path: P) -> Result 33 | where 34 | P: AsRef, 35 | { 36 | let path = path.as_ref(); 37 | let id = path 38 | .file_name() 39 | .and_then(OsStr::to_str) 40 | .ok_or_else(|| Error::CannotObtainName(path_to_str(path)))? 41 | .to_owned(); 42 | debug!("Looking up component with ID: {}", id); 43 | let component = config 44 | .components 45 | .all 46 | .get(&id) 47 | .ok_or_else(|| Error::ComponentNotDefined(id.clone()))?; 48 | let name = component.name.clone(); 49 | let maybe_component_path = component.maybe_path.as_ref().map(path_to_str); 50 | match &maybe_component_path { 51 | Some(component_path) => debug!( 52 | "Found component \"{}\" with name \"{}\" in: {}", 53 | id, name, component_path 54 | ), 55 | None => warn!("No path for component \"{}\"", id), 56 | } 57 | let entry_files = read_and_filter_dir(path, |e| entry_filter(config, e))?; 58 | let entries = read_entries_sorted(entry_files, config)?; 59 | Ok(Self { 60 | id, 61 | name, 62 | maybe_path: maybe_component_path, 63 | entries, 64 | }) 65 | } 66 | 67 | pub fn render(&self, config: &Config) -> String { 68 | let entries_lines = indent_entries( 69 | &self.entries, 70 | config.components.entry_indent, 71 | config.components.entry_indent + 2, 72 | ); 73 | let name = match &self.maybe_path { 74 | // Render as a Markdown hyperlink 75 | Some(path) => format!("[{}]({})", self.name, path), 76 | None => self.name.clone(), 77 | }; 78 | let mut lines = vec![format!("{} {}", config.bullet_style, name)]; 79 | lines.extend(entries_lines); 80 | lines.join("\n") 81 | } 82 | } 83 | 84 | #[derive(Debug, Clone)] 85 | pub struct ComponentSectionIter<'a> { 86 | section: &'a ComponentSection, 87 | entry_id: usize, 88 | } 89 | 90 | impl<'a> ComponentSectionIter<'a> { 91 | pub(crate) fn new(section: &'a ComponentSection) -> Option { 92 | if section.is_empty() { 93 | None 94 | } else { 95 | Some(Self { 96 | section, 97 | entry_id: 0, 98 | }) 99 | } 100 | } 101 | } 102 | 103 | impl<'a> Iterator for ComponentSectionIter<'a> { 104 | type Item = &'a Entry; 105 | 106 | fn next(&mut self) -> Option { 107 | let entry = self.section.entries.get(self.entry_id)?; 108 | self.entry_id += 1; 109 | Some(entry) 110 | } 111 | } 112 | 113 | pub(crate) fn package_section_filter(entry: fs::DirEntry) -> Option> { 114 | let meta = match entry.metadata() { 115 | Ok(m) => m, 116 | Err(e) => return Some(Err(Error::Io(entry.path(), e))), 117 | }; 118 | if meta.is_dir() { 119 | Some(Ok(entry.path())) 120 | } else { 121 | None 122 | } 123 | } 124 | 125 | #[cfg(test)] 126 | mod test { 127 | use super::{ComponentSection, Config}; 128 | use crate::Entry; 129 | 130 | const RENDERED_WITH_PATH: &str = r#"- [Some project](./some-project/) 131 | - Issue 1 132 | - Issue 2 133 | - Issue 3"#; 134 | 135 | const RENDERED_WITHOUT_PATH: &str = r#"- some-project 136 | - Issue 1 137 | - Issue 2 138 | - Issue 3"#; 139 | 140 | #[test] 141 | fn with_path() { 142 | let ps = ComponentSection { 143 | id: "some-project".to_owned(), 144 | name: "Some project".to_owned(), 145 | maybe_path: Some("./some-project/".to_owned()), 146 | entries: test_entries(), 147 | }; 148 | assert_eq!(RENDERED_WITH_PATH, ps.render(&Config::default())); 149 | } 150 | 151 | #[test] 152 | fn without_path() { 153 | let ps = ComponentSection { 154 | id: "some-project".to_owned(), 155 | name: "some-project".to_owned(), 156 | maybe_path: None, 157 | entries: test_entries(), 158 | }; 159 | assert_eq!(RENDERED_WITHOUT_PATH, ps.render(&Config::default())); 160 | } 161 | 162 | fn test_entries() -> Vec { 163 | vec![ 164 | Entry { 165 | filename: "1-issue.md".to_string(), 166 | id: 1, 167 | details: "- Issue 1".to_string(), 168 | }, 169 | Entry { 170 | filename: "2-issue.md".to_string(), 171 | id: 2, 172 | details: "- Issue 2".to_string(), 173 | }, 174 | Entry { 175 | filename: "3-issue.md".to_string(), 176 | id: 3, 177 | details: "- Issue 3".to_string(), 178 | }, 179 | ] 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/changelog/config.rs: -------------------------------------------------------------------------------- 1 | //! Configuration-related types. 2 | 3 | use super::fs_utils::{path_to_str, read_to_string_opt}; 4 | use crate::{Component, Error, Result}; 5 | use log::{debug, info}; 6 | use serde::{de::Error as _, Deserialize, Serialize}; 7 | use std::collections::HashMap; 8 | use std::fmt; 9 | use std::path::Path; 10 | use std::str::FromStr; 11 | use url::Url; 12 | 13 | /// Configuration options relating to the generation of a changelog. 14 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 15 | pub struct Config { 16 | /// The URL of the project. This helps facilitate automatic content 17 | /// generation when supplying an issue or PR number. 18 | #[serde( 19 | default, 20 | rename = "project_url", 21 | with = "crate::s11n::optional_from_str", 22 | skip_serializing_if = "is_default" 23 | )] 24 | pub maybe_project_url: Option, 25 | /// The path to a file containing the change template to use when 26 | /// automatically adding new changelog entries. Relative to the `.changelog` 27 | /// folder. 28 | #[serde( 29 | default = "Config::default_change_template", 30 | skip_serializing_if = "Config::is_default_change_template" 31 | )] 32 | pub change_template: String, 33 | /// Wrap entries automatically to a specific number of characters per line. 34 | #[serde( 35 | default = "Config::default_wrap", 36 | skip_serializing_if = "Config::is_default_wrap" 37 | )] 38 | pub wrap: u16, 39 | /// The heading to use at the beginning of the changelog we generate. 40 | #[serde( 41 | default = "Config::default_heading", 42 | skip_serializing_if = "Config::is_default_heading" 43 | )] 44 | pub heading: String, 45 | /// What style of bullet should we use when generating changelog entries? 46 | #[serde( 47 | default, 48 | with = "crate::s11n::from_str", 49 | skip_serializing_if = "is_default" 50 | )] 51 | pub bullet_style: BulletStyle, 52 | /// The message to use when the changelog is empty. 53 | #[serde( 54 | default = "Config::default_empty_msg", 55 | skip_serializing_if = "Config::is_default_empty_msg" 56 | )] 57 | pub empty_msg: String, 58 | /// The filename (relative to the `.changelog` folder) of the file 59 | /// containing content to be inserted at the beginning of the generated 60 | /// changelog. 61 | #[serde( 62 | default = "Config::default_prologue_filename", 63 | skip_serializing_if = "Config::is_default_prologue_filename" 64 | )] 65 | pub prologue_filename: String, 66 | /// The filename (relative to the `.changelog` folder) of the file 67 | /// containing content to be appended to the end of the generated 68 | /// changelog. 69 | #[serde( 70 | default = "Config::default_epilogue_filename", 71 | skip_serializing_if = "Config::is_default_epilogue_filename" 72 | )] 73 | pub epilogue_filename: String, 74 | 75 | /// Sort releases by the given properties. 76 | #[serde(default, skip_serializing_if = "is_default")] 77 | pub sort_releases_by: ReleaseSortingConfig, 78 | /// An ordered list of possible formats to expect when parsing release 79 | /// summaries to establish release dates. 80 | #[serde(default, skip_serializing_if = "is_default")] 81 | pub release_date_formats: ReleaseDateFormats, 82 | 83 | /// Configuration relating to unreleased changelog entries. 84 | #[serde(default, skip_serializing_if = "is_default")] 85 | pub unreleased: UnreleasedConfig, 86 | /// Configuration relating to sets of changes. 87 | #[serde(default, skip_serializing_if = "is_default")] 88 | pub change_sets: ChangeSetsConfig, 89 | /// Configuration relating to change set sections. 90 | #[serde(default, skip_serializing_if = "is_default")] 91 | pub change_set_sections: ChangeSetSectionsConfig, 92 | /// Configuration relating to components/submodules. 93 | #[serde(default, skip_serializing_if = "is_default")] 94 | pub components: ComponentsConfig, 95 | } 96 | 97 | impl Default for Config { 98 | fn default() -> Self { 99 | Self { 100 | maybe_project_url: None, 101 | change_template: Self::default_change_template(), 102 | wrap: Self::default_wrap(), 103 | heading: Self::default_heading(), 104 | bullet_style: BulletStyle::default(), 105 | empty_msg: Self::default_empty_msg(), 106 | prologue_filename: Self::default_prologue_filename(), 107 | epilogue_filename: Self::default_epilogue_filename(), 108 | unreleased: Default::default(), 109 | sort_releases_by: Default::default(), 110 | release_date_formats: Default::default(), 111 | change_sets: Default::default(), 112 | change_set_sections: Default::default(), 113 | components: Default::default(), 114 | } 115 | } 116 | } 117 | 118 | impl Config { 119 | /// Attempt to read the configuration from the given file. 120 | /// 121 | /// If the given file does not exist, this method does not fail: it returns 122 | /// a [`Config`] object with all of the default values set. 123 | /// 124 | /// At present, only [TOML](https://toml.io/) format is supported. 125 | pub fn read_from_file>(path: P) -> Result { 126 | let path = path.as_ref(); 127 | info!( 128 | "Attempting to load configuration file from: {}", 129 | path.display() 130 | ); 131 | let maybe_content = read_to_string_opt(path)?; 132 | match maybe_content { 133 | Some(content) => { 134 | toml::from_str::(&content).map_err(|e| Error::TomlParse(path_to_str(path), e)) 135 | } 136 | None => { 137 | info!("No changelog configuration file. Assuming defaults."); 138 | Ok(Self::default()) 139 | } 140 | } 141 | } 142 | 143 | /// Attempt to save the configuration to the given file. 144 | pub fn write_to_file>(&self, path: P) -> Result<()> { 145 | let path = path.as_ref(); 146 | debug!( 147 | "Attempting to save configuration file to: {}", 148 | path.display() 149 | ); 150 | let content = toml::to_string_pretty(&self).map_err(Error::TomlSerialize)?; 151 | std::fs::write(path, content).map_err(|e| Error::Io(path.to_path_buf(), e))?; 152 | info!("Saved configuration to: {}", path.display()); 153 | Ok(()) 154 | } 155 | 156 | fn default_change_template() -> String { 157 | "change-template.md".to_owned() 158 | } 159 | 160 | fn is_default_change_template(change_template: &str) -> bool { 161 | change_template == Self::default_change_template() 162 | } 163 | 164 | fn default_wrap() -> u16 { 165 | 80 166 | } 167 | 168 | fn is_default_wrap(w: &u16) -> bool { 169 | *w == Self::default_wrap() 170 | } 171 | 172 | fn default_heading() -> String { 173 | "# CHANGELOG".to_owned() 174 | } 175 | 176 | fn is_default_heading(heading: &str) -> bool { 177 | heading == Self::default_heading() 178 | } 179 | 180 | fn default_empty_msg() -> String { 181 | "Nothing to see here! Add some entries to get started.".to_owned() 182 | } 183 | 184 | fn is_default_empty_msg(empty_msg: &str) -> bool { 185 | empty_msg == Self::default_empty_msg() 186 | } 187 | 188 | fn default_prologue_filename() -> String { 189 | "prologue.md".to_owned() 190 | } 191 | 192 | fn is_default_prologue_filename(prologue_filename: &str) -> bool { 193 | prologue_filename == Self::default_prologue_filename() 194 | } 195 | 196 | fn default_epilogue_filename() -> String { 197 | "epilogue.md".to_owned() 198 | } 199 | 200 | fn is_default_epilogue_filename(epilogue_filename: &str) -> bool { 201 | epilogue_filename == Self::default_epilogue_filename() 202 | } 203 | } 204 | 205 | /// The various styles of bullets available in Markdown. 206 | #[derive(Debug, Clone, Copy, PartialEq)] 207 | pub enum BulletStyle { 208 | /// `*` 209 | Asterisk, 210 | /// `-` 211 | Dash, 212 | } 213 | 214 | impl fmt::Display for BulletStyle { 215 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 216 | match self { 217 | Self::Asterisk => write!(f, "*"), 218 | Self::Dash => write!(f, "-"), 219 | } 220 | } 221 | } 222 | 223 | impl FromStr for BulletStyle { 224 | type Err = Error; 225 | 226 | fn from_str(s: &str) -> Result { 227 | match s { 228 | "*" => Ok(Self::Asterisk), 229 | "-" => Ok(Self::Dash), 230 | _ => Err(Error::InvalidBulletStyle), 231 | } 232 | } 233 | } 234 | 235 | impl Default for BulletStyle { 236 | fn default() -> Self { 237 | Self::Dash 238 | } 239 | } 240 | 241 | impl Serialize for BulletStyle { 242 | fn serialize(&self, serializer: S) -> std::result::Result 243 | where 244 | S: serde::Serializer, 245 | { 246 | serializer.serialize_str(&self.to_string()) 247 | } 248 | } 249 | 250 | impl<'de> Deserialize<'de> for BulletStyle { 251 | fn deserialize(deserializer: D) -> std::result::Result 252 | where 253 | D: serde::Deserializer<'de>, 254 | { 255 | String::deserialize(deserializer)? 256 | .parse::() 257 | .map_err(|e| D::Error::custom(format!("{e}"))) 258 | } 259 | } 260 | 261 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 262 | pub struct ReleaseSortingConfig(pub Vec); 263 | 264 | impl Default for ReleaseSortingConfig { 265 | fn default() -> Self { 266 | Self(vec![SortReleasesBy::Version]) 267 | } 268 | } 269 | 270 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 271 | pub enum SortReleasesBy { 272 | /// Sort releases in descending order by semantic version, with most recent 273 | /// version first. 274 | #[serde(rename = "version")] 275 | #[default] 276 | Version, 277 | /// Sort releases in descending order by release date, with most recent 278 | /// release first. 279 | #[serde(rename = "date")] 280 | Date, 281 | } 282 | 283 | /// An ordered list of possible release date formats to expect when parsing 284 | /// release summaries for release dates. 285 | /// 286 | /// See for 287 | /// possible options. 288 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 289 | pub struct ReleaseDateFormats(pub Vec); 290 | 291 | impl Default for ReleaseDateFormats { 292 | fn default() -> Self { 293 | Self(vec!["%F".to_string()]) 294 | } 295 | } 296 | 297 | /// Configuration relating to unreleased changelog entries. 298 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 299 | pub struct UnreleasedConfig { 300 | #[serde(default = "UnreleasedConfig::default_folder")] 301 | pub folder: String, 302 | #[serde(default = "UnreleasedConfig::default_heading")] 303 | pub heading: String, 304 | } 305 | 306 | impl Default for UnreleasedConfig { 307 | fn default() -> Self { 308 | Self { 309 | folder: Self::default_folder(), 310 | heading: Self::default_heading(), 311 | } 312 | } 313 | } 314 | 315 | impl UnreleasedConfig { 316 | fn default_folder() -> String { 317 | "unreleased".to_owned() 318 | } 319 | 320 | fn default_heading() -> String { 321 | "## Unreleased".to_owned() 322 | } 323 | } 324 | 325 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 326 | pub struct ChangeSetsConfig { 327 | #[serde(default = "ChangeSetsConfig::default_summary_filename")] 328 | pub summary_filename: String, 329 | #[serde(default = "ChangeSetsConfig::default_entry_ext")] 330 | pub entry_ext: String, 331 | } 332 | 333 | impl Default for ChangeSetsConfig { 334 | fn default() -> Self { 335 | Self { 336 | summary_filename: Self::default_summary_filename(), 337 | entry_ext: Self::default_entry_ext(), 338 | } 339 | } 340 | } 341 | 342 | impl ChangeSetsConfig { 343 | fn default_summary_filename() -> String { 344 | "summary.md".to_owned() 345 | } 346 | 347 | fn default_entry_ext() -> String { 348 | "md".to_owned() 349 | } 350 | } 351 | 352 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 353 | pub struct ChangeSetSectionsConfig { 354 | /// Sort entries in change set sections by a specific property. 355 | #[serde(default, skip_serializing_if = "is_default")] 356 | pub sort_entries_by: SortEntriesBy, 357 | } 358 | 359 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 360 | pub struct ComponentsConfig { 361 | #[serde( 362 | default = "ComponentsConfig::default_general_entries_title", 363 | skip_serializing_if = "ComponentsConfig::is_default_general_entries_title" 364 | )] 365 | pub general_entries_title: String, 366 | #[serde( 367 | default = "ComponentsConfig::default_entry_indent", 368 | skip_serializing_if = "ComponentsConfig::is_default_entry_indent" 369 | )] 370 | pub entry_indent: u8, 371 | /// All of the components themselves. 372 | #[serde(default, skip_serializing_if = "is_default")] 373 | pub all: HashMap, 374 | } 375 | 376 | impl Default for ComponentsConfig { 377 | fn default() -> Self { 378 | Self { 379 | general_entries_title: Self::default_general_entries_title(), 380 | entry_indent: Self::default_entry_indent(), 381 | all: HashMap::default(), 382 | } 383 | } 384 | } 385 | 386 | impl ComponentsConfig { 387 | fn default_general_entries_title() -> String { 388 | "General".to_owned() 389 | } 390 | 391 | fn is_default_general_entries_title(t: &str) -> bool { 392 | t == Self::default_general_entries_title() 393 | } 394 | 395 | fn default_entry_indent() -> u8 { 396 | 2 397 | } 398 | 399 | fn is_default_entry_indent(i: &u8) -> bool { 400 | *i == Self::default_entry_indent() 401 | } 402 | } 403 | 404 | fn is_default(v: &D) -> bool 405 | where 406 | D: Default + PartialEq, 407 | { 408 | D::default().eq(v) 409 | } 410 | 411 | /// Allows for configuration of how entries are to be sorted within change set 412 | /// sections. 413 | #[derive(Debug, Clone, Default, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize)] 414 | pub enum SortEntriesBy { 415 | #[serde(rename = "id")] 416 | #[default] 417 | ID, 418 | #[serde(rename = "entry-text")] 419 | EntryText, 420 | } 421 | -------------------------------------------------------------------------------- /src/changelog/entry.rs: -------------------------------------------------------------------------------- 1 | use crate::changelog::fs_utils::{path_to_str, read_to_string}; 2 | use crate::changelog::parsing_utils::trim_newlines; 3 | use crate::{Config, Error, Result}; 4 | use log::debug; 5 | use std::ffi::OsStr; 6 | use std::fmt; 7 | use std::path::{Path, PathBuf}; 8 | use std::str::FromStr; 9 | 10 | use super::config::SortEntriesBy; 11 | 12 | /// A single entry in a set of changes. 13 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 14 | pub struct Entry { 15 | /// The original filename of this entry. 16 | pub filename: String, 17 | /// The issue/pull request ID relating to this entry. 18 | pub id: u64, 19 | /// The content of the entry. 20 | pub details: String, 21 | } 22 | 23 | impl Entry { 24 | /// Attempt to read a single entry for a change set section from the given 25 | /// file. 26 | pub fn read_from_file>(path: P) -> Result { 27 | let path = path.as_ref(); 28 | debug!("Loading entry from {}", path.display()); 29 | let orig_id = path 30 | .file_name() 31 | .and_then(OsStr::to_str) 32 | .ok_or_else(|| Error::CannotObtainName(path_to_str(path)))? 33 | .to_string(); 34 | let id = extract_entry_id(&orig_id)?; 35 | Ok(Self { 36 | filename: orig_id, 37 | id, 38 | details: trim_newlines(&read_to_string(path)?).to_owned(), 39 | }) 40 | } 41 | } 42 | 43 | impl fmt::Display for Entry { 44 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 | f.write_str(&self.details) 46 | } 47 | } 48 | 49 | fn extract_entry_id>(s: S) -> Result { 50 | let s = s.as_ref(); 51 | let num_digits = s 52 | .chars() 53 | .position(|c| !c.is_ascii_digit()) 54 | .ok_or_else(|| Error::InvalidEntryId(s.to_owned()))?; 55 | let digits = &s[..num_digits]; 56 | Ok(u64::from_str(digits)?) 57 | } 58 | 59 | pub(crate) fn read_entries_sorted( 60 | entry_files: Vec, 61 | config: &Config, 62 | ) -> Result> { 63 | let mut entries = entry_files 64 | .into_iter() 65 | .map(Entry::read_from_file) 66 | .collect::>>()?; 67 | // Sort entries by ID in ascending numeric order. 68 | entries.sort_by(|a, b| match config.change_set_sections.sort_entries_by { 69 | SortEntriesBy::ID => a.id.cmp(&b.id), 70 | SortEntriesBy::EntryText => a.details.cmp(&b.details), 71 | }); 72 | Ok(entries) 73 | } 74 | 75 | #[cfg(test)] 76 | mod test { 77 | use super::extract_entry_id; 78 | 79 | #[test] 80 | fn entry_id_extraction() { 81 | let cases = vec![ 82 | ("830-something.md", 830_u64), 83 | ("1.md", 1_u64), 84 | ("0128-another-issue.md", 128_u64), 85 | ]; 86 | 87 | for (s, expected) in cases { 88 | let actual = extract_entry_id(s).unwrap(); 89 | assert_eq!(expected, actual); 90 | } 91 | 92 | assert!(extract_entry_id("no-number").is_err()); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/changelog/entry_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::{ChangeSet, ChangeSetSection, Changelog, ComponentSection, Config, Entry, Release}; 4 | 5 | /// Provides a precise path through a specific changelog to a specific entry. 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 7 | pub struct EntryPath<'a> { 8 | pub changelog: &'a Changelog, 9 | pub release_path: EntryReleasePath<'a>, 10 | } 11 | 12 | impl<'a> EntryPath<'a> { 13 | /// Reconstructs the filesystem path, relative to the changelog folder path, 14 | /// for this particular entry. 15 | pub fn as_path(&self, config: &Config) -> PathBuf { 16 | self.release_path.as_path(config) 17 | } 18 | 19 | pub fn entry(&self) -> &'a Entry { 20 | self.release_path.entry() 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 25 | pub enum EntryReleasePath<'a> { 26 | Unreleased(EntryChangeSetPath<'a>), 27 | Released(&'a Release, EntryChangeSetPath<'a>), 28 | } 29 | 30 | impl<'a> EntryReleasePath<'a> { 31 | pub fn as_path(&self, config: &Config) -> PathBuf { 32 | match self { 33 | Self::Unreleased(p) => PathBuf::from(&config.unreleased.folder).join(p.as_path()), 34 | Self::Released(r, p) => PathBuf::from(&r.id).join(p.as_path()), 35 | } 36 | } 37 | 38 | pub fn entry(&self) -> &'a Entry { 39 | match self { 40 | Self::Unreleased(change_set_path) => change_set_path.entry(), 41 | Self::Released(_, change_set_path) => change_set_path.entry(), 42 | } 43 | } 44 | } 45 | 46 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 47 | pub struct EntryChangeSetPath<'a> { 48 | pub change_set: &'a ChangeSet, 49 | pub section_path: ChangeSetSectionPath<'a>, 50 | } 51 | 52 | impl<'a> EntryChangeSetPath<'a> { 53 | pub fn as_path(&self) -> PathBuf { 54 | self.section_path.as_path() 55 | } 56 | 57 | pub fn entry(&self) -> &'a Entry { 58 | self.section_path.entry() 59 | } 60 | } 61 | 62 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 63 | pub struct ChangeSetSectionPath<'a> { 64 | pub change_set_section: &'a ChangeSetSection, 65 | pub component_path: ChangeSetComponentPath<'a>, 66 | } 67 | 68 | impl<'a> ChangeSetSectionPath<'a> { 69 | pub fn as_path(&self) -> PathBuf { 70 | PathBuf::from(&self.change_set_section.id).join(self.component_path.as_path()) 71 | } 72 | 73 | pub fn entry(&self) -> &'a Entry { 74 | self.component_path.entry() 75 | } 76 | } 77 | 78 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 79 | pub enum ChangeSetComponentPath<'a> { 80 | General(&'a Entry), 81 | Component(&'a ComponentSection, &'a Entry), 82 | } 83 | 84 | impl<'a> ChangeSetComponentPath<'a> { 85 | pub fn as_path(&self) -> PathBuf { 86 | match self { 87 | Self::General(entry) => PathBuf::from(&entry.filename), 88 | Self::Component(component_section, entry) => { 89 | PathBuf::from(&component_section.id).join(&entry.filename) 90 | } 91 | } 92 | } 93 | 94 | pub fn entry(&self) -> &'a Entry { 95 | match self { 96 | Self::General(entry) => entry, 97 | Self::Component(_, entry) => entry, 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/changelog/parsing_utils.rs: -------------------------------------------------------------------------------- 1 | //! Utilities to assist in parsing changelogs. 2 | 3 | use crate::error::Error; 4 | 5 | pub(crate) fn trim_newlines(s: &str) -> &str { 6 | s.trim_end_matches(|c| c == '\n' || c == '\r') 7 | } 8 | 9 | pub(crate) fn extract_release_version(s: &str) -> crate::Result<&str> { 10 | // Just find the first digit in the string 11 | let version_start = s 12 | .chars() 13 | .position(|c| c.is_ascii_digit()) 14 | .ok_or_else(|| Error::CannotExtractVersion(s.to_owned()))?; 15 | Ok(&s[version_start..]) 16 | } 17 | 18 | #[cfg(test)] 19 | mod test { 20 | use super::extract_release_version; 21 | 22 | #[test] 23 | fn release_version_extraction() { 24 | let cases = vec![ 25 | ("v0.1.0", "0.1.0"), 26 | ("0.1.0", "0.1.0"), 27 | ("v0.1.0-beta.1", "0.1.0-beta.1"), 28 | ]; 29 | 30 | for (s, expected) in cases { 31 | let actual = extract_release_version(s).unwrap(); 32 | assert_eq!(expected, actual); 33 | } 34 | 35 | assert!(extract_release_version("no-version").is_err()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/changelog/release.rs: -------------------------------------------------------------------------------- 1 | use crate::changelog::config::SortReleasesBy; 2 | use crate::changelog::fs_utils::path_to_str; 3 | use crate::changelog::parsing_utils::extract_release_version; 4 | use crate::{ChangeSet, Config, Error, Result, Version}; 5 | use chrono::NaiveDate; 6 | use log::{debug, warn}; 7 | use std::path::Path; 8 | 9 | /// The changes associated with a specific release. 10 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 11 | pub struct Release { 12 | /// This release's ID (could be the version plus a prefix, e.g. `v0.1.0`). 13 | pub id: String, 14 | /// This release's version (using [semantic versioning](https://semver.org)). 15 | pub version: Version, 16 | /// This possibly a release date, parsed according to the configuration file 17 | /// rules. 18 | pub maybe_date: Option, 19 | /// The changes associated with this release. 20 | pub changes: ChangeSet, 21 | } 22 | 23 | impl Release { 24 | /// Attempt to read a single release from the given directory. 25 | pub fn read_from_dir

(config: &Config, path: P) -> Result 26 | where 27 | P: AsRef, 28 | { 29 | let path = path.as_ref().to_path_buf(); 30 | debug!("Loading release from {}", path.display()); 31 | let path_str = path_to_str(path.clone()); 32 | if !path.is_dir() { 33 | return Err(Error::ExpectedDir(path_str)); 34 | } 35 | let id = path 36 | .file_name() 37 | .ok_or_else(|| Error::CannotObtainName(path_str.clone()))? 38 | .to_string_lossy() 39 | .to_string(); 40 | let version = Version::parse(extract_release_version(&id)?)?; 41 | let changes = ChangeSet::read_from_dir(config, path)?; 42 | let maybe_date = changes.maybe_summary.as_ref().and_then(|summary| { 43 | let summary_first_line = match summary.split('\n').next() { 44 | Some(s) => s, 45 | None => { 46 | if config.sort_releases_by.0.contains(&SortReleasesBy::Date) { 47 | warn!("Unable to extract release date from {version}: unable to extract first line of summary"); 48 | } 49 | return None 50 | } 51 | }; 52 | for date_fmt in &config.release_date_formats.0 { 53 | if let Ok(date) = NaiveDate::parse_from_str(summary_first_line, date_fmt) { 54 | return Some(date); 55 | } 56 | } 57 | if config.sort_releases_by.0.contains(&SortReleasesBy::Date) { 58 | warn!("Unable to parse date from first line of {version}: no formats match \"{summary_first_line}\""); 59 | } 60 | None 61 | }); 62 | Ok(Self { 63 | id, 64 | version, 65 | maybe_date, 66 | changes, 67 | }) 68 | } 69 | 70 | /// Attempt to render this release to a string using the given 71 | /// configuration. 72 | pub fn render(&self, config: &Config) -> String { 73 | let mut paragraphs = vec![format!("## {}", self.id)]; 74 | if !self.changes.is_empty() { 75 | paragraphs.push(self.changes.render(config)); 76 | } 77 | paragraphs.join("\n\n") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Errors that can be produced by unclog. 2 | 3 | use std::path::PathBuf; 4 | use thiserror::Error; 5 | 6 | /// All error variants that can be produced by unclog. 7 | #[derive(Debug, Error)] 8 | pub enum Error { 9 | #[error("I/O error relating to {0}: {1}")] 10 | Io(PathBuf, std::io::Error), 11 | #[error("failed to execute subprocess {0}: {1}")] 12 | Subprocess(String, std::io::Error), 13 | #[error("expected path to be a directory: {0}")] 14 | ExpectedDir(String), 15 | #[error("unexpected release directory name prefix: \"{0}\"")] 16 | UnexpectedReleaseDirPrefix(String), 17 | #[error("cannot obtain (or invalid) last component of path: \"{0}\"")] 18 | CannotObtainName(String), 19 | #[error("cannot extract version")] 20 | CannotExtractVersion(String), 21 | #[error("directory already exists: {0}")] 22 | DirExists(String), 23 | #[error("file already exists: {0}")] 24 | FileExists(String), 25 | #[error("invalid semantic version")] 26 | InvalidSemanticVersion(#[from] semver::Error), 27 | #[error("expected entry ID to start with a number, but got: \"{0}\"")] 28 | InvalidEntryId(String), 29 | #[error("failed to parse entry ID as a number")] 30 | InvalidEntryNumber(#[from] std::num::ParseIntError), 31 | #[error("no unreleased entries yet")] 32 | NoUnreleasedEntries, 33 | #[error("non-UTF8 characters in string")] 34 | NonUtf8String(#[from] std::string::FromUtf8Error), 35 | #[error("non-zero process exit code when executing {0}: {1}")] 36 | NonZeroExitCode(String, i32), 37 | #[error("failed to parse JSON: {0}")] 38 | JsonParsingFailed(#[from] serde_json::Error), 39 | #[error("no such cargo package: {0}")] 40 | NoSuchCargoPackage(String), 41 | #[error("failed to get relative package path: {0}")] 42 | StripPrefixError(#[from] std::path::StripPrefixError), 43 | #[error("unrecognized project type: {0}")] 44 | UnrecognizedProjectType(String), 45 | #[error("cannot autodetect project type in path: {0}")] 46 | CannotAutodetectProjectType(PathBuf), 47 | #[error("invalid bullet style - can only be \"*\" or \"-\"")] 48 | InvalidBulletStyle, 49 | #[error("failed to parse TOML file \"{0}\": {1}")] 50 | TomlParse(String, toml::de::Error), 51 | #[error("failed to serialize TOML: {0}")] 52 | TomlSerialize(toml::ser::Error), 53 | #[error("failed to parse URL: {0}")] 54 | FailedToParseUrl(#[from] url::ParseError), 55 | #[error("missing issue number (--issue-no) or pull request (--pull-request)")] 56 | MissingIssueNoOrPullRequest, 57 | #[error("please specify either an issue number (--issue-no) or a pull request (--pull-request), but not both")] 58 | EitherIssueNoOrPullRequest, 59 | #[error("the URL is missing its host: {0}")] 60 | UrlMissingHost(String), 61 | #[error("not a GitHub project: {0}")] 62 | NotGitHubProject(String), 63 | #[error("GitHub project is missing its path: {0}")] 64 | GitHubProjectMissingPath(String), 65 | #[error("GitHub project URLs must include both the org/user ID and project ID: {0}")] 66 | InvalidGitHubProjectPath(String), 67 | #[error("configuration is missing a project URL (needed for automatic entry generation)")] 68 | MissingProjectUrl, 69 | #[error("error loading Handlebars template: {0}")] 70 | HandlebarsTemplateLoad(String), 71 | #[error("error rendering Handlebars template: {0}")] 72 | HandlebarsTemplateRender(String), 73 | #[error("git error: {0}")] 74 | Git(#[from] git2::Error), 75 | #[error("configuration file already exists: {0}")] 76 | ConfigurationFileAlreadyExists(String), 77 | #[error("no parent folder for path: {0}")] 78 | NoParentFolder(String), 79 | #[error("invalid URL in Git repository for remote \"{0}\": {1}")] 80 | InvalidGitRemoteUrl(String, String), 81 | #[error("invalid URL: {0}")] 82 | InvalidUrl(String), 83 | #[error("component \"{0}\" is not defined in changelog config.toml file")] 84 | ComponentNotDefined(String), 85 | #[error("CLI error: {0}")] 86 | CommandLine(String), 87 | } 88 | -------------------------------------------------------------------------------- /src/fs_utils.rs: -------------------------------------------------------------------------------- 1 | //! File system-related utilities to help with manipulating changelogs. 2 | 3 | use crate::{Config, Error, Result}; 4 | use log::{debug, info}; 5 | use std::fs; 6 | use std::path::{Path, PathBuf}; 7 | 8 | pub fn path_to_str>(path: P) -> String { 9 | path.as_ref().to_string_lossy().to_string() 10 | } 11 | 12 | pub fn read_to_string>(path: P) -> Result { 13 | let path = path.as_ref(); 14 | fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e)) 15 | } 16 | 17 | pub fn read_to_string_opt>(path: P) -> Result> { 18 | let path = path.as_ref(); 19 | if fs::metadata(path).is_err() { 20 | return Ok(None); 21 | } 22 | read_to_string(path).map(Some) 23 | } 24 | 25 | pub fn ensure_dir(path: &Path) -> Result<()> { 26 | if fs::metadata(path).is_err() { 27 | fs::create_dir(path).map_err(|e| Error::Io(path.to_path_buf(), e))?; 28 | info!("Created directory: {}", path_to_str(path)); 29 | } 30 | let meta = fs::metadata(path).map_err(|e| Error::Io(path.to_path_buf(), e))?; 31 | if !meta.is_dir() { 32 | return Err(Error::ExpectedDir(path_to_str(path))); 33 | } 34 | Ok(()) 35 | } 36 | 37 | pub fn rm_gitkeep(path: &Path) -> Result<()> { 38 | let path = path.join(".gitkeep"); 39 | if fs::metadata(&path).is_ok() { 40 | fs::remove_file(&path).map_err(|e| Error::Io(path.to_path_buf(), e))?; 41 | debug!("Removed .gitkeep file from: {}", path_to_str(&path)); 42 | } 43 | Ok(()) 44 | } 45 | 46 | pub fn read_and_filter_dir(path: &Path, filter: F) -> Result> 47 | where 48 | F: Fn(fs::DirEntry) -> Option>, 49 | { 50 | fs::read_dir(path) 51 | .map_err(|e| Error::Io(path.to_path_buf(), e))? 52 | .filter_map(|r| match r { 53 | Ok(e) => filter(e), 54 | Err(e) => Some(Err(Error::Io(path.to_path_buf(), e))), 55 | }) 56 | .collect::>>() 57 | } 58 | 59 | pub fn entry_filter(config: &Config, entry: fs::DirEntry) -> Option> { 60 | let meta = match entry.metadata() { 61 | Ok(m) => m, 62 | Err(e) => return Some(Err(Error::Io(entry.path(), e))), 63 | }; 64 | let path = entry.path(); 65 | let ext = path.extension()?.to_str()?; 66 | if meta.is_file() && ext == config.change_sets.entry_ext { 67 | Some(Ok(path)) 68 | } else { 69 | None 70 | } 71 | } 72 | 73 | pub fn get_relative_path, Q: AsRef>(path: P, prefix: Q) -> Result { 74 | Ok(path.as_ref().strip_prefix(prefix.as_ref())?.to_path_buf()) 75 | } 76 | 77 | pub fn file_exists>(path: P) -> bool { 78 | let path = path.as_ref(); 79 | if let Ok(meta) = fs::metadata(path) { 80 | return meta.is_file(); 81 | } 82 | false 83 | } 84 | 85 | pub fn dir_exists>(path: P) -> bool { 86 | let path = path.as_ref(); 87 | if let Ok(meta) = fs::metadata(path) { 88 | return meta.is_dir(); 89 | } 90 | false 91 | } 92 | 93 | #[cfg(test)] 94 | mod test { 95 | use super::get_relative_path; 96 | 97 | #[test] 98 | fn relative_path_extraction() { 99 | assert_eq!( 100 | "mypackage", 101 | get_relative_path("/path/to/mypackage", "/path/to") 102 | .unwrap() 103 | .to_str() 104 | .unwrap() 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `unclog` helps you build your changelog. 2 | 3 | mod changelog; 4 | mod error; 5 | pub mod fs_utils; 6 | mod s11n; 7 | mod vcs; 8 | 9 | pub use changelog::config::{ 10 | BulletStyle, ChangeSetsConfig, ComponentsConfig, Config, UnreleasedConfig, 11 | }; 12 | pub use changelog::{ 13 | ChangeSet, ChangeSetComponentPath, ChangeSetSection, ChangeSetSectionPath, Changelog, 14 | Component, ComponentSection, Entry, EntryChangeSetPath, EntryPath, EntryReleasePath, Release, 15 | }; 16 | pub use error::Error; 17 | pub use vcs::{GenericProject, PlatformId, Project}; 18 | 19 | /// Result type used throughout the `unclog` crate. 20 | pub type Result = std::result::Result; 21 | 22 | // Re-exports 23 | pub use semver::{self, Version}; 24 | -------------------------------------------------------------------------------- /src/s11n.rs: -------------------------------------------------------------------------------- 1 | //! Serialization-related functionality for unclog. 2 | 3 | pub mod from_str; 4 | pub mod optional_from_str; 5 | -------------------------------------------------------------------------------- /src/s11n/from_str.rs: -------------------------------------------------------------------------------- 1 | //! Adapters for serializing/deserializing types that implement `FromStr` and 2 | //! `std::fmt::Display`. 3 | 4 | use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; 5 | 6 | /// Serialize `value.to_string()` 7 | pub fn serialize(value: &T, serializer: S) -> Result 8 | where 9 | S: Serializer, 10 | T: std::fmt::Display, 11 | { 12 | value.to_string().serialize(serializer) 13 | } 14 | 15 | /// Deserialize a string and attempt to parse it into an instance of type `T`. 16 | pub fn deserialize<'de, D, T>(deserializer: D) -> Result 17 | where 18 | D: Deserializer<'de>, 19 | T: std::str::FromStr, 20 | ::Err: std::fmt::Display, 21 | { 22 | String::deserialize(deserializer)? 23 | .parse::() 24 | .map_err(|e| D::Error::custom(format!("{e}"))) 25 | } 26 | -------------------------------------------------------------------------------- /src/s11n/optional_from_str.rs: -------------------------------------------------------------------------------- 1 | //! De/serialize an optional type that must be converted from/to a string. 2 | 3 | use serde::de::Error; 4 | use serde::{Deserialize, Deserializer, Serializer}; 5 | use std::str::FromStr; 6 | 7 | pub fn serialize(value: &Option, serializer: S) -> Result 8 | where 9 | S: Serializer, 10 | T: ToString, 11 | { 12 | match value { 13 | Some(t) => serializer.serialize_some(&t.to_string()), 14 | None => serializer.serialize_none(), 15 | } 16 | } 17 | 18 | pub fn deserialize<'de, D, T>(deserializer: D) -> Result, D::Error> 19 | where 20 | D: Deserializer<'de>, 21 | T: FromStr, 22 | T::Err: std::error::Error, 23 | { 24 | let s = match Option::::deserialize(deserializer)? { 25 | Some(s) => s, 26 | None => return Ok(None), 27 | }; 28 | Ok(Some(s.parse().map_err(|e: ::Err| { 29 | D::Error::custom(e.to_string()) 30 | })?)) 31 | } 32 | -------------------------------------------------------------------------------- /src/vcs.rs: -------------------------------------------------------------------------------- 1 | //! API for dealing with version control systems (Git) and VCS platforms (e.g. 2 | //! GitHub). 3 | 4 | use crate::{fs_utils::path_to_str, Error, Result}; 5 | use log::{debug, info}; 6 | use std::{convert::TryFrom, path::Path, str::FromStr}; 7 | use url::Url; 8 | 9 | /// Provides a way of referencing a change through the VCS platform. 10 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 11 | pub enum PlatformId { 12 | /// The change is referenced by way of issue number. 13 | Issue(u32), 14 | /// The change is referenced by way of pull request number. 15 | PullRequest(u32), 16 | } 17 | 18 | impl PlatformId { 19 | /// Return the integer ID associated with this platform-specific ID. 20 | pub fn id(&self) -> u32 { 21 | match self { 22 | Self::Issue(issue) => *issue, 23 | Self::PullRequest(pull_request) => *pull_request, 24 | } 25 | } 26 | } 27 | 28 | /// Generic definition of an online Git project. 29 | pub trait GenericProject { 30 | fn change_url(&self, platform_id: PlatformId) -> Result; 31 | fn url_str(&self) -> String; 32 | fn url(&self) -> Url; 33 | } 34 | 35 | impl std::fmt::Display for dyn GenericProject { 36 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 37 | write!(f, "{}", self.url_str()) 38 | } 39 | } 40 | 41 | /// A project on GitHub. 42 | #[derive(Debug, Clone, PartialEq, Eq)] 43 | pub struct GitHubProject { 44 | /// The organization or user associated with this project. 45 | pub owner: String, 46 | /// The ID of the project. 47 | pub project: String, 48 | } 49 | 50 | impl TryFrom<&Url> for GitHubProject { 51 | type Error = Error; 52 | 53 | fn try_from(url: &Url) -> Result { 54 | let host = url 55 | .host_str() 56 | .ok_or_else(|| Error::UrlMissingHost(url.to_string()))?; 57 | 58 | if host != "github.com" { 59 | return Err(Error::NotGitHubProject(url.to_string())); 60 | } 61 | 62 | let path_parts = url 63 | .path_segments() 64 | .ok_or_else(|| Error::GitHubProjectMissingPath(url.to_string()))? 65 | .collect::>(); 66 | 67 | if path_parts.len() < 2 { 68 | return Err(Error::InvalidGitHubProjectPath(url.to_string())); 69 | } 70 | 71 | Ok(Self { 72 | owner: path_parts[0].to_owned(), 73 | project: path_parts[1].trim_end_matches(".git").to_owned(), 74 | }) 75 | } 76 | } 77 | 78 | impl FromStr for GitHubProject { 79 | type Err = Error; 80 | 81 | fn from_str(s: &str) -> Result { 82 | let url = Url::parse(s)?; 83 | Self::try_from(&url) 84 | } 85 | } 86 | 87 | impl std::fmt::Display for GitHubProject { 88 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 89 | write!(f, "{}", self.url_str()) 90 | } 91 | } 92 | 93 | impl GenericProject for GitHubProject { 94 | /// Construct a URL for this project based on the given platform-specific 95 | /// ID. 96 | fn change_url(&self, platform_id: PlatformId) -> Result { 97 | Ok(Url::parse(&format!( 98 | "{}/{}", 99 | self, 100 | match platform_id { 101 | PlatformId::Issue(no) => format!("issues/{no}"), 102 | PlatformId::PullRequest(no) => format!("pull/{no}"), 103 | } 104 | ))?) 105 | } 106 | 107 | fn url_str(&self) -> String { 108 | format!("https://github.com/{}/{}", self.owner, self.project) 109 | } 110 | 111 | fn url(&self) -> Url { 112 | let url_str = self.url_str(); 113 | Url::parse(&url_str).unwrap_or_else(|e| panic!("failed to parse URL \"{url_str}\": {e}")) 114 | } 115 | } 116 | 117 | /// A project on GitLab. 118 | #[derive(Debug, Clone, PartialEq, Eq)] 119 | pub struct GitLabProject { 120 | /// The root url of the project. 121 | pub root_url: String, 122 | /// The host of the project. 123 | pub host: String, 124 | /// The ID of the project. 125 | pub project: String, 126 | } 127 | 128 | impl TryFrom<&Url> for GitLabProject { 129 | type Error = Error; 130 | 131 | fn try_from(url: &Url) -> Result { 132 | let host = url 133 | .host_str() 134 | .ok_or_else(|| Error::UrlMissingHost(url.to_string()))?; 135 | 136 | if !host.contains("gitlab") { 137 | return Err(Error::NotGitHubProject(url.to_string())); 138 | } 139 | 140 | let mut path_parts = url 141 | .path_segments() 142 | .ok_or_else(|| Error::GitHubProjectMissingPath(url.to_string()))? 143 | .collect::>(); 144 | 145 | path_parts.retain(|&x| !x.is_empty()); 146 | 147 | if path_parts.len() < 2 { 148 | return Err(Error::InvalidGitHubProjectPath(url.to_string())); 149 | } 150 | 151 | Ok(Self { 152 | host: host.to_owned(), 153 | root_url: path_parts.as_slice()[..path_parts.len() - 1] 154 | .to_vec() 155 | .join("/"), 156 | project: path_parts[path_parts.len() - 1] 157 | .trim_end_matches(".git") 158 | .to_owned(), 159 | }) 160 | } 161 | } 162 | 163 | impl FromStr for GitLabProject { 164 | type Err = Error; 165 | 166 | fn from_str(s: &str) -> Result { 167 | let url = Url::parse(s)?; 168 | Self::try_from(&url) 169 | } 170 | } 171 | 172 | impl std::fmt::Display for GitLabProject { 173 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 174 | write!(f, "{}", self.url_str()) 175 | } 176 | } 177 | 178 | impl GenericProject for GitLabProject { 179 | /// Construct a URL for this project based on the given platform-specific 180 | /// ID. 181 | fn change_url(&self, platform_id: PlatformId) -> Result { 182 | Ok(Url::parse(&format!( 183 | "{}/{}", 184 | self, 185 | match platform_id { 186 | PlatformId::Issue(no) => format!("-/issues/{}", no), 187 | PlatformId::PullRequest(no) => format!("-/merge_requests/{}", no), 188 | } 189 | ))?) 190 | } 191 | 192 | fn url_str(&self) -> String { 193 | format!("https://{}/{}/{}", self.host, self.root_url, self.project) 194 | } 195 | 196 | fn url(&self) -> Url { 197 | let url_str = self.url_str(); 198 | Url::parse(&url_str) 199 | .unwrap_or_else(|e| panic!("failed to parse URL \"{}\": {}", url_str, e)) 200 | } 201 | } 202 | 203 | pub enum Project { 204 | GitHubProject(GitHubProject), 205 | GitLabProject(GitLabProject), 206 | } 207 | 208 | impl GenericProject for Project { 209 | fn change_url(&self, platform_id: PlatformId) -> Result { 210 | match self { 211 | Project::GitHubProject(github) => github.change_url(platform_id), 212 | Project::GitLabProject(gitlab) => gitlab.change_url(platform_id), 213 | } 214 | } 215 | 216 | fn url_str(&self) -> String { 217 | match self { 218 | Project::GitHubProject(github) => github.url_str(), 219 | Project::GitLabProject(gitlab) => gitlab.url_str(), 220 | } 221 | } 222 | 223 | fn url(&self) -> Url { 224 | match self { 225 | Project::GitHubProject(github) => github.url(), 226 | Project::GitLabProject(gitlab) => gitlab.url(), 227 | } 228 | } 229 | } 230 | 231 | impl std::fmt::Display for Project { 232 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 233 | match self { 234 | Project::GitHubProject(github) => github.fmt(f), 235 | Project::GitLabProject(gitlab) => gitlab.fmt(f), 236 | } 237 | } 238 | } 239 | 240 | pub fn from_git_repo(path: &Path, remote: &str) -> Result { 241 | debug!("Opening path as Git repository: {}", path_to_str(path)); 242 | let repo = git2::Repository::open(path)?; 243 | let remote_url = repo 244 | .find_remote(remote)? 245 | .url() 246 | .map(String::from) 247 | .ok_or_else(|| Error::InvalidGitRemoteUrl(remote.to_owned(), path_to_str(path)))?; 248 | debug!("Found Git remote \"{}\" URL: {}", remote, remote_url); 249 | let remote_url = parse_url(&remote_url)?; 250 | debug!("Parsed remote URL as: {}", remote_url.to_string()); 251 | 252 | try_from(&remote_url) 253 | } 254 | 255 | pub fn try_from(url: &Url) -> Result { 256 | if let Ok(maybe_github_project) = GitHubProject::try_from(url) { 257 | info!("Deduced GitHub project!"); 258 | Ok(Project::GitHubProject(maybe_github_project)) 259 | } else if let Ok(maybe_gitlab_project) = GitLabProject::try_from(url) { 260 | info!("Deduced GitLab project!"); 261 | Ok(Project::GitLabProject(maybe_gitlab_project)) 262 | } else { 263 | Err(Error::UnrecognizedProjectType(url.to_string())) 264 | } 265 | } 266 | 267 | fn parse_url(u: &str) -> Result { 268 | // Not an SSH URL 269 | if u.starts_with("http://") || u.starts_with("https://") { 270 | return Ok(Url::parse(u)?); 271 | } 272 | Ok(Url::parse(&format!("ssh://{}", u.replace(':', "/")))?) 273 | } 274 | 275 | #[cfg(test)] 276 | mod test { 277 | use super::*; 278 | 279 | #[test] 280 | fn github_project_url_parsing() { 281 | // With or without the trailing slash 282 | const URLS: &[&str] = &[ 283 | "https://github.com/informalsystems/unclog", 284 | "https://github.com/informalsystems/unclog/", 285 | "https://github.com/informalsystems/unclog.git", 286 | "ssh://git@github.com/informalsystems/unclog.git", 287 | ]; 288 | let expected = GitHubProject { 289 | owner: "informalsystems".to_owned(), 290 | project: "unclog".to_owned(), 291 | }; 292 | for url in URLS { 293 | let actual = GitHubProject::from_str(url).unwrap(); 294 | assert_eq!(expected, actual); 295 | } 296 | } 297 | 298 | #[test] 299 | fn github_project_url_construction() { 300 | let project = GitHubProject { 301 | owner: "informalsystems".to_owned(), 302 | project: "unclog".to_owned(), 303 | }; 304 | assert_eq!( 305 | project.to_string(), 306 | "https://github.com/informalsystems/unclog" 307 | ) 308 | } 309 | 310 | #[test] 311 | fn gitlab_project_url_parsing() { 312 | // With or without the trailing slash 313 | const URLS: &[&str] = &[ 314 | "https://gitlab.host.com/group/project", 315 | "https://gitlab.host.com/group/project/", 316 | "https://gitlab.host.com/group/project.git", 317 | "ssh://git@gitlab.host.com/group/project.git", 318 | ]; 319 | let expected = GitLabProject { 320 | root_url: "group".to_owned(), 321 | host: "gitlab.host.com".to_owned(), 322 | project: "project".to_owned(), 323 | }; 324 | for url in URLS { 325 | let actual = GitLabProject::from_str(url).unwrap(); 326 | assert_eq!(expected, actual); 327 | } 328 | } 329 | 330 | #[test] 331 | fn gitlab_project_url_construction() { 332 | let project = GitLabProject { 333 | root_url: "group".to_owned(), 334 | host: "gitlab.host.com".to_owned(), 335 | project: "project".to_owned(), 336 | }; 337 | assert_eq!(project.to_string(), "https://gitlab.host.com/group/project") 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /tests/full/config.toml: -------------------------------------------------------------------------------- 1 | project_url = "https://github.com/org/project" 2 | -------------------------------------------------------------------------------- /tests/full/epilogue.md: -------------------------------------------------------------------------------- 1 | This goes at the end of the CHANGELOG. 2 | -------------------------------------------------------------------------------- /tests/full/expected-released-only.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This goes at the BEGINNING of the changelog. 4 | 5 | ## v0.2.1 6 | 7 | *31 Mar 2021* 8 | 9 | ### BREAKING CHANGES 10 | 11 | - [Component 2](2nd-component) 12 | - Gargle the truffle 13 | - Travel the gravel 14 | - Laugh at the gaggle 15 | 16 | ### FEATURES 17 | 18 | - General 19 | - Nibble the bubbles 20 | - Carry the wobbles 21 | - component1 22 | - Fasten the handles 23 | - Hasten the sandals 24 | - [Component 2](2nd-component) 25 | - Waggle the juggle 26 | - Drizzle the funnel 27 | 28 | ## v0.2.0 29 | 30 | *27 Feb 2021* 31 | 32 | It's finally out, yay! 33 | 34 | ### BREAKING CHANGES 35 | 36 | - Let the tune meet the unlawful disaster 37 | - Educate the specialist vigorously 38 | 39 | ### FEATURES 40 | 41 | - Stir the engineer with the foolish sound 42 | - Attend the entry with an ambitious blank 43 | 44 | ## v0.2.0-beta 45 | 46 | *13 Feb 2021* 47 | 48 | This is the second pre-release of v0.2.0. 49 | 50 | ### FEATURES 51 | 52 | - Balance the antique garbage 53 | - Spark the chair in the storm 54 | 55 | ### IMPROVEMENTS 56 | 57 | - Allow the fan to meet his shoe 58 | 59 | ## v0.2.0-alpha 60 | 61 | *3 Feb 2021* 62 | 63 | This is the first pre-release of our upcoming v0.2.0 release. 64 | 65 | ### BREAKING CHANGES 66 | 67 | - Add serene brown drops to the scattered magazine 68 | - Tick the effect in actual chemicals 69 | - Eat the resort and cry 70 | 71 | ### IMPROVEMENTS 72 | 73 | - Hover over the historian with a melodic mix 74 | that travels over multiple lines. 75 | 76 | ## v0.1.1 77 | 78 | *31 Mar 2021* 79 | 80 | ### BUG FIXES 81 | 82 | - Some emergency patch for the old release line 83 | 84 | ## v0.1.0 85 | 86 | *8 Jan 2021* 87 | 88 | This is our first release! 89 | 90 | This goes at the end of the CHANGELOG. 91 | -------------------------------------------------------------------------------- /tests/full/expected-sorted-by-entry-text.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This goes at the BEGINNING of the changelog. 4 | 5 | ## Unreleased 6 | 7 | ### FEATURES 8 | 9 | - Travel through space as a beneficial example 10 | 11 | ### IMPROVEMENTS 12 | 13 | - Eat the profile 14 | 15 | ## v0.2.1 16 | 17 | *31 Mar 2021* 18 | 19 | ### BREAKING CHANGES 20 | 21 | - [Component 2](2nd-component) 22 | - Gargle the truffle 23 | - Laugh at the gaggle 24 | - Travel the gravel 25 | 26 | ### FEATURES 27 | 28 | - General 29 | - Carry the wobbles 30 | - Nibble the bubbles 31 | - component1 32 | - Fasten the handles 33 | - Hasten the sandals 34 | - [Component 2](2nd-component) 35 | - Drizzle the funnel 36 | - Waggle the juggle 37 | 38 | ## v0.2.0 39 | 40 | *27 Feb 2021* 41 | 42 | It's finally out, yay! 43 | 44 | ### BREAKING CHANGES 45 | 46 | - Educate the specialist vigorously 47 | - Let the tune meet the unlawful disaster 48 | 49 | ### FEATURES 50 | 51 | - Attend the entry with an ambitious blank 52 | - Stir the engineer with the foolish sound 53 | 54 | ## v0.2.0-beta 55 | 56 | *13 Feb 2021* 57 | 58 | This is the second pre-release of v0.2.0. 59 | 60 | ### FEATURES 61 | 62 | - Balance the antique garbage 63 | - Spark the chair in the storm 64 | 65 | ### IMPROVEMENTS 66 | 67 | - Allow the fan to meet his shoe 68 | 69 | ## v0.2.0-alpha 70 | 71 | *3 Feb 2021* 72 | 73 | This is the first pre-release of our upcoming v0.2.0 release. 74 | 75 | ### BREAKING CHANGES 76 | 77 | - Add serene brown drops to the scattered magazine 78 | - Eat the resort and cry 79 | - Tick the effect in actual chemicals 80 | 81 | ### IMPROVEMENTS 82 | 83 | - Hover over the historian with a melodic mix 84 | that travels over multiple lines. 85 | 86 | ## v0.1.1 87 | 88 | *31 Mar 2021* 89 | 90 | ### BUG FIXES 91 | 92 | - Some emergency patch for the old release line 93 | 94 | ## v0.1.0 95 | 96 | *8 Jan 2021* 97 | 98 | This is our first release! 99 | 100 | This goes at the end of the CHANGELOG. 101 | -------------------------------------------------------------------------------- /tests/full/expected-sorted-by-release-date.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This goes at the BEGINNING of the changelog. 4 | 5 | ## Unreleased 6 | 7 | ### FEATURES 8 | 9 | - Travel through space as a beneficial example 10 | 11 | ### IMPROVEMENTS 12 | 13 | - Eat the profile 14 | 15 | ## v0.2.1 16 | 17 | *31 Mar 2021* 18 | 19 | ### BREAKING CHANGES 20 | 21 | - [Component 2](2nd-component) 22 | - Gargle the truffle 23 | - Travel the gravel 24 | - Laugh at the gaggle 25 | 26 | ### FEATURES 27 | 28 | - General 29 | - Nibble the bubbles 30 | - Carry the wobbles 31 | - component1 32 | - Fasten the handles 33 | - Hasten the sandals 34 | - [Component 2](2nd-component) 35 | - Waggle the juggle 36 | - Drizzle the funnel 37 | 38 | ## v0.1.1 39 | 40 | *31 Mar 2021* 41 | 42 | ### BUG FIXES 43 | 44 | - Some emergency patch for the old release line 45 | 46 | ## v0.2.0 47 | 48 | *27 Feb 2021* 49 | 50 | It's finally out, yay! 51 | 52 | ### BREAKING CHANGES 53 | 54 | - Let the tune meet the unlawful disaster 55 | - Educate the specialist vigorously 56 | 57 | ### FEATURES 58 | 59 | - Stir the engineer with the foolish sound 60 | - Attend the entry with an ambitious blank 61 | 62 | ## v0.2.0-beta 63 | 64 | *13 Feb 2021* 65 | 66 | This is the second pre-release of v0.2.0. 67 | 68 | ### FEATURES 69 | 70 | - Balance the antique garbage 71 | - Spark the chair in the storm 72 | 73 | ### IMPROVEMENTS 74 | 75 | - Allow the fan to meet his shoe 76 | 77 | ## v0.2.0-alpha 78 | 79 | *3 Feb 2021* 80 | 81 | This is the first pre-release of our upcoming v0.2.0 release. 82 | 83 | ### BREAKING CHANGES 84 | 85 | - Add serene brown drops to the scattered magazine 86 | - Tick the effect in actual chemicals 87 | - Eat the resort and cry 88 | 89 | ### IMPROVEMENTS 90 | 91 | - Hover over the historian with a melodic mix 92 | that travels over multiple lines. 93 | 94 | ## v0.1.0 95 | 96 | *8 Jan 2021* 97 | 98 | This is our first release! 99 | 100 | This goes at the end of the CHANGELOG. 101 | -------------------------------------------------------------------------------- /tests/full/expected.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | This goes at the BEGINNING of the changelog. 4 | 5 | ## Unreleased 6 | 7 | ### FEATURES 8 | 9 | - Travel through space as a beneficial example 10 | 11 | ### IMPROVEMENTS 12 | 13 | - Eat the profile 14 | 15 | ## v0.2.1 16 | 17 | *31 Mar 2021* 18 | 19 | ### BREAKING CHANGES 20 | 21 | - [Component 2](2nd-component) 22 | - Gargle the truffle 23 | - Travel the gravel 24 | - Laugh at the gaggle 25 | 26 | ### FEATURES 27 | 28 | - General 29 | - Nibble the bubbles 30 | - Carry the wobbles 31 | - component1 32 | - Fasten the handles 33 | - Hasten the sandals 34 | - [Component 2](2nd-component) 35 | - Waggle the juggle 36 | - Drizzle the funnel 37 | 38 | ## v0.2.0 39 | 40 | *27 Feb 2021* 41 | 42 | It's finally out, yay! 43 | 44 | ### BREAKING CHANGES 45 | 46 | - Let the tune meet the unlawful disaster 47 | - Educate the specialist vigorously 48 | 49 | ### FEATURES 50 | 51 | - Stir the engineer with the foolish sound 52 | - Attend the entry with an ambitious blank 53 | 54 | ## v0.2.0-beta 55 | 56 | *13 Feb 2021* 57 | 58 | This is the second pre-release of v0.2.0. 59 | 60 | ### FEATURES 61 | 62 | - Balance the antique garbage 63 | - Spark the chair in the storm 64 | 65 | ### IMPROVEMENTS 66 | 67 | - Allow the fan to meet his shoe 68 | 69 | ## v0.2.0-alpha 70 | 71 | *3 Feb 2021* 72 | 73 | This is the first pre-release of our upcoming v0.2.0 release. 74 | 75 | ### BREAKING CHANGES 76 | 77 | - Add serene brown drops to the scattered magazine 78 | - Tick the effect in actual chemicals 79 | - Eat the resort and cry 80 | 81 | ### IMPROVEMENTS 82 | 83 | - Hover over the historian with a melodic mix 84 | that travels over multiple lines. 85 | 86 | ## v0.1.1 87 | 88 | *31 Mar 2021* 89 | 90 | ### BUG FIXES 91 | 92 | - Some emergency patch for the old release line 93 | 94 | ## v0.1.0 95 | 96 | *8 Jan 2021* 97 | 98 | This is our first release! 99 | 100 | This goes at the end of the CHANGELOG. 101 | -------------------------------------------------------------------------------- /tests/full/prologue.md: -------------------------------------------------------------------------------- 1 | This goes at the BEGINNING of the changelog. 2 | -------------------------------------------------------------------------------- /tests/full/unreleased/features/45-travel.md: -------------------------------------------------------------------------------- 1 | - Travel through space as a beneficial example 2 | -------------------------------------------------------------------------------- /tests/full/unreleased/improvements/43-eat-profile.md: -------------------------------------------------------------------------------- 1 | - Eat the profile 2 | -------------------------------------------------------------------------------- /tests/full/v0.1.0/summary.md: -------------------------------------------------------------------------------- 1 | *8 Jan 2021* 2 | 3 | This is our first release! 4 | -------------------------------------------------------------------------------- /tests/full/v0.1.1/bug-fixes/123-some-emergency-patch.md: -------------------------------------------------------------------------------- 1 | - Some emergency patch for the old release line 2 | -------------------------------------------------------------------------------- /tests/full/v0.1.1/summary.md: -------------------------------------------------------------------------------- 1 | *31 Mar 2021* 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0-alpha/breaking-changes/23-brown-drops.md: -------------------------------------------------------------------------------- 1 | - Add serene brown drops to the scattered magazine -------------------------------------------------------------------------------- /tests/full/v0.2.0-alpha/breaking-changes/25-tick-effect.md: -------------------------------------------------------------------------------- 1 | - Tick the effect in actual chemicals 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0-alpha/breaking-changes/26-eat-resort.md: -------------------------------------------------------------------------------- 1 | - Eat the resort and cry -------------------------------------------------------------------------------- /tests/full/v0.2.0-alpha/improvements/21-hover-historian.md: -------------------------------------------------------------------------------- 1 | - Hover over the historian with a melodic mix 2 | that travels over multiple lines. -------------------------------------------------------------------------------- /tests/full/v0.2.0-alpha/summary.md: -------------------------------------------------------------------------------- 1 | *3 Feb 2021* 2 | 3 | This is the first pre-release of our upcoming v0.2.0 release. -------------------------------------------------------------------------------- /tests/full/v0.2.0-beta/features/30-balance-garbage.md: -------------------------------------------------------------------------------- 1 | - Balance the antique garbage -------------------------------------------------------------------------------- /tests/full/v0.2.0-beta/features/33-spark-char.md: -------------------------------------------------------------------------------- 1 | - Spark the chair in the storm 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0-beta/improvements/31-fan-shoe.md: -------------------------------------------------------------------------------- 1 | - Allow the fan to meet his shoe -------------------------------------------------------------------------------- /tests/full/v0.2.0-beta/summary.md: -------------------------------------------------------------------------------- 1 | *13 Feb 2021* 2 | 3 | This is the second pre-release of v0.2.0. 4 | -------------------------------------------------------------------------------- /tests/full/v0.2.0/breaking-changes/41-tune-disaster.md: -------------------------------------------------------------------------------- 1 | - Let the tune meet the unlawful disaster 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0/breaking-changes/42-educate-specialist.md: -------------------------------------------------------------------------------- 1 | - Educate the specialist vigorously 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0/features/39-stir-engineer.md: -------------------------------------------------------------------------------- 1 | - Stir the engineer with the foolish sound 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0/features/40-attend-entry.md: -------------------------------------------------------------------------------- 1 | - Attend the entry with an ambitious blank 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.0/summary.md: -------------------------------------------------------------------------------- 1 | *27 Feb 2021* 2 | 3 | It's finally out, yay! -------------------------------------------------------------------------------- /tests/full/v0.2.1/breaking-changes/component2/73-gargle.md: -------------------------------------------------------------------------------- 1 | - Gargle the truffle 2 | -------------------------------------------------------------------------------- /tests/full/v0.2.1/breaking-changes/component2/76-travel.md: -------------------------------------------------------------------------------- 1 | - Travel the gravel -------------------------------------------------------------------------------- /tests/full/v0.2.1/breaking-changes/component2/80-laugh.md: -------------------------------------------------------------------------------- 1 | - Laugh at the gaggle -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/41-nibble.md: -------------------------------------------------------------------------------- 1 | - Nibble the bubbles -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/42-carry.md: -------------------------------------------------------------------------------- 1 | - Carry the wobbles -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/component1/44-fasten.md: -------------------------------------------------------------------------------- 1 | - Fasten the handles -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/component1/45-hasten.md: -------------------------------------------------------------------------------- 1 | - Hasten the sandals -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/component2/50-waggle.md: -------------------------------------------------------------------------------- 1 | - Waggle the juggle -------------------------------------------------------------------------------- /tests/full/v0.2.1/features/component2/53-drizzle.md: -------------------------------------------------------------------------------- 1 | - Drizzle the funnel -------------------------------------------------------------------------------- /tests/full/v0.2.1/summary.md: -------------------------------------------------------------------------------- 1 | *31 Mar 2021* -------------------------------------------------------------------------------- /tests/integration.rs: -------------------------------------------------------------------------------- 1 | //! Integration tests for `unclog`. 2 | 3 | use lazy_static::lazy_static; 4 | use std::{path::Path, sync::Mutex}; 5 | use unclog::{ChangeSetComponentPath, Changelog, Config, EntryReleasePath, PlatformId}; 6 | 7 | lazy_static! { 8 | static ref LOGGING_INITIALIZED: Mutex = Mutex::new(0); 9 | } 10 | 11 | fn init_logger() { 12 | let mut initialized = LOGGING_INITIALIZED.lock().unwrap(); 13 | if *initialized == 0 { 14 | env_logger::init(); 15 | *initialized = 1; 16 | log::debug!("env logger initialized"); 17 | } else { 18 | log::debug!("env logger already initialized"); 19 | } 20 | } 21 | 22 | #[test] 23 | fn full() { 24 | const CONFIG_FILE: &str = r#" 25 | [components.all] 26 | component1 = { name = "component1" } 27 | component2 = { name = "Component 2", path = "2nd-component" } 28 | "#; 29 | 30 | init_logger(); 31 | let config = toml::from_str(CONFIG_FILE).unwrap(); 32 | let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); 33 | let expected = std::fs::read_to_string("./tests/full/expected.md").unwrap(); 34 | assert_eq!(expected, changelog.render_all(&config)); 35 | } 36 | 37 | #[test] 38 | fn released_only() { 39 | const CONFIG_FILE: &str = r#" 40 | [components.all] 41 | component1 = { name = "component1" } 42 | component2 = { name = "Component 2", path = "2nd-component" } 43 | "#; 44 | 45 | init_logger(); 46 | let config = toml::from_str(CONFIG_FILE).unwrap(); 47 | let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); 48 | let expected = std::fs::read_to_string("./tests/full/expected-released-only.md").unwrap(); 49 | assert_eq!(expected, changelog.render_released(&config)); 50 | } 51 | 52 | #[test] 53 | fn full_sorted_by_entry_text() { 54 | const CONFIG_FILE: &str = r#" 55 | [change_set_sections] 56 | sort_entries_by = "entry-text" 57 | 58 | [components.all] 59 | component1 = { name = "component1" } 60 | component2 = { name = "Component 2", path = "2nd-component" } 61 | "#; 62 | 63 | init_logger(); 64 | let config = toml::from_str(CONFIG_FILE).unwrap(); 65 | let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); 66 | let expected = 67 | std::fs::read_to_string("./tests/full/expected-sorted-by-entry-text.md").unwrap(); 68 | assert_eq!(expected, changelog.render_all(&config)); 69 | } 70 | 71 | #[test] 72 | fn full_sorted_by_release_date() { 73 | const CONFIG_FILE: &str = r#" 74 | sort_releases_by = ["date"] 75 | release_date_formats = [ 76 | "*%d %b %Y*" 77 | ] 78 | 79 | [components.all] 80 | component1 = { name = "component1" } 81 | component2 = { name = "Component 2", path = "2nd-component" } 82 | "#; 83 | 84 | init_logger(); 85 | let config = toml::from_str(CONFIG_FILE).unwrap(); 86 | let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); 87 | let expected = 88 | std::fs::read_to_string("./tests/full/expected-sorted-by-release-date.md").unwrap(); 89 | assert_eq!(expected, changelog.render_all(&config)); 90 | } 91 | 92 | #[test] 93 | fn change_template_rendering() { 94 | init_logger(); 95 | let config = Config::read_from_file("./tests/full/config.toml").unwrap(); 96 | let cases = vec![ 97 | (PlatformId::Issue(123), "- This introduces a new *breaking* change\n ([\\#123](https://github.com/org/project/issues/123))"), 98 | (PlatformId::PullRequest(23), "- This introduces a new *breaking* change\n ([\\#23](https://github.com/org/project/pull/23))"), 99 | ]; 100 | for (platform_id, expected) in cases { 101 | let actual = Changelog::render_unreleased_entry_from_template( 102 | &config, 103 | Path::new("./tests/full"), 104 | "breaking-changes", 105 | None, 106 | "some-new-breaking-change", 107 | platform_id, 108 | "This introduces a new *breaking* change", 109 | ) 110 | .unwrap(); 111 | assert_eq!(actual, expected); 112 | } 113 | } 114 | 115 | #[test] 116 | fn entry_iteration() { 117 | const CONFIG_FILE: &str = r#" 118 | [components.all] 119 | component1 = { name = "component1" } 120 | component2 = { name = "Component 2", path = "2nd-component" } 121 | "#; 122 | 123 | const UNRELEASED: &str = "Unreleased"; 124 | const GENERAL: &str = "General"; 125 | const EXPECTED_ENTRIES: &[(&str, &str, &str, &str)] = &[ 126 | ( 127 | UNRELEASED, 128 | "FEATURES", 129 | GENERAL, 130 | "- Travel through space as a beneficial example", 131 | ), 132 | (UNRELEASED, "IMPROVEMENTS", GENERAL, "- Eat the profile"), 133 | ( 134 | "v0.2.1", 135 | "BREAKING CHANGES", 136 | "Component 2", 137 | "- Gargle the truffle", 138 | ), 139 | ( 140 | "v0.2.1", 141 | "BREAKING CHANGES", 142 | "Component 2", 143 | "- Travel the gravel", 144 | ), 145 | ( 146 | "v0.2.1", 147 | "BREAKING CHANGES", 148 | "Component 2", 149 | "- Laugh at the gaggle", 150 | ), 151 | ("v0.2.1", "FEATURES", GENERAL, "- Nibble the bubbles"), 152 | ("v0.2.1", "FEATURES", GENERAL, "- Carry the wobbles"), 153 | ("v0.2.1", "FEATURES", "component1", "- Fasten the handles"), 154 | ("v0.2.1", "FEATURES", "component1", "- Hasten the sandals"), 155 | ]; 156 | 157 | init_logger(); 158 | let config = toml::from_str(CONFIG_FILE).unwrap(); 159 | let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); 160 | let mut entries = changelog.entries(); 161 | 162 | for (i, (expected_release, expected_section, expected_component, expected_entry_details)) in 163 | EXPECTED_ENTRIES.iter().enumerate() 164 | { 165 | let next = entries.next().unwrap(); 166 | assert_eq!(next.changelog, &changelog); 167 | let change_set_path = match next.release_path { 168 | EntryReleasePath::Unreleased(change_set_path) => { 169 | assert_eq!(&UNRELEASED, expected_release, "for entry {i}"); 170 | change_set_path 171 | } 172 | EntryReleasePath::Released(release, change_set_path) => { 173 | assert_eq!(&release.id, expected_release, "for entry {i}"); 174 | assert_eq!( 175 | &change_set_path.section_path.change_set_section.title, 176 | expected_section 177 | ); 178 | change_set_path 179 | } 180 | }; 181 | assert_eq!( 182 | &change_set_path.section_path.change_set_section.title, 183 | expected_section 184 | ); 185 | match change_set_path.section_path.component_path { 186 | ChangeSetComponentPath::General(entry) => { 187 | assert_eq!(&GENERAL, expected_component); 188 | assert_eq!(&entry.details, expected_entry_details); 189 | } 190 | ChangeSetComponentPath::Component(component_section, entry) => { 191 | assert_eq!(&component_section.name, expected_component); 192 | assert_eq!(&entry.details, expected_entry_details); 193 | } 194 | } 195 | } 196 | } 197 | --------------------------------------------------------------------------------