diff --git a/.changelog/config.toml b/.changelog/config.toml new file mode 100644 index 0000000..adab527 --- /dev/null +++ b/.changelog/config.toml @@ -0,0 +1,3 @@ +# The configuration file for unclog's changelog + +project_url = "https://github.com/informalsystems/unclog" diff --git a/.changelog/unreleased/breaking-changes/13-entry-autogen.md b/.changelog/unreleased/breaking-changes/13-entry-autogen.md new file mode 100644 index 0000000..e0f05c1 --- /dev/null +++ b/.changelog/unreleased/breaking-changes/13-entry-autogen.md @@ -0,0 +1,3 @@ +- Unreleased entries can now automatically be added to changelogs from the CLI. + This necessarily introduces configuration to be able to specify the project's + GitHub URL ([#13](https://github.com/informalsystems/unclog/issues/13)) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7a97e29..e4ca7e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,51 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" [[package]] name = "bitflags" -version = "1.2.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", +] + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "cc" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26a6ce4b6a484fa3edb70f7efa6fc430fd2b87285fe8b84304fd0936faa0dc0" +dependencies = [ + "jobserver", +] [[package]] name = "cfg-if" @@ -72,11 +114,20 @@ dependencies = [ "atty", "bitflags", "strsim", - "textwrap", + "textwrap 0.11.0", "unicode-width", "vec_map", ] +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array", +] + [[package]] name = "env_logger" version = "0.8.4" @@ -90,6 +141,31 @@ dependencies = [ "termcolor", ] +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "getrandom" version = "0.2.3" @@ -101,6 +177,35 @@ dependencies = [ "wasi", ] +[[package]] +name = "git2" +version = "0.13.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c1cbbfc9a1996c6af82c2b4caf828d2c653af4fcdbb0e5674cc966eee5a4197" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "handlebars" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b09e2322d20d14bc2572401ce7c1d60b4748580a76c230ed9c1f8938f0c833" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error", + "serde", + "serde_json", +] + [[package]] name = "heck" version = "0.3.3" @@ -125,11 +230,31 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "itoa" -version = "0.4.7" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "jobserver" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +dependencies = [ + "libc", +] [[package]] name = "lazy_static" @@ -139,9 +264,49 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.98" +version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" +checksum = "3cb00336871be5ed2c8ed44b60ae9959dc5b9f08539422ed43f09e34ecaeba21" + +[[package]] +name = "libgit2-sys" +version = "0.12.23+1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29730a445bae719db3107078b46808cc45a5b7a6bae3f31272923af969453356" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0186af0d8f171ae6b9c4c90ec51898bad5d08a2d5e470903a50d9ad8959cbee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] [[package]] name = "log" @@ -152,11 +317,23 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + [[package]] name = "memchr" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "num-integer" @@ -177,6 +354,86 @@ dependencies = [ "autocfg", ] +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + +[[package]] +name = "openssl-probe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" + +[[package]] +name = "openssl-sys" +version = "0.9.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1996d2d305e561b70d1ee0c53f1542833f4e1ac6ce9a6708b6ff2738ca67dc82" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1", +] + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + [[package]] name = "ppv-lite86" version = "0.2.10" @@ -209,13 +466,19 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.28" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" +checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d" dependencies = [ "unicode-xid", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.9" @@ -267,9 +530,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab49abadf3f9e1c4bc499e8845e152ad87d2ad2d30371841171169e9d75feee" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ "bitflags", ] @@ -308,24 +571,24 @@ checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" [[package]] name = "semver" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f3aac57ee7f3272d8395c6e4f502f434f0e289fcd62876f70daa008c20dcabe" +checksum = "568a8e6258aa33c13358f81fd834adb854c6f7c9468520910a9b1e8fac068012" [[package]] name = "serde" -version = "1.0.126" +version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.126" +version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" dependencies = [ "proc-macro2", "quote", @@ -334,15 +597,27 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.64" +version = "1.0.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +checksum = "a7f9e390c27c3c0ce8bc5d725f6e4d30a29d26659494aa4b17535f7522c5c950" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer", + "digest", + "fake-simd", + "opaque-debug", +] + [[package]] name = "simplelog" version = "0.10.0" @@ -354,6 +629,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + [[package]] name = "strsim" version = "0.8.0" @@ -362,9 +643,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] name = "structopt" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b041cdcb67226aca307e6e7be44c8806423d83e018bd662360a93dabce4d71" +checksum = "bf9d950ef167e25e0bdb073cf1d68e9ad2795ac826f2f3f59647817cf23c0bfa" dependencies = [ "clap", "lazy_static", @@ -373,9 +654,9 @@ dependencies = [ [[package]] name = "structopt-derive" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7813934aecf5f51a54775e00068c237de98489463968231a51746bbbc03f9c10" +checksum = "134d838a2c9943ac3125cf6df165eda53493451b719f3255b2a26b85f772d0ba" dependencies = [ "heck", "proc-macro-error", @@ -386,9 +667,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.74" +version = "1.0.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" +checksum = "c6f107db402c2c2055242dbf4d2af0e69197202e9faacbef9571bbe47f5a1b84" dependencies = [ "proc-macro2", "quote", @@ -427,20 +708,31 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "textwrap" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" -version = "1.0.26" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2" +checksum = "602eca064b2d83369e2b2f34b09c70b605402801927c65c11071ac911d299b88" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.26" +version = "1.0.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745" +checksum = "bad553cc2c78e8de258400763a647e80e6d1b31ee237275d756f6836d204494c" dependencies = [ "proc-macro2", "quote", @@ -457,11 +749,50 @@ dependencies = [ "winapi", ] +[[package]] +name = "tinyvec" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5241dd6f21443a3606b432718b166d3cedc962fd4b8bea54a8bc7f514ebda986" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "typenum" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" + +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + [[package]] name = "unclog" version = "0.3.0" dependencies = [ "env_logger", + "git2", + "handlebars", + "lazy_static", "log", "semver", "serde", @@ -469,7 +800,34 @@ dependencies = [ "simplelog", "structopt", "tempfile", + "textwrap 0.14.2", "thiserror", + "toml", + "url", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" + +[[package]] +name = "unicode-linebreak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f" +dependencies = [ + "regex", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", ] [[package]] @@ -490,6 +848,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index f5aa8d7..4e7250a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [package] -name = "unclog" -version = "0.3.0" -authors = ["Thane Thomson "] -edition = "2018" -license = "Apache-2.0" -homepage = "https://github.com/informalsystems/unclog" -repository = "https://github.com/informalsystems/unclog" -readme = "README.md" -categories = ["development-tools"] -keywords = ["changelog", "markdown"] +name = "unclog" +version = "0.3.0" +authors = ["Thane Thomson "] +edition = "2018" +license = "Apache-2.0" +homepage = "https://github.com/informalsystems/unclog" +repository = "https://github.com/informalsystems/unclog" +readme = "README.md" +categories = ["development-tools"] +keywords = ["changelog", "markdown"] description = """ unclog allows you to build your changelog from a collection of independent files. This helps prevent annoying and unnecessary merge conflicts when @@ -21,15 +21,20 @@ path = "src/bin/cli.rs" required-features = ["cli"] [features] -default = [ "cli" ] +default = ["cli"] cli = ["simplelog", "structopt", "tempfile"] [dependencies] +git2 = "0.13.22" +handlebars = "4.1.3" log = "0.4.14" semver = "1.0" -serde = { version = "1.0", features = [ "derive" ] } +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +textwrap = "0.14.2" thiserror = "1.0" +toml = "0.5" +url = "2.2" simplelog = { version = "0.10", optional = true } structopt = { version = "0.3.21", optional = true } @@ -37,3 +42,4 @@ tempfile = { version = "3.2.0", optional = true } [dev-dependencies] env_logger = "0.8.3" +lazy_static = "1.4.0" diff --git a/README.md b/README.md index 8816955..3f0c714 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ output when building the files in `tests/full`. ### CLI +```bash +# Detailed information regarding usage. +unclog -h +``` + #### Initializing a changelog ```bash @@ -93,10 +98,50 @@ unclog init # existing CHANGELOG.md into it as an epilogue (to be appended at the end of # the final changelog built by unclog). unclog init -e CHANGELOG.md + +# Automatically generate a `config.toml` file for your changelog, inferring as +# many settings as possible from the environment. (Right now this mainly infers +# your GitHub project URL, if it's a GitHub project) +unclog init -g ``` #### Adding a new unreleased entry +There are two ways of adding a new entry: + +1. Entirely through the CLI +2. By way of your default `$EDITOR` + +To add an entry entirely through the CLI: + +```bash +# First ensure your config.toml file contains the project URL: +echo 'project_url = "https://github.com/org/project"' >> .changelog/config.toml + +# Add a new entry whose associated GitHub issue number is 23. +# Word wrapping will automatically be applied at the boundary specified in your +# `config.toml` file. +unclog add --id some-new-feature \ + --issue 23 \ + --section breaking-changes \ + --message "Some *new* feature" + +# Same as above, but with shortened parameters +unclog add -i some-new-feature \ + -n 23 \ + -s breaking-changes \ + -m "Some *new* feature" + +# If your project uses components/sub-modules +unclog add -i some-new-feature \ + -n 23 \ + -c submodule \ + -s breaking-changes \ + -m "Some *new* feature" +``` + +To add an entry with your favourite `$EDITOR`: + ```bash # First ensure that your $EDITOR environment variable is configured, or you can # manually specify an editor binary path via the --editor flag. @@ -152,6 +197,115 @@ unclog --help unclog release --version v0.2.0 ``` +### Components/Submodules + +If your project has components or submodules to it (see the configuration below +for details on how to specify components), referencing them when creating +changelog entries allows you to group entries for one component together. For +example: + +```bash +unclog add -i some-new-feature \ + -n 23 \ + -c submodule \ + -s breaking-changes \ + -m "Some *new* feature" +``` + +would result in an entry being created in +`.changelog/unreleased/submodule/breaking-changes/23-some-new-feature.md` which, +when rendered, would look like: + +```markdown +- [submodule](./submodule) + - Some *new* feature ([#23](https://github.com/org/project/issues/23)) +``` + +### Configuration + +Certain `unclog` settings can be overridden through the use of a configuration +file in `.changelog/config.toml`. The following TOML shows all of the defaults +for the configuration. If you don't have a `.changelog/config.toml` file, all of +the defaults will be assumed. + +```toml +# The GitHub URL for your project. +# +# This is mainly necessary if you need to automatically generate changelog +# entries directly from the CLI. Right now we only support GitHub, but if +# anyone wants GitLab support please let us know and we'll try implement it +# too. +project_url = "https://github.com/org/project" + +# The file to use as a Handlebars template for changes added directly through +# the CLI. +# +# Assumes that relative paths are relative to the `.changelog` folder. If this +# file does not exist, a default template will be used. +change_template = "change-template.md" + +# The number of characters at which to wrap entries automatically added from +# the CLI. +wrap = 80 + +# The heading right at the beginning of the changelog. +heading = "# CHANGELOG" + +# What style of bullet to use for the instances where unclog has to generate +# bullets for you. Can be "-" or "*". +bullet_style = "-" + +# The message to output when your changelog has no entries yet. +empty_msg = "Nothing to see here! Add some entries to get started." + +# The name of the file (relative to the `.changelog` directory) to use as an +# epilogue for your changelog (will be appended as-is to the end of your +# generated changelog). +epilogue_filename = "epilogue.md" + + +# Settings relating to unreleased changelog entries. +[unreleased] + +# The name of the folder containing unreleased entries, relative to the +# `.changelog` folder. +folder = "unreleased" + +# The heading to use for the unreleased entries section. +heading = "## Unreleased" + + +# Settings relating to sets (groups) of changes in the changelog. For example, +# the "BREAKING CHANGES" section would be considered a change set. +[change_sets] + +# The filename containing a summary of the intended changes. Relative to the +# change set folder (e.g. `.changelog/unreleased/breaking-changes/summary.md`). +summary_filename = "summary.md" + +# The extension of files in a change set. +entry_ext = "md" + + +# Settings related to components/sub-modules. Only relevant if you make use of +# components/sub-modules. +[components] + +# The title to use for the section of entries not relating to a specific +# component. +general_entries_title = "General" + +# The number of spaces to inject before each component-related entry. +entry_indent = 2 + + # The components themselves. Each component has a name (used when rendered + # to Markdown) and a path relative to the project folder (i.e. relative to + # the parent of the `.changelog` folder). + [components.all] + component1 = { name = "Component 1", path = "component1" } + docs = { name = "Documentation", path = "docs" } +``` + ### As a Library By default, the `cli` feature is enabled, which builds the CLI. To use `unclog` diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 65bdb4a..966704e 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -1,39 +1,48 @@ //! `unclog` helps you build your changelog. +use log::error; use simplelog::{ColorChoice, LevelFilter, TermLogger, TerminalMode}; use std::path::{Path, PathBuf}; use structopt::StructOpt; -use unclog::{ - Changelog, Error, ProjectType, Result, RustProject, CHANGE_SET_SUMMARY_FILENAME, - UNRELEASED_FOLDER, -}; +use unclog::{Changelog, Config, Error, PlatformId, Result}; const RELEASE_SUMMARY_TEMPLATE: &str = r#" + will not be created. --> "#; const ADD_CHANGE_TEMPLATE: &str = r#" + not be created. --> "#; +const DEFAULT_CHANGELOG_DIR: &str = ".changelog"; +const DEFAULT_CONFIG_FILENAME: &str = "config.toml"; + #[derive(StructOpt)] struct Opt { /// The path to the changelog folder. - #[structopt(short, long, default_value = ".changelog")] + #[structopt(short, long, default_value = DEFAULT_CHANGELOG_DIR)] path: PathBuf, + /// The path to the changelog configuration file. If a relative path is + /// provided, it is assumed this is relative to the `path` parameter. If no + /// configuration file exists, defaults will be used for all parameters. + #[structopt(short, long, default_value = DEFAULT_CONFIG_FILENAME)] + config_file: PathBuf, + /// Increase output logging verbosity to DEBUG level. #[structopt(short, long)] verbose: bool, + /// Suppress all output logging (overrides `--verbose`). + #[structopt(short, long)] + quiet: bool, + #[structopt(subcommand)] cmd: Command, } @@ -44,7 +53,29 @@ enum Command { Init { /// The path to an epilogue to optionally append to the new changelog. #[structopt(name = "epilogue", short, long)] - epilogue_path: Option, + maybe_epilogue_path: Option, + + /// Automatically generate a `config.toml` file for your changelog, + /// inferring parameters from your environment. This is the same as + /// running `unclog generate-config` after `unclog init`. + #[structopt(short, long)] + gen_config: bool, + + /// If automatically generating configuration, the Git remote from which + /// to infer the project URL. + #[structopt(short, long, default_value = "origin")] + remote: String, + }, + /// Automatically generate a configuration file, attempting to infer as many + /// parameters as possible from your project's environment. + GenerateConfig { + /// The Git remote from which to infer the project URL. + #[structopt(short, long, default_value = "origin")] + remote: String, + + /// Overwrite any existing configuration file. + #[structopt(short, long)] + force: bool, }, /// Add a change to the unreleased set of changes. Add { @@ -52,9 +83,9 @@ enum Command { #[structopt(long, env = "EDITOR")] editor: PathBuf, - /// The component to which this entry should be added - #[structopt(short, long)] - component: Option, + /// The component to which this entry should be added. + #[structopt(name = "component", short, long)] + maybe_component: Option, /// The ID of the section to which the change must be added (e.g. /// "breaking-changes"). @@ -65,17 +96,31 @@ enum Command { /// issue or PR to which the change applies (e.g. "820-change-api"). #[structopt(short, long)] id: String, + + /// The issue number associated with this change, if any. Only relevant + /// if the `--message` flag is also provided. Only one of the + /// `--issue-no` or `--pull-request` flags can be specified at a time. + #[structopt(name = "issue_no", short = "n", long = "issue-no")] + maybe_issue_no: Option, + + /// The number of the pull request associated with this change, if any. + /// Only relevant if the `--message` flag is also provided. Only one of + /// the `--issue-no` or `--pull-request` flags can be specified at a + /// time. + #[structopt(name = "pull_request", short, long = "pull-request")] + maybe_pull_request: Option, + + /// If specified, the change will automatically be generated from the + /// default change template. Requires a project URL to be specified in + /// the changelog configuration file. + #[structopt(name = "message", short, long)] + maybe_message: Option, }, /// Build the changelog from the input path and write the output to stdout. Build { /// Only render unreleased changes. #[structopt(short, long)] unreleased: bool, - - /// The type of project this is. If not supplied, unclog will attempt - /// to autodetect it. - #[structopt(name = "type", short, long)] - project_type: Option, }, /// Release any unreleased features. Release { @@ -92,7 +137,9 @@ enum Command { fn main() { let opt: Opt = Opt::from_args(); TermLogger::init( - if opt.verbose { + if opt.quiet { + LevelFilter::Off + } else if opt.verbose { LevelFilter::Debug } else { LevelFilter::Info @@ -103,58 +150,127 @@ fn main() { ) .unwrap(); + let config_path = if opt.config_file.is_relative() { + opt.path.join(opt.config_file) + } else { + opt.config_file + }; + let config = Config::read_from_file(&config_path).unwrap(); + let result = match opt.cmd { - Command::Build { - unreleased, - project_type, - } => build_changelog(&opt.path, unreleased, project_type), + Command::Init { + maybe_epilogue_path, + gen_config, + remote, + } => init_changelog( + &config, + &opt.path, + &config_path, + maybe_epilogue_path, + gen_config, + &remote, + ), + Command::GenerateConfig { remote, force } => { + Changelog::generate_config(&config_path, opt.path, remote, force) + } + Command::Build { unreleased } => build_changelog(&config, &opt.path, unreleased), Command::Add { editor, - component, + maybe_component, section, id, - } => add_unreleased_entry(&editor, &opt.path, §ion, component, &id), - Command::Init { epilogue_path } => Changelog::init_dir(opt.path, epilogue_path), - Command::Release { editor, version } => prepare_release(&editor, &opt.path, &version), + maybe_issue_no, + maybe_pull_request, + maybe_message, + } => match maybe_message { + Some(message) => match maybe_issue_no { + Some(issue_no) => match maybe_pull_request { + Some(_) => Err(Error::EitherIssueNoOrPullRequest), + None => Changelog::add_unreleased_entry_from_template( + &config, + &opt.path, + §ion, + maybe_component, + &id, + PlatformId::Issue(issue_no), + &message, + ), + }, + None => match maybe_pull_request { + Some(pull_request) => Changelog::add_unreleased_entry_from_template( + &config, + &opt.path, + §ion, + maybe_component, + &id, + PlatformId::PullRequest(pull_request), + &message, + ), + None => Err(Error::MissingIssueNoOrPullRequest), + }, + }, + None => add_unreleased_entry_with_editor( + &config, + &editor, + &opt.path, + §ion, + maybe_component, + &id, + ), + }, + Command::Release { editor, version } => { + prepare_release(&config, &editor, &opt.path, &version) + } }; if let Err(e) = result { - log::error!("Failed: {}", e); + error!("Failed: {}", e); std::process::exit(1); } } -fn build_changelog( +fn init_changelog( + config: &Config, path: &Path, - unreleased: bool, - maybe_project_type: Option, + config_path: &Path, + maybe_epilogue_path: Option, + gen_config: bool, + remote: &str, ) -> Result<()> { - let project_type = match maybe_project_type { - Some(pt) => pt, - None => ProjectType::autodetect(std::fs::canonicalize(path)?.parent().unwrap())?, - }; - log::info!("Project type: {}", project_type); - let project = match project_type { - ProjectType::Rust => RustProject::new(path), - }; - let changelog = project.read_changelog()?; + Changelog::init_dir(config, path, maybe_epilogue_path)?; + if gen_config { + Changelog::generate_config(config_path, path, remote, true) + } else { + Ok(()) + } +} + +fn build_changelog(config: &Config, path: &Path, unreleased: bool) -> Result<()> { + let changelog = Changelog::read_from_dir(config, path)?; log::info!("Success!"); if unreleased { - println!("{}", changelog.render_unreleased()?); + println!("{}", changelog.render_unreleased(config)?); } else { - println!("{}", changelog.render_full()); + println!("{}", changelog.render(config)); } Ok(()) } -fn add_unreleased_entry( +fn add_unreleased_entry_with_editor( + config: &Config, editor: &Path, path: &Path, section: &str, component: Option, id: &str, ) -> Result<()> { - let entry_path = - Changelog::get_entry_path(path, UNRELEASED_FOLDER, section, component.clone(), id); + let entry_path = Changelog::get_entry_path( + config, + path, + &config.unreleased.folder, + section, + component.clone(), + id, + ); if std::fs::metadata(&entry_path).is_ok() { return Err(Error::FileExists(entry_path.display().to_string())); } @@ -175,15 +291,15 @@ fn add_unreleased_entry( return Ok(()); } - Changelog::add_unreleased_entry(path, section, component, id, &tmpfile_content) + Changelog::add_unreleased_entry(config, path, section, component, id, &tmpfile_content) } -fn prepare_release(editor: &Path, path: &Path, version: &str) -> Result<()> { +fn prepare_release(config: &Config, editor: &Path, path: &Path, version: &str) -> Result<()> { // Add the summary to the unreleased folder, since we'll be moving it to // the new release folder let summary_path = path - .join(UNRELEASED_FOLDER) - .join(CHANGE_SET_SUMMARY_FILENAME); + .join(&config.unreleased.folder) + .join(&config.change_sets.summary_filename); // If the summary doesn't exist, try to create it if std::fs::metadata(&summary_path).is_err() { std::fs::write(&summary_path, RELEASE_SUMMARY_TEMPLATE)?; @@ -202,5 +318,5 @@ fn prepare_release(editor: &Path, path: &Path, version: &str) -> Result<()> { return Ok(()); } - Changelog::prepare_release_dir(path, version) + Changelog::prepare_release_dir(config, path, version) } diff --git a/src/cargo.rs b/src/cargo.rs deleted file mode 100644 index d5e877b..0000000 --- a/src/cargo.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Integration with [`cargo`](https://doc.rust-lang.org/cargo/) to facilitate -//! metadata extraction. - -use crate::{Error, Result}; -use serde::Deserialize; -use std::path::PathBuf; -use std::process::Command; - -#[derive(Deserialize)] -struct Metadata { - packages: Vec, -} - -#[derive(Deserialize)] -struct Package { - name: String, - manifest_path: String, -} - -/// Attempt to get the manifest path for the crate with the given name from -/// within the current working directory. -pub fn get_crate_manifest_path(name: &str) -> Result { - let output = Command::new("cargo") - .args(vec!["metadata", "--format-version=1"]) - .output()?; - - let metadata = if output.status.success() { - String::from_utf8(output.stdout)? - } else { - return Err(Error::NonZeroExitCode( - "cargo".to_owned(), - output.status.code().unwrap(), - )); - }; - let metadata: Metadata = serde_json::from_str(&metadata)?; - metadata - .packages - .into_iter() - .find(|package| package.name == name) - .map(|package| PathBuf::from(package.manifest_path)) - .ok_or_else(|| Error::NoSuchCargoPackage(name.to_owned())) -} diff --git a/src/changelog.rs b/src/changelog.rs index 87e7c4d..c8cdf14 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -2,38 +2,34 @@ mod change_set; mod change_set_section; +mod component; mod component_section; +pub mod config; mod entry; -pub mod fs_utils; mod parsing_utils; mod release; pub use change_set::ChangeSet; pub use change_set_section::ChangeSetSection; +pub use component::Component; pub use component_section::ComponentSection; pub use entry::Entry; pub use release::Release; +use serde_json::json; -use crate::changelog::fs_utils::{ - ensure_dir, path_to_str, read_and_filter_dir, read_to_string_opt, rm_gitkeep, -}; use crate::changelog::parsing_utils::{extract_release_version, trim_newlines}; -use crate::{ComponentLoader, Error, Result}; -use log::{debug, info}; +use crate::fs_utils::{ + self, ensure_dir, path_to_str, read_and_filter_dir, read_to_string_opt, rm_gitkeep, +}; +use crate::{Error, GitHubProject, PlatformId, Result}; +use config::Config; +use log::{debug, info, warn}; +use std::convert::TryFrom; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fmt, fs}; -pub const CHANGELOG_HEADING: &str = "# CHANGELOG"; -pub const UNRELEASED_FOLDER: &str = "unreleased"; -pub const UNRELEASED_HEADING: &str = "## Unreleased"; -pub const EPILOGUE_FILENAME: &str = "epilogue.md"; -pub const CHANGE_SET_SUMMARY_FILENAME: &str = "summary.md"; -pub const CHANGE_SET_ENTRY_EXT: &str = "md"; -pub const EMPTY_CHANGELOG_MSG: &str = "Nothing to see here! Add some entries to get started."; -pub const COMPONENT_GENERAL_ENTRIES_TITLE: &str = "General"; -pub const COMPONENT_NAME_PREFIX: &str = "- "; -pub const COMPONENT_ENTRY_INDENT: u8 = 2; -pub const COMPONENT_ENTRY_OVERFLOW_INDENT: u8 = 4; +const DEFAULT_CHANGE_TEMPLATE: &str = + "{{{ bullet }}} {{{ message }}} ([#{{ change_id }}]({{{ change_url }}}))"; /// A log of changes for a specific project. #[derive(Debug, Clone)] @@ -58,33 +54,36 @@ impl Changelog { } /// Renders the full changelog to a string. - pub fn render_full(&self) -> String { - let mut paragraphs = vec![CHANGELOG_HEADING.to_owned()]; + pub fn render(&self, config: &Config) -> String { + let mut paragraphs = vec![config.heading.clone()]; if self.is_empty() { - paragraphs.push(EMPTY_CHANGELOG_MSG.to_owned()); + paragraphs.push(config.empty_msg.clone()); } else { - if let Ok(unreleased_paragraphs) = self.unreleased_paragraphs() { + if let Ok(unreleased_paragraphs) = self.unreleased_paragraphs(config) { paragraphs.extend(unreleased_paragraphs); } self.releases .iter() - .for_each(|r| paragraphs.push(r.to_string())); + .for_each(|r| paragraphs.push(r.render(config))); if let Some(epilogue) = self.epilogue.as_ref() { paragraphs.push(epilogue.clone()); } } - paragraphs.join("\n\n") + format!("{}\n", paragraphs.join("\n\n")) } /// Renders just the unreleased changes to a string. - pub fn render_unreleased(&self) -> Result { - Ok(self.unreleased_paragraphs()?.join("\n\n")) + pub fn render_unreleased(&self, config: &Config) -> Result { + Ok(self.unreleased_paragraphs(config)?.join("\n\n")) } - fn unreleased_paragraphs(&self) -> Result> { + fn unreleased_paragraphs(&self, config: &Config) -> Result> { if let Some(unreleased) = self.maybe_unreleased.as_ref() { if !unreleased.is_empty() { - return Ok(vec![UNRELEASED_HEADING.to_owned(), unreleased.to_string()]); + return Ok(vec![ + config.unreleased.heading.clone(), + unreleased.render(config), + ]); } } Err(Error::NoUnreleasedEntries) @@ -95,36 +94,84 @@ impl Changelog { /// Creates the target folder if it doesn't exist, and optionally copies an /// epilogue into it. pub fn init_dir, E: AsRef>( + config: &Config, path: P, - epilogue_path: Option, + maybe_epilogue_path: Option, ) -> Result<()> { let path = path.as_ref(); // Ensure the desired path exists. ensure_dir(path)?; // Optionally copy an epilogue into the target path. - let epilogue_path = epilogue_path.as_ref(); - if let Some(ep) = epilogue_path { - let new_epilogue_path = path.join(EPILOGUE_FILENAME); - fs::copy(ep, &new_epilogue_path)?; - info!( - "Copied epilogue from {} to {}", - path_to_str(ep), - path_to_str(&new_epilogue_path), - ); + let maybe_epilogue_path = maybe_epilogue_path.as_ref(); + if let Some(ep) = maybe_epilogue_path { + let new_epilogue_path = path.join(&config.epilogue_filename); + if !fs_utils::file_exists(&new_epilogue_path) { + fs::copy(ep, &new_epilogue_path)?; + info!( + "Copied epilogue from {} to {}", + path_to_str(ep), + path_to_str(&new_epilogue_path), + ); + } else { + info!( + "Epilogue file already exists, not copying: {}", + path_to_str(&new_epilogue_path) + ); + } } // We want an empty unreleased directory with a .gitkeep file - Self::init_empty_unreleased_dir(path)?; + Self::init_empty_unreleased_dir(config, path)?; info!("Success!"); Ok(()) } + /// Attempts to generate a configuration file for the changelog in the given + /// path, inferring as many parameters as possible from its environment. + pub fn generate_config(config_path: P, path: Q, remote: S, force: bool) -> Result<()> + where + P: AsRef, + Q: AsRef, + S: AsRef, + { + let config_path = config_path.as_ref(); + if fs_utils::file_exists(config_path) { + if !force { + return Err(Error::ConfigurationFileAlreadyExists(path_to_str( + config_path, + ))); + } else { + warn!( + "Overwriting configuration file: {}", + path_to_str(config_path) + ); + } + } + + let path = fs::canonicalize(path.as_ref())?; + let parent = path + .parent() + .ok_or_else(|| Error::NoParentFolder(path_to_str(&path)))?; + let git_folder = parent.join(".git"); + let maybe_github_project = if fs_utils::dir_exists(git_folder) { + Some(GitHubProject::from_git_repo(parent, remote.as_ref())?) + } else { + warn!("Parent folder of changelog directory is not a Git repository. Cannot infer whether it is a GitHub project."); + None + }; + + let config = Config { + maybe_project_url: maybe_github_project.map(|gp| gp.url()), + ..Config::default() + }; + config.write_to_file(config_path) + } + /// Attempt to read a full changelog from the given directory. - pub fn read_from_dir(path: P, component_loader: &mut C) -> Result + pub fn read_from_dir

