Compare commits

..

No commits in common. 'df234e24074745a7ca420f4b3f8c082623fa9c94' and '12933d363b2c6271adb03ec291371baff50ddb52' have entirely different histories.

  1. 4
      .gitignore
  2. 5
      CHANGELOG.md
  3. 102
      Cargo.lock
  4. 10
      Cargo.toml
  5. 7
      README.md
  6. 8
      changelog/channels/default.json
  7. 0
      changelog/entries/.gitkeep
  8. 1
      changelog/entries/stable release.md
  9. 3
      changelog/manifest.json
  10. 78
      clpack.toml
  11. 2
      src/action_init.rs
  12. 78
      src/assets/config_file_template.toml
  13. 42
      src/config.rs
  14. 5
      src/git.rs
  15. 4
      src/main.rs
  16. 103
      src/store.rs
  17. 37
      src/utils/empty_to_none.rs
  18. 1
      src/utils/mod.rs

4
.gitignore vendored

@ -1,2 +1,6 @@
/target /target
.idea/ .idea/
# Remove when the lib is stable enough and useful to keep our own changelog...
changelog
clpack.toml

@ -1,5 +0,0 @@
# Changelog
## [1.0.0] - 2025-09-15
- First stable release, file format version 1

102
Cargo.lock generated

@ -100,6 +100,12 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.37" version = "1.2.37"
@ -158,19 +164,19 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]] [[package]]
name = "clpack" name = "clpack"
version = "1.0.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"clap", "clap",
"colored", "colored",
"faccess", "faccess",
"indexmap",
"inquire", "inquire",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"smart-default", "smart-default",
"thiserror",
"toml", "toml",
] ]
@ -197,15 +203,15 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 1.3.2",
"crossterm_winapi", "crossterm_winapi",
"libc",
"mio", "mio",
"parking_lot", "parking_lot",
"rustix 0.38.44",
"signal-hook", "signal-hook",
"signal-hook-mio", "signal-hook-mio",
"winapi", "winapi",
@ -274,6 +280,15 @@ dependencies = [
"thread_local", "thread_local",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.3" version = "0.3.3"
@ -324,19 +339,21 @@ checksum = "206a8042aec68fa4a62e8d3f7aa4ceb508177d9324faf261e1959e495b7a1921"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown",
"serde",
] ]
[[package]] [[package]]
name = "inquire" name = "inquire"
version = "0.8.0" version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"crossterm", "crossterm",
"dyn-clone", "dyn-clone",
"fuzzy-matcher", "fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"tempfile", "tempfile",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
@ -370,12 +387,6 @@ version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.9.4" version = "0.9.4"
@ -406,14 +417,23 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.4" version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi 0.11.1+wasi-snapshot-preview1", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0", "windows-sys 0.48.0",
]
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
] ]
[[package]] [[package]]
@ -522,19 +542,6 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.8" version = "1.0.8"
@ -544,7 +551,7 @@ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@ -686,10 +693,30 @@ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom",
"once_cell", "once_cell",
"rustix 1.0.8", "rustix",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "thiserror"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.9" version = "1.1.9"
@ -941,15 +968,6 @@ dependencies = [
"windows-targets 0.48.5", "windows-targets 0.48.5",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

@ -1,12 +1,13 @@
[package] [package]
name = "clpack" name = "clpack"
version = "1.0.0" version = "0.1.0"
edition = "2024" edition = "2024"
authors = ["Ondřej Hruška <ondra@ondrovo.com>"] authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
description = "Manage changelog across multiple release channels" description = "Manage changelog across multiple release channels"
[dependencies] [dependencies]
clap = { version = "4.5", features = ["string"] } clap = { version = "4.5", features = ["string"] }
thiserror = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.9" toml = "0.9"
@ -16,5 +17,8 @@ anyhow = "1"
colored = "3" colored = "3"
faccess = "0.2" faccess = "0.2"
chrono = "0.4" chrono = "0.4"
indexmap = { version = "2.11", features = ["serde"] }
inquire = { version = "0.8.0", features = ["editor"] } # input
# sadly this looks mostly abandoned. Alternative is "dialoguer"
inquire = { version = "0.7.5", features = ["editor"] }

@ -10,11 +10,10 @@ This tool aims to make change logging as streamlined as possible to encourage de
automatically for every bugfix and feature. This can be enforced as part of a CI pipeline. automatically for every bugfix and feature. This can be enforced as part of a CI pipeline.
The entry format is kept simple and readable, so entries can be added manually as well - e.g. if some The entry format is kept simple and readable, so entries can be added manually as well - e.g. if some
contributors can't or don't want to use this tool. The generated CHANGELOG.md file can be freely edited after clpack contributors can't or don't want to use this tool.
updates it - just keep the header unchanged.
_clpack is meant to be used with Git, where it can pick up information from branch names. Git is, however, optional. _The tool is meant to be used with Git, but it is not required - it's just more convenient,
You can use this tool with other VCS or none at all._ as it can pre-fill some information from branch names._
## Advantages over keeping the changelog manually ## Advantages over keeping the changelog manually

@ -1,8 +0,0 @@
[
{
"version": "1.0.0",
"entries": [
"stable release"
]
}
]

@ -1 +0,0 @@
- First stable release, file format version 1

@ -1,3 +0,0 @@
{
"format_version": 1
}

@ -1,78 +0,0 @@
# Configuration for clpack - changelog keeping utility
# https://github.com/MightyPork/clpack
#
# To add a changelog entry manually, place it in a .md file in changelog/entries/
# Folder for data files - clpack will manage contents of this folder.
data_folder = "changelog"
# ID of the default channel - this only matters inside this config file
default_channel = "default"
# Path or file name of the default changelog file, relative to the root of the project.
#
# The name is used as-is.
changelog_file_default = "CHANGELOG.md"
# Path or file of a channel-specific changelog file, relative to the root of the project.
#
# Placeholders supported are:
# - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
changelog_file_channel = "CHANGELOG-{CHANNEL}.md"
# Title of the changelog file, stripped and put back in front when packing changelog entries
changelog_header = '''
# Changelog
'''
# Pattern for release header
release_header = "[{VERSION}] - {DATE}"
# Date format (strftime-based)
#
# For supported patterns, see https://docs.rs/chrono/latest/chrono/format/strftime/index.html
date_format = "%Y-%m-%d"
# Changelog sections suggested when creating a new entry.
#
# Users may also specify custom section names when writing the changelog file.
#
# Changelog entries under each section will be grouped in the packed changelog.
sections = [
"Fixes",
"Improvements",
"New features",
"Internal",
]
# Regex pattern to extract issue number from a branch name.
# There should be one capture group that is the number.
#
# If empty, no branch identification will be attempted.
#
# The default pattern matches 1234-gitlab-style and SW-1234-youtrack-style
branch_issue_pattern = '/^((?:SW-)?\d+)-.*/'
# Regex pattern to extract release number from a branch name.
# There should be exactly one capture group that is the version.
#
# If empty, no branch identification will be attempted.
#
# The default pattern matches e.g. rel/1.2
branch_version_pattern = '/^rel\/([\d.]+)$/'
# Changelog channels & how to identify them from git branch names.
# To add a new release channel, just add it here.
# At least one channel must be defined - see the config option `default_channel`
#
# Format: key=value
#
# - key - changelog ID; this will be used in the channel file name. Examples: default, eap, beta
# - value - git branch name to recognize the channel. This is a regex pattern.
#
# For simple branch names, e.g. `main`, `master`, `test`, write the name simply as string.
#
# To specify a regex pattern (wildcard name), enclose it in slashes, e.g. '/^release\//'
[channels]
default = '/^(?:main|master)$/'

@ -29,7 +29,7 @@ pub fn cl_init(opts: ClInit) -> anyhow::Result<()> {
"Creating clpack config file: {}", "Creating clpack config file: {}",
opts.config_path.display() opts.config_path.display()
); );
file.write_all(crate::config::CONFIG_FILE_TEMPLATE.as_bytes())?; file.write_all(toml::to_string_pretty(&default_config)?.as_bytes())?;
} else { } else {
println!( println!(
"Loading existing config file: {}", "Loading existing config file: {}",

@ -1,78 +0,0 @@
# Configuration for clpack - changelog keeping utility
# https://github.com/MightyPork/clpack
#
# To add a changelog entry manually, place it in a .md file in changelog/entries/
# Folder for data files - clpack will manage contents of this folder.
data_folder = "changelog"
# ID of the default channel - this only matters inside this config file
default_channel = "default"
# Path or file name of the default changelog file, relative to the root of the project.
#
# The name is used as-is.
changelog_file_default = "CHANGELOG.md"
# Path or file of a channel-specific changelog file, relative to the root of the project.
#
# Placeholders supported are:
# - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
changelog_file_channel = "CHANGELOG-{CHANNEL}.md"
# Title of the changelog file, stripped and put back in front when packing changelog entries
changelog_header = '''
# Changelog
'''
# Pattern for release header
release_header = "[{VERSION}] - {DATE}"
# Date format (strftime-based)
#
# For supported patterns, see https://docs.rs/chrono/latest/chrono/format/strftime/index.html
date_format = "%Y-%m-%d"
# Changelog sections suggested when creating a new entry.
#
# Users may also specify custom section names when writing the changelog file.
#
# Changelog entries under each section will be grouped in the packed changelog.
sections = [
"Fixes",
"Improvements",
"New features",
"Internal",
]
# Regex pattern to extract issue number from a branch name.
# There should be one capture group that is the number.
#
# If empty, no branch identification will be attempted.
#
# The default pattern matches 1234-gitlab-style and SW-1234-youtrack-style
branch_issue_pattern = '/^((?:SW-)?\d+)-.*/'
# Regex pattern to extract release number from a branch name.
# There should be exactly one capture group that is the version.
#
# If empty, no branch identification will be attempted.
#
# The default pattern matches e.g. rel/1.2
branch_version_pattern = '/^rel\/([\d.]+)$/'
# Changelog channels & how to identify them from git branch names.
# To add a new release channel, just add it here.
# At least one channel must be defined - see the config option `default_channel`
#
# Format: key=value
#
# - key - changelog ID; this will be used in the channel file name. Examples: default, eap, beta
# - value - git branch name to recognize the channel. This is a regex pattern.
#
# For simple branch names, e.g. `main`, `master`, `test`, write the name simply as string.
#
# To specify a regex pattern (wildcard name), enclose it in slashes, e.g. '/^release\//'
[channels]
default = '/^(?:main|master)$/'

@ -1,6 +1,6 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smart_default::SmartDefault; use smart_default::SmartDefault;
use std::collections::HashMap;
/// e.g. default, stable, eap /// e.g. default, stable, eap
pub type ChannelName = String; pub type ChannelName = String;
@ -11,35 +11,30 @@ pub type VersionName = String;
/// e.g. SW-1234-stuff-is-broken (without .md) /// e.g. SW-1234-stuff-is-broken (without .md)
pub type EntryName = String; pub type EntryName = String;
pub const CONFIG_FILE_TEMPLATE: &str = include_str!("assets/config_file_template.toml");
#[cfg(test)]
#[test]
fn test_template_file() {
// Check 1. that the example config is valid, and 2. that it matches the defaults in the struct
let parsed: Config = toml::from_str(CONFIG_FILE_TEMPLATE).unwrap();
let def = Config::default();
assert_eq!(parsed, def);
}
/// Main app configuration file /// Main app configuration file
#[derive(Debug, Serialize, Deserialize, SmartDefault, PartialEq, Clone)] #[derive(Debug, Serialize, Deserialize, SmartDefault)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct Config { pub struct Config {
/// Name / path of the folder managed by clpack /// Folder for data files - the tool will manage contents of this folder.
/// Changelog entries are simple text files that may be edited manually
/// if corrections need to be made.
#[default = "changelog"] #[default = "changelog"]
pub data_folder: String, pub data_folder: String,
/// ID of the default channel /// ID of the default channel - this only matters inside this config file
#[default = "default"] #[default = "default"]
pub default_channel: String, pub default_channel: String,
/// Path or file name of the default changelog file, relative to project root (CWD) /// Path or file name of the default changelog file, relative to the root of the project.
///
/// The name is used as-is.
#[default = "CHANGELOG.md"] #[default = "CHANGELOG.md"]
pub changelog_file_default: String, pub changelog_file_default: String,
/// Path or file of a channel-specific changelog file, relative to project root (CWD). /// Path or file of a channel-specific changelog file, relative to the root of the project.
/// Supports placeholder `{channel}`, `{Channel}`, `{CHANNEL}` ///
/// Placeholders supported are:
/// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization
#[default = "CHANGELOG-{CHANNEL}.md"] #[default = "CHANGELOG-{CHANNEL}.md"]
pub changelog_file_channel: String, pub changelog_file_channel: String,
@ -56,9 +51,10 @@ pub struct Config {
pub date_format: String, pub date_format: String,
/// Changelog sections suggested when creating a new entry. /// Changelog sections suggested when creating a new entry.
/// The order is maintained.
/// ///
/// Users may also specify custom section names when writing the changelog file. /// Users may also specify a custom section name.
///
/// Changelog entries under each section will be grouped in the packed changelog.
#[default(vec![ #[default(vec![
"Fixes".to_string(), "Fixes".to_string(),
"Improvements".to_string(), "Improvements".to_string(),
@ -79,10 +75,10 @@ pub struct Config {
/// To specify a regex, enclose it in slashes, e.g. /rel\/foo/ /// To specify a regex, enclose it in slashes, e.g. /rel\/foo/
/// ///
/// If you have a naming schema like e.g. `beta/1.0` where only the prefix stays the same, you may use e.g. `^beta/.*` /// If you have a naming schema like e.g. `beta/1.0` where only the prefix stays the same, you may use e.g. `^beta/.*`
#[default(IndexMap::from([ #[default(HashMap::from([
("default".to_string(), "/^(?:main|master)$/".to_string()) ("default".to_string(), "/^(?:main|master)$/".to_string())
]))] ]))]
pub channels: IndexMap<ChannelName, String>, pub channels: HashMap<ChannelName, String>,
/// Regex pattern to extract issue number from a branch name. /// Regex pattern to extract issue number from a branch name.
/// There should be one capture group that is the number. /// There should be one capture group that is the number.
@ -101,6 +97,6 @@ pub struct Config {
/// If None, no branch identification will be attempted. /// If None, no branch identification will be attempted.
/// ///
/// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others /// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others
#[default(Some(r"/^rel\/([\d.]+)$/".to_string()))] #[default(Some(r"/^rel\/(\d+\.\d+)$/".to_string()))]
pub branch_version_pattern: Option<String>, pub branch_version_pattern: Option<String>,
} }

