├── .gitignore ├── rust-toolchain.toml ├── rustfmt.toml ├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ ├── publish.yml │ └── checks.yml ├── knope.toml ├── Cargo.toml ├── src ├── lib.rs ├── changeset.rs ├── versioning.rs └── change.rs ├── CHANGELOG.md ├── LICENSE-MIT ├── tests ├── change.rs └── change_set.rs ├── Cargo.lock ├── CODE_OF_CONDUCT.md ├── README.md └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /target 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.92.0" 3 | components = ["rustfmt", "clippy"] 4 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | unstable_features = true 2 | group_imports = "StdExternalCrate" 3 | imports_granularity = "Crate" 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [dbanty] 4 | patreon: dbanty 5 | ko_fi: dbanty 6 | -------------------------------------------------------------------------------- /knope.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | versioned_files = ["Cargo.toml", "Cargo.lock"] 3 | changelog = "CHANGELOG.md" 4 | 5 | [[workflows]] 6 | name = "document-change" 7 | 8 | [[workflows.steps]] 9 | type = "CreateChangeFile" 10 | 11 | [github] 12 | owner = "knope-dev" 13 | repo = "changesets" 14 | 15 | [bot.releases] 16 | enabled = true 17 | 18 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":semanticCommitTypeAll(chore)", 5 | "github>Turbo87/renovate-config//rust/updateToolchain" 6 | ], 7 | "platformAutomerge": true, 8 | "lockFileMaintenance": { 9 | "enabled": true 10 | }, 11 | "packageRules": [ 12 | { 13 | "matchUpdateTypes": [ 14 | "minor", 15 | "patch", 16 | "pin", 17 | "digest" 18 | ], 19 | "automerge": true 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "changesets" 3 | authors = ["Dylan Anthony"] 4 | edition = "2024" 5 | rust-version = "1.85" 6 | description = "A library for parsing and creating changeset files" 7 | readme = "README.md" 8 | repository = "https://github.com/knope-dev/changesets" 9 | license = "MIT OR Apache-2.0" 10 | keywords = ["changeset", "changelog", "semantic", "versioning", "release"] 11 | categories = ["development-tools"] 12 | version = "0.4.0" 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dev-dependencies] 17 | tempfile = "3.10.1" 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | workflow_dispatch: 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | publish-crate: 12 | runs-on: ubuntu-latest 13 | environment: "crates.io" 14 | permissions: 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 18 | - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 19 | - uses: rust-lang/crates-io-auth-action@v1 20 | id: auth 21 | - run: cargo publish --workspace 22 | env: 23 | CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} 24 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![deny(clippy::all)] 3 | #![deny(clippy::pedantic)] 4 | #![deny(clippy::cargo)] 5 | #![deny(warnings)] 6 | // Don't panic! 7 | #![cfg_attr( 8 | not(test), 9 | deny( 10 | clippy::panic, 11 | clippy::exit, 12 | clippy::unimplemented, 13 | clippy::todo, 14 | clippy::expect_used, 15 | clippy::unwrap_used, 16 | clippy::indexing_slicing, 17 | clippy::missing_panics_doc 18 | ) 19 | )] 20 | 21 | pub use change::{Change, LoadingError, ParsingError, UniqueId}; 22 | pub use changeset::{ChangeSet, PackageChange, Release}; 23 | pub use versioning::{BuildVersioningError, ChangeType, PackageName, Versioning}; 24 | 25 | mod change; 26 | mod changeset; 27 | mod versioning; 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.0 (2025-03-08) 2 | 3 | ### Breaking Changes 4 | 5 | - Update edition to 2024 and MSRV to 1.85 6 | 7 | #### Stop normalizing paths for existing files 8 | 9 | If you already have a change file, 10 | potentially created by another tool, 11 | this library renormalizing the file name can cause unexpected errors (for example, when writing _back_ to the file). 12 | 13 | Internally, `Change::from_file`, `Change::from_file_name_and_content`, 14 | and `ChangeSet::from_directory` all now use `UniqueId::exact`. 15 | 16 | When creating a _new_ change file (not opening an existing one), 17 | you should construct a `Change { ... }` yourself and use `UniqueId::normalize` to get the previous behavior. 18 | 19 | #### Removed `From>` for `UniqueId` 20 | 21 | Instead, use either `UniqueId::normalize` or `UniqueId::exact` to specify if you'd like the value to be transformed. 22 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Run Checks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | merge_group: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 14 | - uses: Swatinem/rust-cache@v2 15 | - run: cargo test 16 | 17 | lint: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 21 | - uses: Swatinem/rust-cache@v2 22 | - run: cargo clippy 23 | 24 | check-format: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 28 | - run: | 29 | rustup override set nightly 30 | rustup update nightly 31 | rustup component add rustfmt 32 | 33 | - run: cargo fmt --check 34 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dylan Anthony 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/change.rs: -------------------------------------------------------------------------------- 1 | use changesets::{Change, ChangeType, UniqueId, Versioning}; 2 | use tempfile::tempdir; 3 | 4 | #[test] 5 | fn create_change() { 6 | let basic_programmatic = Change { 7 | unique_id: UniqueId::exact("basic_programmatic"), 8 | versioning: Versioning::from(("my_package", ChangeType::Minor)), 9 | summary: String::from("### This is a summary"), 10 | }; 11 | 12 | let multiple_packages = Change { 13 | unique_id: UniqueId::exact("multiple_packages"), 14 | versioning: Versioning::try_from_iter([ 15 | ("my_package", ChangeType::Minor), 16 | ("my_other_package", ChangeType::Major), 17 | ]) 18 | .unwrap(), 19 | summary: String::from("### This is a summary"), 20 | }; 21 | 22 | let dir = tempdir().unwrap(); 23 | let basic_change_path = basic_programmatic.write_to_directory(&dir).unwrap(); 24 | let multiple_change_path = multiple_packages.write_to_directory(&dir).unwrap(); 25 | 26 | let contents = std::fs::read_to_string(basic_change_path).unwrap(); 27 | assert_eq!( 28 | contents, 29 | "---\nmy_package: minor\n---\n\n### This is a summary\n" 30 | ); 31 | 32 | let contents = std::fs::read_to_string(multiple_change_path).unwrap(); 33 | // Order of packages is not guaranteed, they are semantically the same in YAML 34 | let first_possibility = 35 | "---\nmy_package: minor\nmy_other_package: major\n---\n\n### This is a summary\n"; 36 | let second_possibility = 37 | "---\nmy_other_package: major\nmy_package: minor\n---\n\n### This is a summary\n"; 38 | assert!( 39 | contents == first_possibility || contents == second_possibility, 40 | "Contents were not as expected: {}", 41 | contents 42 | ); 43 | } 44 | 45 | #[test] 46 | fn load_change() { 47 | let dir = tempdir().unwrap(); 48 | let change_path = dir.path().join("a_change.md"); 49 | std::fs::write( 50 | &change_path, 51 | "---\nmy_package: minor\n---\n\n### This is a summary\n", 52 | ) 53 | .unwrap(); 54 | 55 | let change = Change::from_file(&change_path).unwrap(); 56 | 57 | assert_eq!(change.unique_id.to_string(), "a_change"); 58 | assert_eq!(change.summary, "### This is a summary"); 59 | assert_eq!( 60 | change.versioning, 61 | Versioning::from(("my_package", ChangeType::Minor)) 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /tests/change_set.rs: -------------------------------------------------------------------------------- 1 | use changesets::{ChangeSet, ChangeType, PackageChange, Release, UniqueId}; 2 | use tempfile::tempdir; 3 | 4 | #[test] 5 | fn load_changeset() { 6 | let dir = tempdir().unwrap(); 7 | 8 | let first_change_name = "first_change"; 9 | let first_change_path = dir.path().join(format!("{first_change_name}.md")); 10 | let first_package = "first_package"; 11 | let second_package = "second_package"; 12 | let first_change_type = ChangeType::Minor; 13 | let second_package_type = ChangeType::Patch; 14 | let first_change_summary = "### This is a summary"; 15 | std::fs::write( 16 | first_change_path, 17 | format!( 18 | "---\n{first_package}: {first_change_type}\n{second_package}: {second_package_type}\n---\n\n{first_change_summary}\n", 19 | ), 20 | ) 21 | .unwrap(); 22 | 23 | let second_change_name = "Second Change"; 24 | let second_change_path = dir.path().join(format!("{second_change_name}.md")); 25 | let second_change_type = ChangeType::Major; 26 | let second_change_summary = "### Another summary"; 27 | std::fs::write( 28 | second_change_path, 29 | format!("---\n{second_package}: {second_change_type}\n---\n\n{second_change_summary}\n",), 30 | ) 31 | .unwrap(); 32 | 33 | let changeset = ChangeSet::from_directory(&dir).unwrap(); 34 | let releases: Vec = changeset.into(); 35 | let first_release = releases 36 | .iter() 37 | .find(|release| release.package_name == first_package) 38 | .unwrap(); 39 | assert_eq!(first_release.package_name, first_package); 40 | assert_eq!(first_release.change_type().unwrap(), &first_change_type); 41 | assert_eq!( 42 | first_release.changes, 43 | vec![PackageChange { 44 | unique_id: UniqueId::exact(first_change_name).into(), 45 | change_type: first_change_type, 46 | summary: first_change_summary.into() 47 | },] 48 | ); 49 | let second_release = releases 50 | .iter() 51 | .find(|release| release.package_name == second_package) 52 | .unwrap(); 53 | assert_eq!(second_release.package_name, second_package); 54 | assert_eq!(second_release.change_type().unwrap(), &second_change_type); 55 | // Order of reading files is probably not guaranteed 56 | let first_variant = vec![ 57 | PackageChange { 58 | unique_id: UniqueId::exact(first_change_name).into(), 59 | change_type: second_package_type, 60 | summary: first_change_summary.into(), 61 | }, 62 | PackageChange { 63 | unique_id: UniqueId::exact(second_change_name).into(), 64 | change_type: second_change_type, 65 | summary: second_change_summary.into(), 66 | }, 67 | ]; 68 | let second_variant = first_variant.iter().cloned().rev().collect::>(); 69 | assert!( 70 | second_release.changes == first_variant || second_release.changes == second_variant, 71 | "Expected {:?} or {:?}, got {:?}", 72 | first_variant, 73 | second_variant, 74 | second_release.changes 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "bitflags" 7 | version = "2.10.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "1.0.4" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 16 | 17 | [[package]] 18 | name = "changesets" 19 | version = "0.4.0" 20 | dependencies = [ 21 | "tempfile", 22 | ] 23 | 24 | [[package]] 25 | name = "errno" 26 | version = "0.3.14" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 29 | dependencies = [ 30 | "libc", 31 | "windows-sys", 32 | ] 33 | 34 | [[package]] 35 | name = "fastrand" 36 | version = "2.3.0" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 39 | 40 | [[package]] 41 | name = "getrandom" 42 | version = "0.3.4" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 45 | dependencies = [ 46 | "cfg-if", 47 | "libc", 48 | "r-efi", 49 | "wasip2", 50 | ] 51 | 52 | [[package]] 53 | name = "libc" 54 | version = "0.2.178" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" 57 | 58 | [[package]] 59 | name = "linux-raw-sys" 60 | version = "0.11.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" 63 | 64 | [[package]] 65 | name = "once_cell" 66 | version = "1.21.3" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 69 | 70 | [[package]] 71 | name = "r-efi" 72 | version = "5.3.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 75 | 76 | [[package]] 77 | name = "rustix" 78 | version = "1.1.2" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" 81 | dependencies = [ 82 | "bitflags", 83 | "errno", 84 | "libc", 85 | "linux-raw-sys", 86 | "windows-sys", 87 | ] 88 | 89 | [[package]] 90 | name = "tempfile" 91 | version = "3.23.0" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" 94 | dependencies = [ 95 | "fastrand", 96 | "getrandom", 97 | "once_cell", 98 | "rustix", 99 | "windows-sys", 100 | ] 101 | 102 | [[package]] 103 | name = "wasip2" 104 | version = "1.0.1+wasi-0.2.4" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 107 | dependencies = [ 108 | "wit-bindgen", 109 | ] 110 | 111 | [[package]] 112 | name = "windows-link" 113 | version = "0.2.1" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 116 | 117 | [[package]] 118 | name = "windows-sys" 119 | version = "0.61.2" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 122 | dependencies = [ 123 | "windows-link", 124 | ] 125 | 126 | [[package]] 127 | name = "wit-bindgen" 128 | version = "0.46.0" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 131 | -------------------------------------------------------------------------------- /src/changeset.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, sync::Arc}; 2 | 3 | use crate::{ 4 | Change, ChangeType, PackageName, 5 | change::{LoadingError, UniqueId}, 6 | }; 7 | 8 | /// A set of [`Change`]s that combine to form [`Release`]s of one or more packages. 9 | #[derive(Clone, Debug, Eq, PartialEq)] 10 | pub struct ChangeSet { 11 | releases: Vec, 12 | } 13 | 14 | impl ChangeSet { 15 | /// Load from a directory (usually called `.changeset`) containing markdown files. 16 | /// 17 | /// Any files that don't end with `.md` will be ignored. 18 | /// 19 | /// # Errors 20 | /// 21 | /// 1. Directory doesn't exist 22 | /// 2. There's a problem loading a file (see [`Change`] for details) 23 | pub fn from_directory>(path: P) -> Result { 24 | path.as_ref() 25 | .read_dir()? 26 | .filter_map(|entry| { 27 | entry 28 | .map_err(LoadingError::from) 29 | .and_then(|entry| { 30 | let path = entry.path(); 31 | if path.extension().is_some_and(|ext| ext == "md") && path.is_file() { 32 | Change::from_file(path).map(Some) 33 | } else { 34 | Ok(None) 35 | } 36 | }) 37 | .transpose() 38 | }) 39 | .collect() 40 | } 41 | } 42 | 43 | impl FromIterator for ChangeSet { 44 | fn from_iter>(iter: T) -> Self { 45 | let mut releases = iter 46 | .into_iter() 47 | .flat_map(|change| { 48 | let unique_id = Arc::new(change.unique_id); 49 | let summary: Arc = change.summary.into(); 50 | change 51 | .versioning 52 | .into_iter() 53 | .map(move |(package_name, change_type)| { 54 | ( 55 | package_name, 56 | PackageChange { 57 | change_type, 58 | unique_id: unique_id.clone(), 59 | summary: summary.clone(), 60 | }, 61 | ) 62 | }) 63 | }) 64 | .fold( 65 | Vec::::new(), 66 | |mut releases, (package_name, change)| { 67 | if let Some(release) = releases 68 | .iter_mut() 69 | .find(|release| release.package_name == package_name) 70 | { 71 | release.changes.push(change); 72 | } else { 73 | releases.push(Release { 74 | package_name, 75 | changes: vec![change], 76 | }); 77 | } 78 | releases 79 | }, 80 | ); 81 | for release in &mut releases { 82 | release 83 | .changes 84 | .sort_by(|first, second| first.unique_id.cmp(&second.unique_id)); 85 | } 86 | Self { releases } 87 | } 88 | } 89 | 90 | impl IntoIterator for ChangeSet { 91 | type Item = Release; 92 | type IntoIter = std::vec::IntoIter; 93 | 94 | fn into_iter(self) -> Self::IntoIter { 95 | self.releases.into_iter() 96 | } 97 | } 98 | 99 | impl From for Vec { 100 | fn from(value: ChangeSet) -> Vec { 101 | value.releases 102 | } 103 | } 104 | 105 | /// The combination of applicable [`Change`]s in a [`ChangeSet`] for a single package. 106 | #[derive(Clone, Debug, Eq, PartialEq)] 107 | pub struct Release { 108 | pub package_name: PackageName, 109 | pub changes: Vec, 110 | } 111 | 112 | impl Release { 113 | /// The overall [`ChangeType`] for the package's version based on all the [`Release::changes`]. 114 | #[must_use] 115 | pub fn change_type(&self) -> Option<&ChangeType> { 116 | self.changes.iter().map(|change| &change.change_type).max() 117 | } 118 | } 119 | 120 | /// A [`Change`] as it applies to a single package for a [`Release`], 121 | #[derive(Clone, Debug, Eq, PartialEq)] 122 | pub struct PackageChange { 123 | /// The ID of the originating [`Change`]. 124 | pub unique_id: Arc, 125 | /// The type of change, which determines how the version will be bumped (if at all). 126 | pub change_type: ChangeType, 127 | /// The details of the change, as a markdown-formatted string. 128 | pub summary: Arc, 129 | } 130 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact@dylananthony.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | A Rust crate implementing [Changesets]. If you want a CLI which supports this format, check out [Knope]. 4 | 5 | ## What can this do? 6 | 7 | This crate programmatically works with [changesets], and only concerns itself with the [changeset][changesets] formats and conventions. It specifically _does not_ parse Markdown, bodies of changes are considered plain text. 8 | 9 | Examples are not copy/pasted here, because it's hard to test them. So instead, here are links to common tasks: 10 | 11 | - [Create a change](https://github.com/knope-dev/changesets/blob/61a3f4887e23af02542da66428d4364ee6025f00/tests/change.rs#L5) 12 | - [Load a change](https://github.com/knope-dev/changesets/blob/61a3f4887e23af02542da66428d4364ee6025f00/tests/change.rs#LL46C6-L46C6) 13 | - [Load a changeset](https://github.com/knope-dev/changesets/blob/61a3f4887e23af02542da66428d4364ee6025f00/tests/change_set.rs#L5) 14 | 15 | ## What is a changeset? 16 | 17 | Releasing a project requires two things at a minimum: 18 | 19 | 1. Setting a new version, preferably a [Semantic Version][semver]. 20 | 2. Describing the changes in some sort of release notes, like a [changelog](https://keepachangelog.com). 21 | 22 | The manual way to do this is to review all the changes since the last release, write a changelog, and decide on a new version. However, the longer you go between _making the change_ (e.g., merging a pull request) and _releasing the change_, the more likely you are to forget something. This is especially true if you have a lot of changes, or if you have a lot of projects. 23 | 24 | Changesets are a way of tracking changes as they happen, then bundling them up into a release. For each change you create a Markdown file containing which packages the change effects, how it effects them (in [semver terms][semver], and a Markdown summary of that change. For example, you might merge a PR which has these two change files: 25 | 26 | ### `.changeset/new_feature_to_fix_bug.md` 27 | 28 | ```markdown 29 | --- 30 | changesets: minor 31 | knope: patch 32 | --- 33 | 34 | Added a feature to `changesets` to fix a bug in `knope`. 35 | ``` 36 | 37 | ### `.changeset/new_feature_for_knope.md` 38 | 39 | ```markdown 40 | --- 41 | knope: minor 42 | --- 43 | 44 | This is a feature for Knope in the same PR 45 | ``` 46 | 47 | When you release, the `knope` package would contain both summaries in its changelog (and bump the version based on the highest change type), and the `changesets` package would contain only the first summary in its changelog. 48 | 49 | This works very similarly to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), but does not rely on Git. You can use this together _with_ conventional commits using a tool like [Knope]. 50 | 51 | ## Terminology in this project 52 | 53 | ### Change 54 | 55 | A single Markdown file (usually in the `.changeset` directory) describing a change to one or more packages. Note that this matches the original definition of [changesets]. A change contains a summary (in Markdown), a list of packages affected, and the ["change type"](#change-type) for each package. The file must be in a [very strict format](#change-file-format). 56 | 57 | ### Change summary 58 | 59 | The Markdown description of a change. This is the body of the change file. It should be included in the generated changelog. 60 | 61 | ### Change type 62 | 63 | A string describing which type of change this is. If it is one of `patch`, `minor`, or `major`, the version will be bumped accordingly. All other types of changes are equivalent to `patch` for versioning, but may have a different effect in the generation of the changelog. 64 | 65 | ### Package 66 | 67 | A releasable unit of code. Examples include a Rust crate, a JavaScript package, a Go module. A change can affect multiple packages. 68 | 69 | ### Changeset 70 | 71 | A _set_ of _changes_ which will be released together. Notably, this differs from the original definition of [changesets], which is does not have a term for the bundle of multiple changes. A changeset may affect any number of packages. 72 | 73 | ### Release 74 | 75 | The part of a changeset that applies to a single package and determines how that package is released. 76 | 77 | ## Change file format 78 | 79 | Change files are Markdown files whose names _must_ end with `.md`. The content of the file must be as follows: 80 | 81 | 1. A line containing `---` (three dashes) on its own line. 82 | 2. Any number of lines containing `package: change type` pairs where `package` defines a package that this change impacts and `change type` is a [change type](#change-type). One pair per line. The first `:` is used to determine the separation between package and change type, so the package name may not contain a `:`. 83 | 3. A line containing `---` (three dashes) on its own line. 84 | 4. The rest of the file can contain any valid Markdown text. 85 | 86 | ## Differences from the original changesets 87 | 88 | 1. The original is implemented in JavaScript, intended for use with Node.js. This is implemented in Rust, intended primarily for use by [Knope]. 89 | 2. The original has four fixed changed types (`major`, `minor`, `patch`, and `none`). This has only the first three, and allows for custom change types (for more flexibility when building changelogs). There is no way to specify that a change does not impact the version, since releasing a package without increasing the version is typically not supported. 90 | 3. The original defines a single Markdown file as a "changeset" without any term to define the collection of change files (e.g., in the `.changeset` folder). This crate defines a "changeset" as the collection of change files in a directory (e.g., `.changeset` is a changeset). A single change file is called a "change". 91 | 92 | ## Questions? 93 | 94 | If you have any questions, comments, or suggestions, please create a [discussion] (after checking for an existing one). 95 | 96 | [semver]: https://semver.org/ 97 | [changesets]: https://github.com/changesets/changesets 98 | [Knope]: https://github.com/knope-dev/knope 99 | [discussion]: https://github.com/knope-dev/changesets/discussions 100 | -------------------------------------------------------------------------------- /src/versioning.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::Ordering, 3 | collections::HashMap, 4 | convert::Infallible, 5 | error::Error, 6 | fmt::{Display, Formatter}, 7 | }; 8 | 9 | /// Describes how a [`crate::Change`] affects the version of relevant packages. 10 | /// 11 | /// This is guaranteed to never be empty, as a changeset must always apply to at least one package. 12 | #[derive(Clone, Debug, Eq, PartialEq)] 13 | pub struct Versioning(HashMap); 14 | 15 | impl From<(&str, ChangeType)> for Versioning { 16 | fn from(value: (&str, ChangeType)) -> Self { 17 | let value = (PackageName::from(value.0), value.1); 18 | Self::from(value) 19 | } 20 | } 21 | 22 | impl From<(PackageName, ChangeType)> for Versioning { 23 | fn from(value: (PackageName, ChangeType)) -> Self { 24 | let mut map = HashMap::new(); 25 | map.insert(value.0, value.1); 26 | Self(map) 27 | } 28 | } 29 | 30 | impl Versioning { 31 | /// Creates a new [`Versioning`] from an iterator of tuples. 32 | /// 33 | /// # Errors 34 | /// 35 | /// 1. If the iterator is empty, you'll get [`BuildVersioningError::EmptyVersioningError`]. 36 | pub fn try_from_iter( 37 | iter: Iter, 38 | ) -> Result 39 | where 40 | Key: Into, 41 | Value: TryInto, 42 | ParseError: Into, 43 | Iter: IntoIterator, 44 | { 45 | let map = iter 46 | .into_iter() 47 | .map(|(key, value)| { 48 | value 49 | .try_into() 50 | .map_err(Into::into) 51 | .map(|value| (key.into(), value)) 52 | }) 53 | .collect::, BuildVersioningError>>()?; 54 | if map.is_empty() { 55 | Err(BuildVersioningError::EmptyVersioningError) 56 | } else { 57 | Ok(Self(map)) 58 | } 59 | } 60 | 61 | pub fn iter(&self) -> impl Iterator { 62 | self.0.iter() 63 | } 64 | 65 | #[must_use] 66 | pub fn len(&self) -> usize { 67 | self.0.len() 68 | } 69 | 70 | #[must_use] 71 | pub fn is_empty(&self) -> bool { 72 | self.0.is_empty() 73 | } 74 | } 75 | 76 | impl IntoIterator for Versioning { 77 | type Item = (PackageName, ChangeType); 78 | type IntoIter = std::collections::hash_map::IntoIter; 79 | 80 | fn into_iter(self) -> Self::IntoIter { 81 | self.0.into_iter() 82 | } 83 | } 84 | 85 | impl FromIterator<(PackageName, ChangeType)> for Versioning { 86 | fn from_iter>(iter: T) -> Self { 87 | Self(iter.into_iter().collect()) 88 | } 89 | } 90 | 91 | /// The error that occurs if you try to create a [`Versioning`] out of an iterator which has no items. 92 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 93 | pub enum BuildVersioningError { 94 | /// The iterator was empty. 95 | EmptyVersioningError, 96 | } 97 | 98 | impl Display for BuildVersioningError { 99 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 100 | match self { 101 | Self::EmptyVersioningError => { 102 | f.write_str("Versioning needs to contain at least one item.") 103 | } 104 | } 105 | } 106 | } 107 | 108 | impl From for BuildVersioningError { 109 | fn from(_: Infallible) -> Self { 110 | unreachable!() 111 | } 112 | } 113 | 114 | impl Error for BuildVersioningError {} 115 | 116 | /// An alias to [`String`] to encode semantic meaning in [`Change::versioning`] 117 | pub type PackageName = String; 118 | 119 | /// The [Semantic Versioning](https://semver.org/) component which should be incremented when a [`Change`] 120 | /// is applied. 121 | #[derive(Clone, Debug, Eq, PartialEq)] 122 | pub enum ChangeType { 123 | Patch, 124 | Minor, 125 | Major, 126 | Custom(String), 127 | } 128 | 129 | impl Display for ChangeType { 130 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 131 | match self { 132 | ChangeType::Custom(label) => write!(f, "{label}"), 133 | ChangeType::Patch => write!(f, "patch"), 134 | ChangeType::Minor => write!(f, "minor"), 135 | ChangeType::Major => write!(f, "major"), 136 | } 137 | } 138 | } 139 | 140 | impl From<&str> for ChangeType { 141 | fn from(s: &str) -> Self { 142 | match s { 143 | "patch" => ChangeType::Patch, 144 | "minor" => ChangeType::Minor, 145 | "major" => ChangeType::Major, 146 | other => ChangeType::Custom(other.to_string()), 147 | } 148 | } 149 | } 150 | 151 | impl From for ChangeType { 152 | fn from(s: String) -> Self { 153 | match s.as_str() { 154 | "patch" => ChangeType::Patch, 155 | "minor" => ChangeType::Minor, 156 | "major" => ChangeType::Major, 157 | _ => ChangeType::Custom(s), 158 | } 159 | } 160 | } 161 | 162 | impl Ord for ChangeType { 163 | fn cmp(&self, other: &Self) -> Ordering { 164 | match (self, other) { 165 | (ChangeType::Custom(_), ChangeType::Custom(_)) 166 | | (ChangeType::Major, ChangeType::Major) 167 | | (ChangeType::Patch, ChangeType::Patch) 168 | | (ChangeType::Minor, ChangeType::Minor) => Ordering::Equal, 169 | (ChangeType::Custom(_), _) => Ordering::Less, 170 | (_, ChangeType::Custom(_)) => Ordering::Greater, 171 | (ChangeType::Patch, _) => Ordering::Less, 172 | (_, ChangeType::Patch) => Ordering::Greater, 173 | (ChangeType::Minor, _) => Ordering::Less, 174 | (_, ChangeType::Minor) => Ordering::Greater, 175 | } 176 | } 177 | } 178 | 179 | impl PartialOrd for ChangeType { 180 | fn partial_cmp(&self, other: &Self) -> Option { 181 | Some(self.cmp(other)) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/change.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | fmt::Display, 4 | path::{Path, PathBuf}, 5 | }; 6 | 7 | use crate::{BuildVersioningError, ChangeType, PackageName, Versioning}; 8 | 9 | /// Represents a single [change](https://github.com/knope-dev/changesets#terminology) which is 10 | /// applicable to any number of packages. 11 | #[derive(Clone, Debug, Eq, PartialEq)] 12 | pub struct Change { 13 | /// Something to uniquely identify a change. 14 | /// 15 | /// This is the name of the file (without the `.md` extension) which defines this changeset. 16 | pub unique_id: UniqueId, 17 | /// Describes how a changeset affects the relevant packages. 18 | pub versioning: Versioning, 19 | /// The details of the change which will be written to a Changelog file 20 | pub summary: String, 21 | } 22 | 23 | impl Change { 24 | /// Create a markdown file in the provided directory with the contents of this [`Change`]. 25 | /// 26 | /// The name of the created file will be the [`Change::unique_id`] with the `.md` extension— 27 | /// that path is returned. 28 | /// 29 | /// # Errors 30 | /// 31 | /// If the file cannot be written, an [`std::io::Error`] is returned. This may happen if the 32 | /// directory does not exist. 33 | pub fn write_to_directory>(&self, path: T) -> std::io::Result { 34 | let output_path = path.as_ref().join(self.unique_id.to_file_name()); 35 | std::fs::write(&output_path, self.to_string())?; 36 | Ok(output_path) 37 | } 38 | 39 | /// Load a [`Change`] from a Markdown file. 40 | /// 41 | /// # Errors 42 | /// 43 | /// - If the file can't be read 44 | /// - If the file doesn't have a valid name (it doesn't end in `.md`) 45 | /// - If the file doesn't have a valid front matter 46 | /// - If the file doesn't have valid versioning info in the front matter 47 | pub fn from_file>(path: T) -> Result { 48 | let path = path.as_ref(); 49 | let file_name = path 50 | .file_name() 51 | .ok_or(LoadingError::InvalidFileName)? 52 | .to_string_lossy(); 53 | let contents = std::fs::read_to_string(path)?; 54 | Self::from_file_name_and_content(file_name.as_ref(), &contents) 55 | } 56 | 57 | /// Given the name of a file and its content, create a [`Change`]. 58 | /// 59 | /// # Errors 60 | /// 61 | /// - If the file doesn't have a valid name (it doesn't end in `.md`) 62 | /// - If the file doesn't have a valid front matter 63 | /// - If the file doesn't have valid versioning info in the front matter 64 | pub fn from_file_name_and_content( 65 | file_name: &str, 66 | content: &str, 67 | ) -> Result { 68 | let unique_id = file_name 69 | .strip_suffix(".md") 70 | .ok_or(LoadingError::InvalidFileName) 71 | .map(UniqueId::exact)?; 72 | Self::from_str(unique_id, content).map_err(LoadingError::from) 73 | } 74 | 75 | fn from_str(unique_id: UniqueId, content: &str) -> Result { 76 | let mut lines = content.lines(); 77 | let first_line = lines.next().ok_or(ParsingError::MissingFrontMatter)?; 78 | if first_line.trim() != "---" { 79 | return Err(ParsingError::MissingFrontMatter); 80 | } 81 | let versioning_iter = lines 82 | .clone() 83 | .take_while(|line| line.trim() != "---") 84 | .map(|line| { 85 | let parts = line 86 | .split_once(':') 87 | .ok_or(ParsingError::InvalidFrontMatter)?; 88 | let package_name = PackageName::from(parts.0.trim()); 89 | let change_type = ChangeType::from(parts.1.trim()); 90 | Ok((package_name, change_type)) 91 | }) 92 | .collect::, ParsingError>>()?; 93 | let versioning = Versioning::try_from_iter(versioning_iter)?; 94 | let mut lines = lines.skip(versioning.len()); 95 | let end_front_matter = lines.next().ok_or(ParsingError::InvalidFrontMatter)?; 96 | if end_front_matter.trim() != "---" { 97 | return Err(ParsingError::InvalidFrontMatter); 98 | } 99 | let summary = lines 100 | .skip_while(|line| line.trim().is_empty()) 101 | .collect::>() 102 | .join("\n"); 103 | Ok(Self { 104 | unique_id, 105 | versioning, 106 | summary, 107 | }) 108 | } 109 | } 110 | 111 | #[cfg(test)] 112 | mod test_change { 113 | use super::*; 114 | 115 | #[test] 116 | fn it_can_contain_spaces_in_package_names() { 117 | let change = Change::from_str( 118 | UniqueId::normalize("a change"), 119 | r"--- 120 | package name: patch 121 | package name 2: minor 122 | --- 123 | This is a summary 124 | ", 125 | ) 126 | .unwrap(); 127 | assert_eq!( 128 | change.versioning, 129 | Versioning::from_iter(vec![ 130 | (PackageName::from("package name"), ChangeType::Patch), 131 | (PackageName::from("package name 2"), ChangeType::Minor), 132 | ]) 133 | ); 134 | } 135 | 136 | #[test] 137 | fn it_can_contain_spaces_in_change_types() { 138 | let change = Change::from_str( 139 | UniqueId::normalize("a change"), 140 | r"--- 141 | package: custom change type 142 | package name 2: something custom 143 | --- 144 | This is a summary 145 | ", 146 | ) 147 | .unwrap(); 148 | assert_eq!( 149 | change.versioning, 150 | Versioning::from_iter(vec![ 151 | ( 152 | PackageName::from("package"), 153 | ChangeType::Custom("custom change type".into()) 154 | ), 155 | ( 156 | PackageName::from("package name 2"), 157 | ChangeType::Custom("something custom".into()) 158 | ), 159 | ]) 160 | ); 161 | } 162 | 163 | #[test] 164 | fn it_can_have_an_empty_summary() { 165 | let change = Change::from_str( 166 | UniqueId::normalize("a change"), 167 | r"--- 168 | package: patch 169 | ---", 170 | ) 171 | .unwrap(); 172 | assert_eq!(change.summary, ""); 173 | } 174 | } 175 | 176 | impl Display for Change { 177 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 178 | writeln!(f, "---")?; 179 | for (package_name, change_type) in self.versioning.iter() { 180 | writeln!(f, "{package_name}: {change_type}")?; 181 | } 182 | writeln!(f, "---")?; 183 | writeln!(f)?; 184 | writeln!(f, "{}", self.summary) 185 | } 186 | } 187 | 188 | /// The unique ID of a [`Change`], used to set the file name of the Markdown file. 189 | #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] 190 | pub struct UniqueId(String); 191 | 192 | impl UniqueId { 193 | #[must_use] 194 | pub fn to_file_name(&self) -> String { 195 | format!("{self}.md") 196 | } 197 | 198 | #[must_use] 199 | /// Creates a new [`UniqueId`] from a string without altering the value at all. For working on 200 | /// with existing paths. 201 | /// Use [`Self::normalize`] when creating new files. 202 | pub fn exact>(value: T) -> Self { 203 | Self(value.as_ref().to_string()) 204 | } 205 | 206 | #[must_use] 207 | /// Converts an arbitrary string into only lower case letters and underscores, for creating 208 | /// file names from arbitrary strings. 209 | pub fn normalize>(value: T) -> Self { 210 | let mut previous_was_underscore = false; 211 | Self( 212 | value 213 | .as_ref() 214 | .chars() 215 | .filter_map(|c| match (c, previous_was_underscore) { 216 | (c, _) if c.is_ascii_alphanumeric() => { 217 | previous_was_underscore = false; 218 | Some(c.to_ascii_lowercase()) 219 | } 220 | (' ' | '_' | '-', false) => { 221 | previous_was_underscore = true; 222 | Some('_') 223 | } 224 | _ => None, 225 | }) 226 | .collect(), 227 | ) 228 | } 229 | } 230 | 231 | impl Display for UniqueId { 232 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 233 | write!(f, "{}", self.0) 234 | } 235 | } 236 | 237 | #[cfg(test)] 238 | mod test_unique_id_normalize { 239 | use super::UniqueId; 240 | 241 | #[test] 242 | fn it_handles_special_characters() { 243 | assert_eq!( 244 | UniqueId::normalize("`[i carry your_heart with-me(i carry it in]`").to_string(), 245 | "i_carry_your_heart_with_mei_carry_it_in" 246 | ); 247 | } 248 | 249 | #[test] 250 | fn it_handles_capitalization() { 251 | assert_eq!( 252 | UniqueId::normalize("This is a Title").to_string(), 253 | "this_is_a_title" 254 | ); 255 | } 256 | 257 | #[test] 258 | fn it_doesnt_duplicate_underscores() { 259 | assert_eq!( 260 | UniqueId::normalize("Something ______ else").to_string(), 261 | "something_else" 262 | ); 263 | } 264 | } 265 | 266 | #[derive(Debug)] 267 | pub enum ParsingError { 268 | MissingFrontMatter, 269 | InvalidFrontMatter, 270 | InvalidVersioning(BuildVersioningError), 271 | } 272 | 273 | impl From for ParsingError { 274 | fn from(err: BuildVersioningError) -> Self { 275 | ParsingError::InvalidVersioning(err) 276 | } 277 | } 278 | 279 | impl Display for ParsingError { 280 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 281 | match self { 282 | ParsingError::MissingFrontMatter => write!(f, "missing front matter"), 283 | ParsingError::InvalidFrontMatter => write!(f, "invalid front matter"), 284 | ParsingError::InvalidVersioning(err) => { 285 | write!(f, "invalid front matter: {err}") 286 | } 287 | } 288 | } 289 | } 290 | 291 | impl Error for ParsingError {} 292 | 293 | #[derive(Debug)] 294 | pub enum LoadingError { 295 | InvalidFileName, 296 | Io(std::io::Error), 297 | Parsing(ParsingError), 298 | } 299 | 300 | impl From for LoadingError { 301 | fn from(err: std::io::Error) -> Self { 302 | LoadingError::Io(err) 303 | } 304 | } 305 | 306 | impl From for LoadingError { 307 | fn from(err: ParsingError) -> Self { 308 | LoadingError::Parsing(err) 309 | } 310 | } 311 | 312 | impl Display for LoadingError { 313 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 314 | match self { 315 | LoadingError::InvalidFileName => write!(f, "invalid file name"), 316 | LoadingError::Io(err) => Display::fmt(err, f), 317 | LoadingError::Parsing(err) => Display::fmt(err, f), 318 | } 319 | } 320 | } 321 | 322 | impl Error for LoadingError {} 323 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Dylan Anthony 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------