(config: &Config, path: P) -> Result where P: AsRef, - C: ComponentLoader, { let path = path.as_ref(); info!( @@ -135,17 +182,17 @@ impl Changelog { return Err(Error::ExpectedDir(fs_utils::path_to_str(path))); } let unreleased = - ChangeSet::read_from_dir_opt(path.join(UNRELEASED_FOLDER), component_loader)?; + ChangeSet::read_from_dir_opt(config, path.join(&config.unreleased.folder))?; debug!("Scanning for releases in {}", path.display()); - let release_dirs = read_and_filter_dir(path, release_dir_filter)?; + let release_dirs = read_and_filter_dir(path, |e| release_dir_filter(config, e))?; let mut releases = release_dirs .into_iter() - .map(|path| Release::read_from_dir(path, component_loader)) + .map(|path| Release::read_from_dir(config, path)) .collect::>>()?; // Sort releases by version in descending order (newest to oldest). releases.sort_by(|a, b| a.version.cmp(&b.version).reverse()); - let epilogue = - read_to_string_opt(path.join(EPILOGUE_FILENAME))?.map(|e| trim_newlines(&e).to_owned()); + let epilogue = read_to_string_opt(path.join(&config.epilogue_filename))? + .map(|e| trim_newlines(&e).to_owned()); Ok(Self { maybe_unreleased: unreleased, releases, @@ -156,9 +203,10 @@ impl Changelog { /// Adds a changelog entry with the given ID to the specified section in /// the `unreleased` folder. pub fn add_unreleased_entry( + config: &Config, path: P, section: S, - component: Option, + maybe_component: Option, id: I, content: O, ) -> Result<()> @@ -170,17 +218,17 @@ impl Changelog { O: AsRef, { let path = path.as_ref(); - let unreleased_path = path.join(UNRELEASED_FOLDER); + let unreleased_path = path.join(&config.unreleased.folder); ensure_dir(&unreleased_path)?; let section = section.as_ref(); let section_path = unreleased_path.join(section); ensure_dir(§ion_path)?; let mut entry_dir = section_path; - if let Some(component) = component { + if let Some(component) = maybe_component { entry_dir = entry_dir.join(component.as_ref()); ensure_dir(&entry_dir)?; } - let entry_path = entry_dir.join(entry_id_to_filename(id)); + let entry_path = entry_dir.join(entry_id_to_filename(config, id)); // We don't want to overwrite any existing entries if fs::metadata(&entry_path).is_ok() { return Err(Error::FileExists(path_to_str(&entry_path))); @@ -190,8 +238,110 @@ impl Changelog { Ok(()) } + /// Attempts to add an unreleased changelog entry from the given parameters, + /// rendering them through the change template specified in the + /// configuration file. + /// + /// The change template is assumed to be in [Handlebars] format. + /// + /// [Handlebars]: https://handlebarsjs.com/ + pub fn add_unreleased_entry_from_template( + config: &Config, + path: &Path, + section: &str, + component: Option, + id: &str, + platform_id: PlatformId, + message: &str, + ) -> Result<()> { + let rendered_change = Self::render_unreleased_entry_from_template( + config, + path, + section, + component.clone(), + id, + platform_id, + message, + )?; + let mut id = id.to_owned(); + if !id.starts_with(&format!("{}-", platform_id.id())) { + id = format!("{}-{}", platform_id.id(), id); + debug!("Automatically prepending platform ID to change ID: {}", id); + } + Self::add_unreleased_entry(config, path, section, component, &id, &rendered_change) + } + + /// Renders an unreleased changelog entry from the given parameters to a + /// string, making use of the change template specified in the configuration + /// file. + /// + /// The change template is assumed to be in [Handlebars] format. + /// + /// [Handlebars]: https://handlebarsjs.com/ + pub fn render_unreleased_entry_from_template( + config: &Config, + path: &Path, + section: &str, + component: Option, + id: &str, + platform_id: PlatformId, + message: &str, + ) -> Result { + let project_url = config + .maybe_project_url + .as_ref() + .ok_or(Error::MissingProjectUrl)?; + // We only support GitHub projects at the moment + let github_project = GitHubProject::try_from(project_url)?; + let mut change_template_file = PathBuf::from(&config.change_template); + if change_template_file.is_relative() { + change_template_file = path.join(change_template_file); + } + info!( + "Loading change template from: {}", + fs_utils::path_to_str(&change_template_file) + ); + let change_template = fs_utils::read_to_string_opt(&change_template_file)? + .unwrap_or_else(|| DEFAULT_CHANGE_TEMPLATE.to_owned()); + debug!("Loaded change template:\n{}", change_template); + let mut hb = handlebars::Handlebars::new(); + hb.register_template_string("change", change_template)?; + + let (platform_id_field, platform_id_val) = match platform_id { + PlatformId::Issue(issue) => ("issue", issue), + PlatformId::PullRequest(pull_request) => ("pull_request", pull_request), + }; + let template_params = json!({ + "project_url": github_project.to_string(), + "section": section, + "component": component, + "id": id, + platform_id_field: platform_id_val, + "message": message, + "change_url": github_project.change_url(platform_id)?.to_string(), + "change_id": platform_id.id(), + "bullet": config.bullet_style.to_string(), + }); + debug!( + "Template parameters: {}", + serde_json::to_string_pretty(&template_params)? + ); + let rendered_change = hb.render("change", &template_params)?; + let wrapped_rendered = textwrap::wrap( + &rendered_change, + textwrap::Options::new(config.wrap as usize) + .subsequent_indent(" ") + .break_words(false) + .word_separator(textwrap::word_separators::AsciiSpace), + ) + .join("\n"); + debug!("Rendered wrapped change:\n{}", wrapped_rendered); + Ok(wrapped_rendered) + } + /// Compute the file system path to the entry with the given parameters. pub fn get_entry_path( + config: &Config, path: P, release: R, section: S, @@ -209,12 +359,16 @@ impl Changelog { if let Some(component) = component { path = path.join(component.as_ref()); } - path.join(entry_id_to_filename(id)) + path.join(entry_id_to_filename(config, id)) } /// Moves the `unreleased` folder from our changelog to a directory whose /// name is the given version. - pub fn prepare_release_dir, S: AsRef>(path: P, version: S) -> Result<()> { + pub fn prepare_release_dir, S: AsRef>( + config: &Config, + path: P, + version: S, + ) -> Result<()> { let path = path.as_ref(); let version = version.as_ref(); @@ -227,7 +381,7 @@ impl Changelog { return Err(Error::DirExists(path_to_str(&version_path))); } - let unreleased_path = path.join(UNRELEASED_FOLDER); + let unreleased_path = path.join(&config.unreleased.folder); // The unreleased folder must exist if fs::metadata(&unreleased_path).is_err() { return Err(Error::ExpectedDir(path_to_str(&unreleased_path))); @@ -242,11 +396,11 @@ impl Changelog { // We no longer need a .gitkeep in the release directory, if there is one rm_gitkeep(&version_path)?; - Self::init_empty_unreleased_dir(path) + Self::init_empty_unreleased_dir(config, path) } - fn init_empty_unreleased_dir(path: &Path) -> Result<()> { - let unreleased_dir = path.join(UNRELEASED_FOLDER); + fn init_empty_unreleased_dir(config: &Config, path: &Path) -> Result<()> { + let unreleased_dir = path.join(&config.unreleased.folder); ensure_dir(&unreleased_dir)?; let unreleased_gitkeep = unreleased_dir.join(".gitkeep"); fs::write(&unreleased_gitkeep, "")?; @@ -255,24 +409,18 @@ impl Changelog { } } -impl fmt::Display for Changelog { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "{}", self.render_full()) - } -} - -fn entry_id_to_filename>(id: S) -> String { - format!("{}.{}", id.as_ref(), CHANGE_SET_ENTRY_EXT) +fn entry_id_to_filename>(config: &Config, id: S) -> String { + format!("{}.{}", id.as_ref(), config.change_sets.entry_ext) } -fn release_dir_filter(e: fs::DirEntry) -> Option> { +fn release_dir_filter(config: &Config, e: fs::DirEntry) -> Option> { let file_name = e.file_name(); let file_name = file_name.to_string_lossy(); let meta = match e.metadata() { Ok(m) => m, Err(e) => return Some(Err(Error::Io(e))), }; - if meta.is_dir() && file_name != UNRELEASED_FOLDER { + if meta.is_dir() && file_name != config.unreleased.folder { Some(Ok(e.path())) } else { None diff --git a/src/changelog/change_set.rs b/src/changelog/change_set.rs index 6f7dbce..09a234e 100644 --- a/src/changelog/change_set.rs +++ b/src/changelog/change_set.rs @@ -1,9 +1,9 @@ use crate::changelog::fs_utils::{read_and_filter_dir, read_to_string_opt}; use crate::changelog::parsing_utils::trim_newlines; -use crate::{ChangeSetSection, ComponentLoader, Error, Result, CHANGE_SET_SUMMARY_FILENAME}; +use crate::{ChangeSetSection, Config, Error, Result}; use log::debug; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fmt, fs}; /// A set of changes, either associated with a release or not. #[derive(Debug, Clone)] @@ -27,19 +27,18 @@ impl ChangeSet { } /// Attempt to read a single change set from the given directory. - pub fn read_from_dir(path: P, component_loader: &mut C) -> Result + pub fn read_from_dir

(config: &Config, path: P) -> Result where P: AsRef, - C: ComponentLoader, { let path = path.as_ref(); debug!("Loading change set from {}", path.display()); - let summary = read_to_string_opt(path.join(CHANGE_SET_SUMMARY_FILENAME))? + let summary = read_to_string_opt(path.join(&config.change_sets.summary_filename))? .map(|s| trim_newlines(&s).to_owned()); let section_dirs = read_and_filter_dir(path, change_set_section_filter)?; let mut sections = section_dirs .into_iter() - .map(|path| ChangeSetSection::read_from_dir(path, component_loader)) + .map(|path| ChangeSetSection::read_from_dir(config, path)) .collect::>>()?; // Sort sections alphabetically sections.sort_by(|a, b| a.title.cmp(&b.title)); @@ -52,22 +51,19 @@ impl ChangeSet { /// Attempt to read a single change set from the given directory, like /// [`ChangeSet::read_from_dir`], but return `Option::None` if the /// directory does not exist. - pub fn read_from_dir_opt(path: P, component_loader: &mut C) -> Result> + pub fn read_from_dir_opt

(config: &Config, path: P) -> Result> where P: AsRef, - C: ComponentLoader, { let path = path.as_ref(); // The path doesn't exist if fs::metadata(path).is_err() { return Ok(None); } - Self::read_from_dir(path, component_loader).map(Some) + Self::read_from_dir(config, path).map(Some) } -} -impl fmt::Display for ChangeSet { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + pub fn render(&self, config: &Config) -> String { let mut paragraphs = Vec::new(); if let Some(summary) = self.maybe_summary.as_ref() { paragraphs.push(summary.clone()); @@ -75,8 +71,8 @@ impl fmt::Display for ChangeSet { self.sections .iter() .filter(|s| !s.is_empty()) - .for_each(|s| paragraphs.push(s.to_string())); - write!(f, "{}", paragraphs.join("\n\n")) + .for_each(|s| paragraphs.push(s.render(config))); + paragraphs.join("\n\n") } } diff --git a/src/changelog/change_set_section.rs b/src/changelog/change_set_section.rs index 2a2be0b..3429f37 100644 --- a/src/changelog/change_set_section.rs +++ b/src/changelog/change_set_section.rs @@ -1,13 +1,9 @@ use crate::changelog::component_section::package_section_filter; use crate::changelog::entry::read_entries_sorted; use crate::changelog::fs_utils::{entry_filter, path_to_str, read_and_filter_dir}; -use crate::{ - ComponentLoader, ComponentSection, Entry, Error, Result, COMPONENT_ENTRY_INDENT, - COMPONENT_ENTRY_OVERFLOW_INDENT, COMPONENT_GENERAL_ENTRIES_TITLE, COMPONENT_NAME_PREFIX, -}; +use crate::{ComponentSection, Config, Entry, Error, Result}; use log::debug; use std::ffi::OsStr; -use std::fmt; use std::path::Path; /// A single section in a set of changes. @@ -30,10 +26,9 @@ impl ChangeSetSection { } /// Attempt to read a single change set section from the given directory. - pub fn read_from_dir(path: P, component_loader: &mut C) -> Result + pub fn read_from_dir

(config: &Config, path: P) -> Result where P: AsRef, - C: ComponentLoader, { let path = path.as_ref(); debug!("Loading section {}", path.display()); @@ -47,11 +42,11 @@ impl ChangeSetSection { let component_section_dirs = read_and_filter_dir(path, package_section_filter)?; let mut component_sections = component_section_dirs .into_iter() - .map(|path| ComponentSection::read_from_dir(path, component_loader)) + .map(|path| ComponentSection::read_from_dir(config, path)) .collect::>>()?; // Component sections must be sorted by name component_sections.sort_by(|a, b| a.name.cmp(&b.name)); - let entry_files = read_and_filter_dir(path, entry_filter)?; + let entry_files = read_and_filter_dir(path, |e| entry_filter(config, e))?; let entries = read_entries_sorted(entry_files)?; Ok(Self { title, @@ -59,10 +54,10 @@ impl ChangeSetSection { component_sections, }) } -} -impl fmt::Display for ChangeSetSection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + /// Render this change set section to a string using the given + /// configuration. + pub fn render(&self, config: &Config) -> String { let mut lines = Vec::new(); // If we have no package sections if self.component_sections.is_empty() { @@ -80,25 +75,26 @@ impl fmt::Display for ChangeSetSection { // For example: // - General lines.push(format!( - "{}{}", - COMPONENT_NAME_PREFIX, COMPONENT_GENERAL_ENTRIES_TITLE + "{} {}", + config.bullet_style.to_string(), + config.components.general_entries_title )); // Now we indent all general entries. lines.extend(indent_entries( &self.entries, - COMPONENT_ENTRY_INDENT, - COMPONENT_ENTRY_OVERFLOW_INDENT, + config.components.entry_indent, + config.components.entry_indent + 2, )); } // Component-specific sections are already indented lines.extend( self.component_sections .iter() - .map(|ps| ps.to_string()) + .map(|ps| ps.render(config)) .collect::>(), ); } - write!(f, "### {}\n\n{}", self.title, lines.join("\n")) + format!("### {}\n\n{}", self.title, lines.join("\n")) } } diff --git a/src/changelog/component.rs b/src/changelog/component.rs new file mode 100644 index 0000000..900a1ca --- /dev/null +++ b/src/changelog/component.rs @@ -0,0 +1,13 @@ +//! Components/sub-modules of a project. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// A single component of a project. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Component { + /// The name of the component. + pub name: String, + /// The path of the component relative to the project path. + pub path: PathBuf, +} diff --git a/src/changelog/component_section.rs b/src/changelog/component_section.rs index 34c1c00..4c87495 100644 --- a/src/changelog/component_section.rs +++ b/src/changelog/component_section.rs @@ -1,14 +1,11 @@ use crate::changelog::change_set_section::indent_entries; use crate::changelog::entry::read_entries_sorted; use crate::changelog::fs_utils::{entry_filter, path_to_str, read_and_filter_dir}; -use crate::{ - ComponentLoader, Entry, Error, Result, COMPONENT_ENTRY_INDENT, COMPONENT_ENTRY_OVERFLOW_INDENT, - COMPONENT_NAME_PREFIX, -}; +use crate::{Config, Entry, Error, Result}; use log::debug; use std::ffi::OsStr; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fmt, fs}; /// A section of entries related to a specific component/submodule/package. #[derive(Debug, Clone)] @@ -30,10 +27,9 @@ impl ComponentSection { } /// Attempt to load this component section from the given directory. - pub fn read_from_dir(path: P, component_loader: &mut C) -> Result + pub fn read_from_dir

(config: &Config, path: P) -> Result where P: AsRef, - C: ComponentLoader, { let path = path.as_ref(); let name = path @@ -43,13 +39,13 @@ impl ComponentSection { .ok_or_else(|| Error::CannotObtainName(path_to_str(path)))? .to_owned(); debug!("Looking up component: {}", name); - let maybe_component = component_loader.get_component(&name)?; - let maybe_component_path = maybe_component.map(|c| c.rel_path).map(path_to_str); + let maybe_component = config.components.all.get(&name); + let maybe_component_path = maybe_component.map(|c| &c.path).map(path_to_str); match &maybe_component_path { Some(component_path) => debug!("Found component \"{}\" in: {}", name, component_path), None => debug!("Could not find component \"{}\"", name), } - let entry_files = read_and_filter_dir(path, entry_filter)?; + let entry_files = read_and_filter_dir(path, |e| entry_filter(config, e))?; let entries = read_entries_sorted(entry_files)?; Ok(Self { name, @@ -57,23 +53,21 @@ impl ComponentSection { entries, }) } -} -impl fmt::Display for ComponentSection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + pub fn render(&self, config: &Config) -> String { let entries_lines = indent_entries( &self.entries, - COMPONENT_ENTRY_INDENT, - COMPONENT_ENTRY_OVERFLOW_INDENT, + config.components.entry_indent, + config.components.entry_indent + 2, ); let name = match &self.maybe_path { // Render as a Markdown hyperlink Some(path) => format!("[{}]({})", self.name, path), None => self.name.clone(), }; - let mut lines = vec![format!("{}{}", COMPONENT_NAME_PREFIX, name)]; + let mut lines = vec![format!("{} {}", config.bullet_style.to_string(), name)]; lines.extend(entries_lines); - write!(f, "{}", lines.join("\n")) + lines.join("\n") } } @@ -91,7 +85,7 @@ pub(crate) fn package_section_filter(e: fs::DirEntry) -> Option> #[cfg(test)] mod test { - use super::ComponentSection; + use super::{ComponentSection, Config}; use crate::Entry; const RENDERED_WITH_PATH: &str = r#"- [some-project](./some-project/) @@ -111,7 +105,7 @@ mod test { maybe_path: Some("./some-project/".to_owned()), entries: test_entries(), }; - assert_eq!(RENDERED_WITH_PATH, ps.to_string()); + assert_eq!(RENDERED_WITH_PATH, ps.render(&Config::default())); } #[test] @@ -121,7 +115,7 @@ mod test { maybe_path: None, entries: test_entries(), }; - assert_eq!(RENDERED_WITHOUT_PATH, ps.to_string()); + assert_eq!(RENDERED_WITHOUT_PATH, ps.render(&Config::default())); } fn test_entries() -> Vec { diff --git a/src/changelog/config.rs b/src/changelog/config.rs new file mode 100644 index 0000000..7e685c0 --- /dev/null +++ b/src/changelog/config.rs @@ -0,0 +1,333 @@ +//! Configuration-related types. + +use super::fs_utils::{path_to_str, read_to_string_opt}; +use crate::{Component, Error, Result}; +use log::{debug, info}; +use serde::{de::Error as _, Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; +use std::path::Path; +use std::str::FromStr; +use url::Url; + +/// Configuration options relating to the generation of a changelog. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Config { + /// The URL of the project. This helps facilitate automatic content + /// generation when supplying an issue or PR number. + #[serde( + default, + rename = "project_url", + with = "crate::s11n::optional_from_str", + skip_serializing_if = "is_default" + )] + pub maybe_project_url: Option, + /// The path to a file containing the change template to use when + /// automatically adding new changelog entries. Relative to the `.changelog` + /// folder. + #[serde( + default = "Config::default_change_template", + skip_serializing_if = "Config::is_default_change_template" + )] + pub change_template: String, + /// Wrap entries automatically to a specific number of characters per line. + #[serde( + default = "Config::default_wrap", + skip_serializing_if = "Config::is_default_wrap" + )] + pub wrap: u16, + /// The heading to use at the beginning of the changelog we generate. + #[serde( + default = "Config::default_heading", + skip_serializing_if = "Config::is_default_heading" + )] + pub heading: String, + /// What style of bullet should we use when generating changelog entries? + #[serde( + default, + with = "crate::s11n::from_str", + skip_serializing_if = "is_default" + )] + pub bullet_style: BulletStyle, + /// The message to use when the changelog is empty. + #[serde( + default = "Config::default_empty_msg", + skip_serializing_if = "Config::is_default_empty_msg" + )] + pub empty_msg: String, + /// The filename (relative to the `.changelog` folder) of the file + /// containing content to be appended to the end of the generated + /// changelog. + #[serde( + default = "Config::default_epilogue_filename", + skip_serializing_if = "Config::is_default_epilogue_filename" + )] + pub epilogue_filename: String, + /// Configuration relating to unreleased changelog entries. + #[serde(default, skip_serializing_if = "is_default")] + pub unreleased: UnreleasedConfig, + /// Configuration relating to sets of changes. + #[serde(default, skip_serializing_if = "is_default")] + pub change_sets: ChangeSetsConfig, + /// Configuration relating to components/submodules. + #[serde(default, skip_serializing_if = "is_default")] + pub components: ComponentsConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + maybe_project_url: None, + change_template: Self::default_change_template(), + wrap: Self::default_wrap(), + heading: Self::default_heading(), + bullet_style: BulletStyle::default(), + empty_msg: Self::default_empty_msg(), + epilogue_filename: Self::default_epilogue_filename(), + unreleased: UnreleasedConfig::default(), + change_sets: ChangeSetsConfig::default(), + components: ComponentsConfig::default(), + } + } +} + +impl Config { + /// Attempt to read the configuration from the given file. + /// + /// If the given file does not exist, this method does not fail: it returns + /// a [`Config`] object with all of the default values set. + /// + /// At present, only [TOML](https://toml.io/) format is supported. + pub fn read_from_file>(path: P) -> Result { + let path = path.as_ref(); + info!( + "Attempting to load configuration file from: {}", + path.display() + ); + let maybe_content = read_to_string_opt(path)?; + match maybe_content { + Some(content) => toml::from_str::(&content) + .map_err(|e| Error::TomlParse(path_to_str(&path), e)), + None => { + info!("No changelog configuration file. Assuming defaults."); + Ok(Self::default()) + } + } + } + + /// Attempt to save the configuration to the given file. + pub fn write_to_file>(&self, path: P) -> Result<()> { + let path = path.as_ref(); + debug!( + "Attempting to save configuration file to: {}", + path.display() + ); + let content = toml::to_string_pretty(&self).map_err(Error::TomlSerialize)?; + std::fs::write(path, content)?; + info!("Saved configuration to: {}", path.display()); + Ok(()) + } + + fn default_change_template() -> String { + "change-template.md".to_owned() + } + + fn is_default_change_template(change_template: &str) -> bool { + change_template == Self::default_change_template() + } + + fn default_wrap() -> u16 { + 80 + } + + fn is_default_wrap(w: &u16) -> bool { + *w == Self::default_wrap() + } + + fn default_heading() -> String { + "# CHANGELOG".to_owned() + } + + fn is_default_heading(heading: &str) -> bool { + heading == Self::default_heading() + } + + fn default_empty_msg() -> String { + "Nothing to see here! Add some entries to get started.".to_owned() + } + + fn is_default_empty_msg(empty_msg: &str) -> bool { + empty_msg == Self::default_empty_msg() + } + + fn default_epilogue_filename() -> String { + "epilogue.md".to_owned() + } + + fn is_default_epilogue_filename(epilogue_filename: &str) -> bool { + epilogue_filename == Self::default_epilogue_filename() + } +} + +/// The various styles of bullets available in Markdown. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum BulletStyle { + /// `*` + Asterisk, + /// `-` + Dash, +} + +impl fmt::Display for BulletStyle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Asterisk => write!(f, "*"), + Self::Dash => write!(f, "-"), + } + } +} + +impl FromStr for BulletStyle { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "*" => Ok(Self::Asterisk), + "-" => Ok(Self::Dash), + _ => Err(Error::InvalidBulletStyle), + } + } +} + +impl Default for BulletStyle { + fn default() -> Self { + Self::Dash + } +} + +impl Serialize for BulletStyle { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for BulletStyle { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse::() + .map_err(|e| D::Error::custom(format!("{}", e))) + } +} + +/// Configuration relating to unreleased changelog entries. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnreleasedConfig { + #[serde(default = "UnreleasedConfig::default_folder")] + pub folder: String, + #[serde(default = "UnreleasedConfig::default_heading")] + pub heading: String, +} + +impl Default for UnreleasedConfig { + fn default() -> Self { + Self { + folder: Self::default_folder(), + heading: Self::default_heading(), + } + } +} + +impl UnreleasedConfig { + fn default_folder() -> String { + "unreleased".to_owned() + } + + fn default_heading() -> String { + "## Unreleased".to_owned() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChangeSetsConfig { + #[serde(default = "ChangeSetsConfig::default_summary_filename")] + pub summary_filename: String, + #[serde(default = "ChangeSetsConfig::default_entry_ext")] + pub entry_ext: String, +} + +impl Default for ChangeSetsConfig { + fn default() -> Self { + Self { + summary_filename: Self::default_summary_filename(), + entry_ext: Self::default_entry_ext(), + } + } +} + +impl ChangeSetsConfig { + fn default_summary_filename() -> String { + "summary.md".to_owned() + } + + fn default_entry_ext() -> String { + "md".to_owned() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ComponentsConfig { + #[serde( + default = "ComponentsConfig::default_general_entries_title", + skip_serializing_if = "ComponentsConfig::is_default_general_entries_title" + )] + pub general_entries_title: String, + #[serde( + default = "ComponentsConfig::default_entry_indent", + skip_serializing_if = "ComponentsConfig::is_default_entry_indent" + )] + pub entry_indent: u8, + /// All of the components themselves. + #[serde(default, skip_serializing_if = "is_default")] + pub all: HashMap, +} + +impl Default for ComponentsConfig { + fn default() -> Self { + Self { + general_entries_title: Self::default_general_entries_title(), + entry_indent: Self::default_entry_indent(), + all: HashMap::default(), + } + } +} + +impl ComponentsConfig { + fn default_general_entries_title() -> String { + "General".to_owned() + } + + fn is_default_general_entries_title(t: &str) -> bool { + t == Self::default_general_entries_title() + } + + fn default_entry_indent() -> u8 { + 2 + } + + fn is_default_entry_indent(i: &u8) -> bool { + *i == Self::default_entry_indent() + } +} + +fn is_default(v: &D) -> bool +where + D: Default + PartialEq, +{ + D::default().eq(v) +} diff --git a/src/changelog/release.rs b/src/changelog/release.rs index fe4ad74..4c847fe 100644 --- a/src/changelog/release.rs +++ b/src/changelog/release.rs @@ -1,8 +1,7 @@ use crate::changelog::fs_utils::path_to_str; use crate::changelog::parsing_utils::extract_release_version; -use crate::{ChangeSet, ComponentLoader, Error, Result, Version}; +use crate::{ChangeSet, Config, Error, Result, Version}; use log::debug; -use std::fmt; use std::path::Path; /// The changes associated with a specific release. @@ -18,10 +17,9 @@ pub struct Release { impl Release { /// Attempt to read a single release from the given directory. - pub fn read_from_dir(path: P, component_loader: &mut C) -> Result + pub fn read_from_dir

(config: &Config, path: P) -> Result where P: AsRef, - C: ComponentLoader, { let path = path.as_ref().to_path_buf(); debug!("Loading release from {}", path.display()); @@ -38,17 +36,17 @@ impl Release { Ok(Self { id, version, - changes: ChangeSet::read_from_dir(path, component_loader)?, + changes: ChangeSet::read_from_dir(config, path)?, }) } -} -impl fmt::Display for Release { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + /// Attempt to render this release to a string using the given + /// configuration. + pub fn render(&self, config: &Config) -> String { let mut paragraphs = vec![format!("## {}", self.id)]; if !self.changes.is_empty() { - paragraphs.push(self.changes.to_string()); + paragraphs.push(self.changes.render(config)); } - write!(f, "{}", paragraphs.join("\n\n")) + paragraphs.join("\n\n") } } diff --git a/src/error.rs b/src/error.rs index 632b179..c9c4b90 100644 --- a/src/error.rs +++ b/src/error.rs @@ -42,4 +42,40 @@ pub enum Error { UnrecognizedProjectType(String), #[error("cannot autodetect project type in path: {0}")] CannotAutodetectProjectType(PathBuf), + #[error("invalid bullet style - can only be \"*\" or \"-\"")] + InvalidBulletStyle, + #[error("failed to parse TOML file \"{0}\": {1}")] + TomlParse(String, toml::de::Error), + #[error("failed to serialize TOML: {0}")] + TomlSerialize(toml::ser::Error), + #[error("failed to parse URL: {0}")] + FailedToParseUrl(#[from] url::ParseError), + #[error("missing issue number (--issue-no) or pull request (--pull-request)")] + MissingIssueNoOrPullRequest, + #[error("please specify either an issue number (--issue-no) or a pull request (--pull-request), but not both")] + EitherIssueNoOrPullRequest, + #[error("the URL is missing its host: {0}")] + UrlMissingHost(String), + #[error("not a GitHub project: {0}")] + NotGitHubProject(String), + #[error("GitHub project is missing its path: {0}")] + GitHubProjectMissingPath(String), + #[error("GitHub project URLs must include both the org/user ID and project ID: {0}")] + InvalidGitHubProjectPath(String), + #[error("configuration is missing a project URL (needed for automatic entry generation)")] + MissingProjectUrl, + #[error("error loading Handlebars template: {0}")] + HandlebarsTemplateLoad(#[from] handlebars::TemplateError), + #[error("error rendering Handlebars template: {0}")] + HandlebarsTemplateRender(#[from] handlebars::RenderError), + #[error("git error: {0}")] + Git(#[from] git2::Error), + #[error("configuration file already exists: {0}")] + ConfigurationFileAlreadyExists(String), + #[error("no parent folder for path: {0}")] + NoParentFolder(String), + #[error("invalid URL in Git repository for remote \"{0}\": {1}")] + InvalidGitRemoteUrl(String, String), + #[error("invalid URL: {0}")] + InvalidUrl(String), } diff --git a/src/changelog/fs_utils.rs b/src/fs_utils.rs similarity index 62% rename from src/changelog/fs_utils.rs rename to src/fs_utils.rs index 328dee8..fe50df6 100644 --- a/src/changelog/fs_utils.rs +++ b/src/fs_utils.rs @@ -1,19 +1,19 @@ //! File system-related utilities to help with manipulating changelogs. -use crate::{Error, Result, CHANGE_SET_ENTRY_EXT}; +use crate::{Config, Error, Result}; use log::{debug, info}; use std::fs; use std::path::{Path, PathBuf}; -pub(crate) fn path_to_str>(path: P) -> String { +pub fn path_to_str>(path: P) -> String { path.as_ref().to_string_lossy().to_string() } -pub(crate) fn read_to_string>(path: P) -> Result { +pub fn read_to_string>(path: P) -> Result { Ok(fs::read_to_string(path)?) } -pub(crate) fn read_to_string_opt>(path: P) -> Result> { +pub fn read_to_string_opt>(path: P) -> Result> { let path = path.as_ref(); if fs::metadata(path).is_err() { return Ok(None); @@ -21,7 +21,7 @@ pub(crate) fn read_to_string_opt>(path: P) -> Result Result<()> { +pub fn ensure_dir(path: &Path) -> Result<()> { if fs::metadata(path).is_err() { fs::create_dir(path)?; info!("Created directory: {}", path_to_str(path)); @@ -32,7 +32,7 @@ pub(crate) fn ensure_dir(path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn rm_gitkeep(path: &Path) -> Result<()> { +pub fn rm_gitkeep(path: &Path) -> Result<()> { let path = path.join(".gitkeep"); if fs::metadata(&path).is_ok() { fs::remove_file(&path)?; @@ -41,7 +41,7 @@ pub(crate) fn rm_gitkeep(path: &Path) -> Result<()> { Ok(()) } -pub(crate) fn read_and_filter_dir(path: &Path, filter: F) -> Result> +pub fn read_and_filter_dir(path: &Path, filter: F) -> Result> where F: Fn(fs::DirEntry) -> Option>, { @@ -53,27 +53,40 @@ where .collect::>>() } -pub(crate) fn entry_filter(e: fs::DirEntry) -> Option> { +pub fn entry_filter(config: &Config, e: fs::DirEntry) -> Option> { let meta = match e.metadata() { Ok(m) => m, Err(e) => return Some(Err(Error::Io(e))), }; let path = e.path(); let ext = path.extension()?.to_str()?; - if meta.is_file() && ext == CHANGE_SET_ENTRY_EXT { + if meta.is_file() && ext == config.change_sets.entry_ext { Some(Ok(path)) } else { None } } -pub(crate) fn get_relative_path, Q: AsRef>( - path: P, - prefix: Q, -) -> Result { +pub fn get_relative_path, Q: AsRef>(path: P, prefix: Q) -> Result { Ok(path.as_ref().strip_prefix(prefix.as_ref())?.to_path_buf()) } +pub fn file_exists>(path: P) -> bool { + let path = path.as_ref(); + if let Ok(meta) = fs::metadata(&path) { + return meta.is_file(); + } + false +} + +pub fn dir_exists>(path: P) -> bool { + let path = path.as_ref(); + if let Ok(meta) = fs::metadata(&path) { + return meta.is_dir(); + } + false +} + #[cfg(test)] mod test { use super::get_relative_path; diff --git a/src/lib.rs b/src/lib.rs index 1567609..c49a8cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,20 +1,19 @@ //! `unclog` helps you build your changelog. -mod cargo; mod changelog; mod error; -mod project; +pub mod fs_utils; +mod s11n; +mod vcs; +pub use changelog::config::{ + BulletStyle, ChangeSetsConfig, ComponentsConfig, Config, UnreleasedConfig, +}; pub use changelog::{ - ChangeSet, ChangeSetSection, Changelog, ComponentSection, Entry, Release, CHANGELOG_HEADING, - CHANGE_SET_ENTRY_EXT, CHANGE_SET_SUMMARY_FILENAME, COMPONENT_ENTRY_INDENT, - COMPONENT_ENTRY_OVERFLOW_INDENT, COMPONENT_GENERAL_ENTRIES_TITLE, COMPONENT_NAME_PREFIX, - EMPTY_CHANGELOG_MSG, EPILOGUE_FILENAME, UNRELEASED_FOLDER, UNRELEASED_HEADING, + ChangeSet, ChangeSetSection, Changelog, Component, ComponentSection, Entry, Release, }; pub use error::Error; -pub use project::{ - Component, ComponentLoader, Project, ProjectType, RustComponentLoader, RustProject, -}; +pub use vcs::{GitHubProject, PlatformId}; /// Result type used throughout the `unclog` crate. pub type Result = std::result::Result; diff --git a/src/project.rs b/src/project.rs deleted file mode 100644 index 04698d6..0000000 --- a/src/project.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! At a high level, a changelog belongs to a project, and so we need to model -//! this accordingly. - -use crate::cargo::get_crate_manifest_path; -use crate::changelog::fs_utils::get_relative_path; -use crate::{Changelog, Error, Result}; -use log::debug; -use std::collections::HashMap; -use std::fmt; -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -#[derive(Debug, Clone)] -pub enum ProjectType { - Rust, -} - -impl ProjectType { - /// Attempts to autodetect the type of project in the given path. - pub fn autodetect>(path: P) -> Result { - let path = path.as_ref(); - debug!( - "Attempting to autodetect project in path: {}", - path.to_string_lossy() - ); - if Self::is_rust_project(path)? { - Ok(Self::Rust) - } else { - Err(Error::CannotAutodetectProjectType(path.to_path_buf())) - } - } - - fn is_rust_project(path: &Path) -> Result { - let maybe_meta = std::fs::metadata(path.join("Cargo.toml")); - if maybe_meta.map(|meta| meta.is_file()).unwrap_or(false) { - Ok(true) - } else { - Ok(false) - } - } -} - -impl FromStr for ProjectType { - type Err = Error; - - fn from_str(s: &str) -> Result { - match s { - "rust" => Ok(Self::Rust), - _ => Err(Error::UnrecognizedProjectType(s.to_owned())), - } - } -} - -impl fmt::Display for ProjectType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::Rust => "Rust", - } - ) - } -} - -/// A Rust project, using `cargo`. -pub type RustProject = Project; - -/// A project, with project-specific component loader. -#[derive(Debug, Clone)] -pub struct Project { - path: PathBuf, - component_loader: C, -} - -impl Project { - /// Create a project using the given path and given custom component - /// loader. - pub fn new_with_component_loader>(path: P, component_loader: C) -> Self { - Self { - path: path.as_ref().to_path_buf(), - component_loader, - } - } - - /// Attempt to load the changelog associated with this project. - /// - /// Consumes the project. - pub fn read_changelog(mut self) -> Result { - Changelog::read_from_dir(&self.path, &mut self.component_loader) - } -} - -impl Project { - /// Create a new Rust-based project. - pub fn new>(path: P) -> Self { - Self::new_with_component_loader(path, RustComponentLoader::default()) - } -} - -/// A project-specific component loader. -/// -/// Usually each programming language will have at least one component loader. -pub trait ComponentLoader { - /// Attempts to load the component with the given name. - /// - /// If the component does not exist, this returns `Ok(None)`. - fn get_component(&mut self, name: &str) -> Result>; -} - -/// A single component of a project. -#[derive(Debug, Clone)] -pub struct Component { - /// The name/ID of the component. - pub name: String, - /// The path of the component relative to the project path. - pub rel_path: PathBuf, -} - -/// A [`ComponentLoader`] specifically for Rust-based projects. -/// -/// Facilitates loading of components from the current working directory. -#[derive(Debug, Clone)] -pub struct RustComponentLoader { - // We cache lookups of components' details because executing `cargo` as a - // subprocess can be pretty expensive. - cache: HashMap>, -} - -impl Default for RustComponentLoader { - fn default() -> Self { - Self { - cache: HashMap::new(), - } - } -} - -impl ComponentLoader for RustComponentLoader { - fn get_component(&mut self, name: &str) -> Result> { - if let Some(maybe_component) = self.cache.get(name) { - debug!("Using cached component lookup for: {}", name); - return Ok(maybe_component.clone()); - } - debug!( - "Component \"{}\" not found in cache. Calling cargo...", - name - ); - let maybe_component = match get_crate_manifest_path(name) { - Ok(abs_path) => { - let cwd = std::env::current_dir()?; - let parent_path = abs_path.parent().unwrap(); - Some(Component { - name: name.to_owned(), - rel_path: get_relative_path(parent_path, cwd)?, - }) - } - Err(Error::NoSuchCargoPackage(_)) => None, - Err(e) => return Err(e), - }; - self.cache.insert(name.to_owned(), maybe_component.clone()); - Ok(maybe_component) - } -} diff --git a/src/s11n.rs b/src/s11n.rs new file mode 100644 index 0000000..cd6034f --- /dev/null +++ b/src/s11n.rs @@ -0,0 +1,4 @@ +//! Serialization-related functionality for unclog. + +pub mod from_str; +pub mod optional_from_str; diff --git a/src/s11n/from_str.rs b/src/s11n/from_str.rs new file mode 100644 index 0000000..753c53f --- /dev/null +++ b/src/s11n/from_str.rs @@ -0,0 +1,25 @@ +//! Adapters for serializing/deserializing types that implement `FromStr` and +//! `std::fmt::Display`. + +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialize `value.to_string()` +pub fn serialize(value: &T, serializer: S) -> Result +where + S: Serializer, + T: std::fmt::Display, +{ + value.to_string().serialize(serializer) +} + +/// Deserialize a string and attempt to parse it into an instance of type `T`. +pub fn deserialize<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: std::str::FromStr, + ::Err: std::fmt::Display, +{ + String::deserialize(deserializer)? + .parse::() + .map_err(|e| D::Error::custom(format!("{}", e))) +} diff --git a/src/s11n/optional_from_str.rs b/src/s11n/optional_from_str.rs new file mode 100644 index 0000000..2752be8 --- /dev/null +++ b/src/s11n/optional_from_str.rs @@ -0,0 +1,31 @@ +//! De/serialize an optional type that must be converted from/to a string. + +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serializer}; +use std::str::FromStr; + +pub fn serialize(value: &Option, serializer: S) -> Result +where + S: Serializer, + T: ToString, +{ + match value { + Some(t) => serializer.serialize_some(&t.to_string()), + None => serializer.serialize_none(), + } +} + +pub fn deserialize<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: FromStr, + T::Err: std::error::Error, +{ + let s = match Option::::deserialize(deserializer)? { + Some(s) => s, + None => return Ok(None), + }; + Ok(Some(s.parse().map_err(|e: ::Err| { + D::Error::custom(e.to_string()) + })?)) +} diff --git a/src/vcs.rs b/src/vcs.rs new file mode 100644 index 0000000..221ecad --- /dev/null +++ b/src/vcs.rs @@ -0,0 +1,163 @@ +//! API for dealing with version control systems (Git) and VCS platforms (e.g. +//! GitHub). + +use crate::{fs_utils::path_to_str, Error, Result}; +use log::debug; +use std::{convert::TryFrom, path::Path, str::FromStr}; +use url::Url; + +/// Provides a way of referencing a change through the VCS platform. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum PlatformId { + /// The change is referenced by way of issue number. + Issue(u32), + /// The change is referenced by way of pull request number. + PullRequest(u32), +} + +impl PlatformId { + /// Return the integer ID associated with this platform-specific ID. + pub fn id(&self) -> u32 { + match self { + Self::Issue(issue) => *issue, + Self::PullRequest(pull_request) => *pull_request, + } + } +} + +/// A project on GitHub. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitHubProject { + /// The organization or user associated with this project. + pub owner: String, + /// The ID of the project. + pub project: String, +} + +impl TryFrom<&Url> for GitHubProject { + type Error = Error; + + fn try_from(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| Error::UrlMissingHost(url.to_string()))?; + + if host != "github.com" { + return Err(Error::NotGitHubProject(url.to_string())); + } + + let path_parts = url + .path_segments() + .ok_or_else(|| Error::GitHubProjectMissingPath(url.to_string()))? + .collect::>(); + + if path_parts.len() < 2 { + return Err(Error::InvalidGitHubProjectPath(url.to_string())); + } + + Ok(Self { + owner: path_parts[0].to_owned(), + project: path_parts[1].trim_end_matches(".git").to_owned(), + }) + } +} + +impl FromStr for GitHubProject { + type Err = Error; + + fn from_str(s: &str) -> Result { + let url = Url::parse(s)?; + Self::try_from(&url) + } +} + +impl std::fmt::Display for GitHubProject { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url_str()) + } +} + +impl GitHubProject { + /// Constructor that attempts to infer a GitHub project from a git + /// repository. + pub fn from_git_repo(path: &Path, remote: &str) -> Result { + debug!("Opening path as Git repository: {}", path_to_str(path)); + let repo = git2::Repository::open(path)?; + let remote_url = repo + .find_remote(remote)? + .url() + .map(String::from) + .ok_or_else(|| Error::InvalidGitRemoteUrl(remote.to_owned(), path_to_str(path)))?; + debug!("Found Git remote \"{}\" URL: {}", remote, remote_url); + let remote_url = parse_url(&remote_url)?; + debug!("Parsed remote URL as: {}", remote_url.to_string()); + Self::try_from(&remote_url) + } + + /// Construct a URL for this project based on the given platform-specific + /// ID. + pub fn change_url(&self, platform_id: PlatformId) -> Result { + Ok(Url::parse(&format!( + "{}/{}", + self.to_string(), + match platform_id { + PlatformId::Issue(no) => format!("issues/{}", no), + PlatformId::PullRequest(no) => format!("pull/{}", no), + } + ))?) + } + + pub fn url_str(&self) -> String { + format!("https://github.com/{}/{}", self.owner, self.project) + } + + pub fn url(&self) -> Url { + let url_str = self.url_str(); + Url::parse(&url_str) + .unwrap_or_else(|e| panic!("failed to parse URL \"{}\": {}", url_str, e.to_string())) + } +} + +fn parse_url(u: &str) -> Result { + // Not an SSH URL + if u.starts_with("http://") || u.starts_with("https://") { + return Ok(Url::parse(u)?); + } + Ok(Url::parse(&format!("ssh://{}", u.replace(':', "/")))?) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn github_project_url_parsing() { + // With or without the trailing slash + const URLS: &[&str] = &[ + "https://github.com/informalsystems/unclog", + "https://github.com/informalsystems/unclog/", + "https://github.com/informalsystems/unclog.git", + "ssh://git@github.com/informalsystems/unclog.git", + ]; + let expected = GitHubProject { + owner: "informalsystems".to_owned(), + project: "unclog".to_owned(), + }; + for url in URLS { + let actual = GitHubProject::from_str(url).unwrap(); + assert_eq!(expected, actual); + } + } + + #[test] + fn github_project_url_construction() { + let project = GitHubProject { + owner: "informalsystems".to_owned(), + project: "unclog".to_owned(), + }; + assert_eq!( + project.to_string(), + "https://github.com/informalsystems/unclog" + ) + } +} diff --git a/tests/full/config.toml b/tests/full/config.toml new file mode 100644 index 0000000..ee01d8d --- /dev/null +++ b/tests/full/config.toml @@ -0,0 +1 @@ +project_url = "https://github.com/org/project" diff --git a/tests/integration.rs b/tests/integration.rs index 71d7584..f6ff4a5 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,27 +1,57 @@ //! Integration tests for `unclog`. -use std::path::PathBuf; -use unclog::{Component, ComponentLoader, Project, Result}; +use lazy_static::lazy_static; +use std::{path::Path, sync::Mutex}; +use unclog::{Changelog, Config, PlatformId}; -struct MockLoader; +lazy_static! { + static ref LOGGING_INITIALIZED: Mutex = Mutex::new(0); +} -impl ComponentLoader for MockLoader { - fn get_component(&mut self, name: &str) -> Result> { - match name { - "component2" => Ok(Some(Component { - name: "component2".to_owned(), - rel_path: PathBuf::from("2nd-component"), - })), - _ => Ok(None), - } +fn init_logger() { + let mut initialized = LOGGING_INITIALIZED.lock().unwrap(); + if *initialized == 0 { + env_logger::init(); + *initialized = 1; + log::debug!("env logger initialized"); + } else { + log::debug!("env logger already initialized"); } } #[test] fn full() { - env_logger::init(); - let project = Project::new_with_component_loader("./tests/full", MockLoader); - let changelog = project.read_changelog().unwrap(); + const CONFIG_FILE: &str = r#" +[components.all] +component2 = { name = "component2", path = "2nd-component" } +"#; + + init_logger(); + let config = toml::from_str(CONFIG_FILE).unwrap(); + let changelog = Changelog::read_from_dir(&config, "./tests/full").unwrap(); let expected = std::fs::read_to_string("./tests/full/expected.md").unwrap(); - assert_eq!(expected, changelog.to_string()); + assert_eq!(expected, changelog.render(&config)); +} + +#[test] +fn change_template_rendering() { + init_logger(); + let config = Config::read_from_file("./tests/full/config.toml").unwrap(); + let cases = vec![ + (PlatformId::Issue(123), "- This introduces a new *breaking* change\n ([#123](https://github.com/org/project/issues/123))"), + (PlatformId::PullRequest(23), "- This introduces a new *breaking* change\n ([#23](https://github.com/org/project/pull/23))"), + ]; + for (platform_id, expected) in cases { + let actual = Changelog::render_unreleased_entry_from_template( + &config, + Path::new("./tests/full"), + "breaking-changes", + None, + "some-new-breaking-change", + platform_id, + "This introduces a new *breaking* change", + ) + .unwrap(); + assert_eq!(actual, expected); + } }