@ -1,5 +1,4 @@
use crate::AppContext; use crate::AppContext;
use crate::utils::empty_to_none::EmptyToNone;
use anyhow::bail; use anyhow::bail;
use std::fmt::Display; use std::fmt::Display;
use std::fmt::Formatter; use std::fmt::Formatter;
@ -80,7 +79,7 @@ impl BranchName {
/// ///
/// Aborts if the configured regex pattern is invalid. /// Aborts if the configured regex pattern is invalid.
pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> { pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
let Some(pat) = ctx.config.branch_version_pattern.as_ref().empty_to_none() else { let Some(pat) = ctx.config.branch_version_pattern.as_ref() else {
return Ok(None); return Ok(None);
}; };
self.parse_using_regex(pat, "branch_version_pattern") self.parse_using_regex(pat, "branch_version_pattern")
@ -90,7 +89,7 @@ impl BranchName {
/// ///
/// Aborts if the configured regex pattern is invalid. /// Aborts if the configured regex pattern is invalid.
pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> { pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
let Some(pat) = ctx.config.branch_issue_pattern.as_ref().empty_to_none() else { let Some(pat) = ctx.config.branch_issue_pattern.as_ref() else {
return Ok(None); return Ok(None);
}; };
self.parse_using_regex(pat, "branch_issue_pattern") self.parse_using_regex(pat, "branch_issue_pattern")

@ -1,4 +1,4 @@
use crate::action_init::{cl_init, ClInit}; use crate::action_init::{ClInit, cl_init};
use crate::action_log::cl_log; use crate::action_log::cl_log;
use crate::action_pack::cl_pack; use crate::action_pack::cl_pack;
use crate::config::{ChannelName, Config}; use crate::config::{ChannelName, Config};
@ -19,8 +19,6 @@ mod action_init;
mod store; mod store;
mod utils;
#[derive(Debug)] #[derive(Debug)]
pub struct AppContext { pub struct AppContext {
/// Name of the cl binary /// Name of the cl binary

@ -1,26 +1,18 @@
use crate::AppContext; use crate::AppContext;
use crate::config::{ChannelName, Config, EntryName, VersionName}; use crate::config::{ChannelName, Config, EntryName, VersionName};
use anyhow::{Context, bail}; use anyhow::bail;
use colored::Colorize; use colored::Colorize;
use faccess::PathExt; use faccess::PathExt;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use std::fs::{File, OpenOptions, read_to_string}; use std::collections::HashMap;
use std::fs::{OpenOptions, read_to_string};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const DIR_ENTRIES: &str = "entries"; const DIR_ENTRIES: &str = "entries";
const DIR_CHANNELS: &str = "channels"; const DIR_CHANNELS: &str = "channels";
const SUPPORTED_FORMAT_VERSION: usize = 1;
#[derive(Debug, Deserialize, Serialize)]
struct Manifest {
/// Versionm of the format
format_version: usize,
}
/// Changelog store struct /// Changelog store struct
pub struct Store<'a> { pub struct Store<'a> {
/// App context, including config /// App context, including config
@ -28,7 +20,7 @@ pub struct Store<'a> {
/// Path to the changelog directory /// Path to the changelog directory
store_path: PathBuf, store_path: PathBuf,
/// Loaded version history for all channels /// Loaded version history for all channels
versions: IndexMap<ChannelName, ChannelReleaseStore>, versions: HashMap<ChannelName, ChannelReleaseStore>,
} }
impl<'a> Store<'a> { impl<'a> Store<'a> {
@ -38,8 +30,8 @@ impl<'a> Store<'a> {
if !store_path.is_dir() { if !store_path.is_dir() {
if init { if init {
// Try to create it // Try to create it
std::fs::create_dir_all(&store_path) eprintln!("Creating changelog dir: {}", store_path.display());
.with_context(|| format!("Creating changelog dir: {}", store_path.display()))?; std::fs::create_dir_all(&store_path)?;
} else { } else {
bail!( bail!(
"Changelog directory does not exist: {}. Use `{} init` to create it.", "Changelog directory does not exist: {}. Use `{} init` to create it.",
@ -56,47 +48,10 @@ impl<'a> Store<'a> {
); );
} }
let manifest_path = store_path.join("manifest.json");
if manifest_path.is_file() {
let manifest_file = OpenOptions::new()
.read(true)
.open(&manifest_path)
.with_context(|| format!("Opening manifest file: {}", manifest_path.display()))?;
let manifest: Manifest = serde_json::from_reader(manifest_file)
.with_context(|| format!("Reading manifest file: {}", manifest_path.display()))?;
if manifest.format_version != 1 {
bail!(
"clpack store is in format {}. This version of clpack requires format {}",
manifest.format_version,
SUPPORTED_FORMAT_VERSION
);
}
} else {
println!("Creating clpack manifest file: {}", manifest_path.display());
let manifest_file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&manifest_path)
.with_context(|| {
format!(
"Opening manifest file for writing: {}",
manifest_path.display()
)
})?;
let manifest = Manifest {
format_version: SUPPORTED_FORMAT_VERSION,
};
serde_json::to_writer_pretty(manifest_file, &manifest)
.with_context(|| format!("Writing manifest file: {}", manifest_path.display()))?;
}
let mut store = Self { let mut store = Self {
store_path, store_path,
ctx, ctx,
versions: IndexMap::new(), versions: HashMap::new(),
}; };
store.ensure_internal_subdirs_exist()?; store.ensure_internal_subdirs_exist()?;
@ -116,7 +71,8 @@ impl<'a> Store<'a> {
/// Check if a changelog entry exists. Filename is passed without extension. /// Check if a changelog entry exists. Filename is passed without extension.
/// This only checks within the current epoch as older files are no longer present. /// This only checks within the current epoch as older files are no longer present.
pub fn entry_exists(&self, name: &str) -> bool { pub fn entry_exists(&self, name: &str) -> bool {
self.make_entry_path(name).exists() let path = self.make_entry_path(name);
path.exists()
} }
/// Load release lists for all channels /// Load release lists for all channels
@ -156,8 +112,8 @@ impl<'a> Store<'a> {
subdir.display() subdir.display()
); );
} }
std::fs::create_dir_all(&subdir) eprintln!("Creating changelog subdir: {}", subdir.display());
.with_context(|| format!("Creating subdir: {}", subdir.display()))?; std::fs::create_dir_all(&subdir)?;
} }
if !subdir.writable() { if !subdir.writable() {
@ -165,7 +121,7 @@ impl<'a> Store<'a> {
} }
if gitkeep { if gitkeep {
File::create(subdir.join(".gitkeep"))?; std::fs::File::create(subdir.join(".gitkeep"))?;
} }
Ok(()) Ok(())
@ -176,10 +132,9 @@ impl<'a> Store<'a> {
let path = self.make_entry_path(name.as_str()); let path = self.make_entry_path(name.as_str());
let mut file = OpenOptions::new().write(true).create(true).open(&path)?; let mut file = OpenOptions::new().write(true).create(true).open(&path)?;
println!("Writing changelog entry to file: {}", path.display()); eprintln!("Writing changelog entry to file: {}", path.display());
file.write_all(content.as_bytes()) file.write_all(content.as_bytes())?;
.with_context(|| format!("Writing file {}", path.display()))?;
Ok(()) Ok(())
} }
@ -286,9 +241,9 @@ pub struct Release {
impl Release { impl Release {
/// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections /// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections
pub fn render(&self, entries_dir: impl AsRef<Path>, config: &Config) -> anyhow::Result<String> { pub fn render(&self, entries_dir: impl AsRef<Path>, config: &Config) -> anyhow::Result<String> {
let mut entries_per_section = IndexMap::<String, String>::new(); let mut entries_per_section = HashMap::<String, String>::new();
let entries_dir = entries_dir.as_ref(); let entries_dir = entries_dir.as_ref();
let unnamed_section = "".to_string(); let unnamed = "".to_string();
for entry in &self.entries { for entry in &self.entries {
let entry_file = entries_dir.join(&format!("{entry}.md")); let entry_file = entries_dir.join(&format!("{entry}.md"));
@ -303,25 +258,22 @@ impl Release {
let file = OpenOptions::new().read(true).open(&entry_file)?; let file = OpenOptions::new().read(true).open(&entry_file)?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut current_section = unnamed_section.clone(); let mut current_section = unnamed.clone();
for line in reader.lines() { for line in reader.lines() {
let line = line?; let line = line?;
let line = line.trim_end(); if line.trim().is_empty() {
let line_trimmed = line.trim();
if line_trimmed.is_empty() {
continue; continue;
} }
if line_trimmed.starts_with('#') { if line.trim().starts_with('#') {
// It is a section name // It is a section name
current_section = line let section = line.trim_matches(|c| c == '#' || c == ' ');
.trim_start_matches(|c| c == '#' || c == ' ') current_section = section.to_string();
.to_string();
} else { } else {
if let Some(buffer) = entries_per_section.get_mut(&current_section) { if let Some(buffer) = entries_per_section.get_mut(&current_section) {
buffer.push('\n'); buffer.push('\n');
buffer.push_str(&line); buffer.push_str(&line);
} else { } else {
entries_per_section.insert(current_section.clone(), line.to_string()); entries_per_section.insert(current_section.clone(), line);
} }
} }
} }
@ -330,12 +282,12 @@ impl Release {
let mut reordered_sections = Vec::<(String, String)>::new(); let mut reordered_sections = Vec::<(String, String)>::new();
// First the unlabelled section (this is probably junk, but it was entered by the user, so keep it) // First the unlabelled section (this is probably junk, but it was entered by the user, so keep it)
if let Some(unlabelled) = entries_per_section.swap_remove("") { if let Some(unlabelled) = entries_per_section.remove("") {
reordered_sections.push(("".to_string(), unlabelled)); reordered_sections.push(("".to_string(), unlabelled));
} }
for section_name in [unnamed_section].iter().chain(config.sections.iter()) { for section_name in [unnamed].iter().chain(config.sections.iter()) {
if let Some(content) = entries_per_section.swap_remove(section_name) { if let Some(content) = entries_per_section.remove(section_name) {
reordered_sections.push((section_name.clone(), content)); reordered_sections.push((section_name.clone(), content));
} }
} }
@ -355,13 +307,12 @@ impl Release {
for (section_name, content) in reordered_sections { for (section_name, content) in reordered_sections {
if !section_name.is_empty() { if !section_name.is_empty() {
buffer.push_str(&format!("\n### {}\n", section_name)); buffer.push_str(&format!("\n### {}\n\n", section_name));
} }
buffer.push_str(content.trim_end()); buffer.push_str(content.trim_end());
buffer.push_str("\n"); buffer.push_str("\n\n");
} }
buffer.push_str("\n");
Ok(buffer) Ok(buffer)
} }
} }

@ -1,37 +0,0 @@
/// Convert Option::Some() to None if the contained value is empty
pub trait EmptyToNone<T> {
fn empty_to_none(self) -> Option<T>;
}
macro_rules! empty_to_none_impl {
($ty:ty) => {
fn empty_to_none(self) -> Option<$ty> {
match self {
None => None,
Some(s) if s.is_empty() => None,
Some(s) => Some(s),
}
}
};
}
impl<'a> EmptyToNone<&'a str> for Option<&'a str> {
empty_to_none_impl!(&'a str);
}
impl<'a> EmptyToNone<&'a String> for Option<&'a String> {
empty_to_none_impl!(&'a String);
}
impl EmptyToNone<String> for Option<String> {
empty_to_none_impl!(String);
}
impl<X> EmptyToNone<Vec<X>> for Option<Vec<X>> {
empty_to_none_impl!(Vec<X>);
}
impl<'a, X> EmptyToNone<&'a Vec<X>> for Option<&'a Vec<X>> {
empty_to_none_impl!(&'a Vec<X>);
}

@ -1 +0,0 @@
pub mod empty_to_none;
Loading…
Cancel
Save