From 55dc83adc629cbd07aa1b2297f8867556f0289e6 Mon Sep 17 00:00:00 2001 From: ondra Date: Sun, 14 Sep 2025 20:38:04 +0200 Subject: [PATCH] implement changelog packing, more config options, better formatting & flexibility --- Cargo.lock | 226 ++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/action_pack.rs | 79 +++++++++++----- src/config.rs | 11 ++- src/git.rs | 4 + src/main.rs | 24 +++-- src/store.rs | 78 ++++++++++------ 7 files changed, 360 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44f29ed..51a4a8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.20" @@ -85,18 +94,47 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.0", +] + [[package]] name = "clap" version = "4.5.47" @@ -129,6 +167,7 @@ name = "clpack" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "colored", "faccess", @@ -156,6 +195,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossterm" version = "0.25.0" @@ -220,6 +265,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fuzzy-matcher" version = "0.3.7" @@ -256,6 +307,30 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.11.1" @@ -296,6 +371,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.175" @@ -351,6 +436,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -461,6 +555,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -514,6 +614,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.18" @@ -707,6 +813,65 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi" version = "0.3.9" @@ -729,12 +894,71 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.0", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -790,7 +1014,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/Cargo.toml b/Cargo.toml index f93d002..88bb078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ regex = "1" anyhow = "1" colored = "3" faccess = "0.2" +chrono = "0.4" # input diff --git a/src/action_pack.rs b/src/action_pack.rs index 0cdb727..3c6ffad 100644 --- a/src/action_pack.rs +++ b/src/action_pack.rs @@ -1,19 +1,32 @@ use crate::AppContext; +use crate::config::ChannelName; use crate::git::get_branch_name; use crate::store::{Release, Store}; use anyhow::bail; use colored::Colorize; /// Perform the action of packing changelog entries for a release -pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> { +pub(crate) fn cl_pack(ctx: AppContext, channel: Option) -> anyhow::Result<()> { let mut store = Store::new(&ctx, false)?; let branch = get_branch_name(&ctx); - let channel_detected = branch - .as_ref() - .map(|b| b.parse_channel(&ctx)) - .transpose()? - .flatten(); + let (channel_detected, channel_explicit) = match channel { + Some(ch) => (Some(ch), true), // passed via flag already + None => ( + branch + .as_ref() + .map(|b| b.parse_channel(&ctx)) + .transpose()? + .flatten(), + false, + ), + }; + + if let Some(ch) = &channel_detected + && !ctx.config.channels.contains_key(ch) + { + bail!("No such channel: {ch}"); + } // If the branch is named rel/3.40, this can extract 3.40. // TODO try to get something better from git! @@ -33,16 +46,20 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> { // Ask for the channel let channel = if ctx.config.channels.len() > 1 { - let channels = ctx.config.channels.values().collect::>(); - let mut starting_index = None; - if let Some(channel) = channel_detected { - starting_index = channels.iter().position(|ch| *ch == &channel); - } - let mut query = inquire::Select::new("Release channel?", channels); - if let Some(index) = starting_index { - query = query.with_starting_cursor(index); + if channel_explicit { + channel_detected.unwrap() + } else { + let channels = ctx.config.channels.keys().collect::>(); + let mut starting_index = None; + if let Some(channel) = channel_detected { + starting_index = channels.iter().position(|ch| *ch == &channel); + } + let mut query = inquire::Select::new("Release channel?", channels); + if let Some(index) = starting_index { + query = query.with_starting_cursor(index); + } + query.prompt()?.to_string() } - query.prompt()?.to_string() } else { // Just one channel, so use that ctx.config.default_channel.clone() @@ -57,9 +74,9 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> { } println!(); - println!("Changes waiting for release:\n"); + println!("Changes waiting for release:"); for entry in &unreleased { - println!("+ {entry}\n"); + println!("+ {}", entry.cyan()); } println!(); @@ -82,11 +99,25 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> { } } - store.create_release( - channel, - Release { - version, - entries: unreleased, - }, - ) + let release = Release { + version, + entries: unreleased, + }; + + let rendered = store.render_release(&release)?; + + println!("\n\nPreview:\n\n{}\n", rendered); + + if !inquire::Confirm::new("Continue - write to changelog file?") + .with_default(true) + .prompt()? + { + eprintln!("{}", "Cancelled.".red()); + return Ok(()); + } + + store.create_release(channel, release)?; + + println!("{}", "Changelog written.".green()); + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 4889369..17a27a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,10 +37,19 @@ pub struct Config { /// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization #[default = "CHANGELOG-{CHANNEL}.md"] pub changelog_file_channel: String, - + + /// Title of the changelog file, stripped and put back in front #[default = "# Changelog\n\n"] pub changelog_header: String, + /// Pattern for release header + #[default = "[{VERSION}] - {DATE}"] + pub release_header: String, + + /// Date format (see patterns supported by the Chrono crate: https://docs.rs/chrono/latest/chrono/format/strftime/index.html ) + #[default = "%Y-%m-%d"] + pub date_format: String, + /// Changelog sections suggested when creating a new entry. /// /// Users may also specify a custom section name. diff --git a/src/git.rs b/src/git.rs index 3be027a..1882acb 100644 --- a/src/git.rs +++ b/src/git.rs @@ -98,6 +98,10 @@ impl BranchName { /// Try to detect a release channel from this branch name (e.g. stable, EAP) pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result> { for (channel_id, template) in &ctx.config.channels { + if template.is_empty() { + // Channel only for manual choosing + continue; + } if let Some(pat_s) = as_regex_pattern(template) { let pat = match regex::Regex::new(pat_s) { Ok(pat) => pat, diff --git a/src/main.rs b/src/main.rs index e993e88..ea4ddde 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use crate::action_init::{ClInit, cl_init}; use crate::action_log::cl_log; use crate::action_pack::cl_pack; -use crate::config::Config; +use crate::config::{ChannelName, Config}; use anyhow::bail; use clap::builder::NonEmptyStringValueParser; use colored::Colorize; @@ -33,7 +33,7 @@ pub struct AppContext { fn main() { if let Err(e) = main_try() { - eprintln!("{}", e.to_string().red().bold()); + eprintln!("{}", format!("{:?}", e).red().bold()); exit(1); } } @@ -54,15 +54,20 @@ fn main_try() -> anyhow::Result<()> { .subcommand( clap::Command::new("pack") .visible_alias("release") - .about("Pack changelog entries to a changelog section"), + .about("Pack changelog entries to a changelog section") + .arg(clap::Arg::new("CHANNEL") + .short('x') + .long("channel") + .value_parser(NonEmptyStringValueParser::new()) + .required(false)), ) .subcommand(clap::Command::new("add") .visible_alias("log") .about("Add a changelog entry on the current branch")) - .subcommand(clap::Command::new("flush") - .about("Remove all changelog entries that were already released on all channels - clean up the changelog dir. Use e.g. when making a major release where all channel branches are merged.")) - .subcommand(clap::Command::new("status") - .about("Show changelog entries currently waiting for release on the current channel")) + // .subcommand(clap::Command::new("flush") + // .about("Remove all changelog entries that were already released on all channels - clean up the changelog dir. Use e.g. when making a major release where all channel branches are merged.")) + // .subcommand(clap::Command::new("status") + // .about("Show changelog entries currently waiting for release on the current channel")) .subcommand_required(false) .arg(clap::Arg::new("CONFIG") .short('c') @@ -122,8 +127,9 @@ fn main_try() -> anyhow::Result<()> { // eprintln!("AppCtx: {:?}", ctx); match args.subcommand() { - Some(("pack", _)) => { - cl_pack(ctx)?; + Some(("pack", subargs)) => { + let channel: Option = subargs.get_one("CHANNEL").cloned(); + cl_pack(ctx, channel)?; } None | Some(("add", _)) => cl_log(ctx)?, // TODO: status, flush diff --git a/src/store.rs b/src/store.rs index f3ad784..721557d 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,5 +1,5 @@ use crate::AppContext; -use crate::config::{ChannelName, EntryName, VersionName}; +use crate::config::{ChannelName, Config, EntryName, VersionName}; use anyhow::bail; use colored::Colorize; use faccess::PathExt; @@ -92,8 +92,8 @@ impl<'a> Store<'a> { /// Check and create internal subdirs for the clpack system pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> { - self.ensure_subdir_exists(DIR_ENTRIES)?; - self.ensure_subdir_exists(DIR_CHANNELS)?; + self.ensure_subdir_exists(DIR_ENTRIES, true)?; + self.ensure_subdir_exists(DIR_CHANNELS, false)?; // TODO Ok(()) @@ -102,7 +102,7 @@ impl<'a> Store<'a> { /// make sure a subdir exists, creating if needed. /// /// Note there is no lock so there can be a TOCTOU bug later if someone deletes the path - must be checked and handled. - fn ensure_subdir_exists(&self, name: &str) -> anyhow::Result<()> { + fn ensure_subdir_exists(&self, name: &str, gitkeep: bool) -> anyhow::Result<()> { let subdir = self.store_path.join(name); if !subdir.is_dir() { @@ -120,7 +120,9 @@ impl<'a> Store<'a> { bail!("Changelog subdir is not writable: {}", subdir.display()); } - std::fs::File::create(subdir.join(".gitkeep"))?; + if gitkeep { + std::fs::File::create(subdir.join(".gitkeep"))?; + } Ok(()) } @@ -157,12 +159,13 @@ impl<'a> Store<'a> { /// Create a release entry, write it to the releases buffer and to the file. pub fn create_release(&mut self, channel: ChannelName, release: Release) -> anyhow::Result<()> { + let rendered = self.render_release(&release)?; + let Some(store) = self.versions.get_mut(&channel) else { bail!("Channel {channel} does not exist."); }; let config = &self.ctx.config; - let rendered = release.render(self.store_path.join(DIR_ENTRIES), &config.sections)?; let changelog_file = self.ctx.root.join( if channel == config.default_channel { @@ -209,6 +212,12 @@ impl<'a> Store<'a> { store.write_to_file()?; Ok(()) } + + /// Render a release + pub fn render_release(&self, release: &Release) -> anyhow::Result { + let config = &self.ctx.config; + release.render(self.store_path.join(DIR_ENTRIES), &config) + } } /// Uppercase first char of a string @@ -230,12 +239,8 @@ pub struct Release { } impl Release { - /// Render the entry into a Markdown fragment, using h2 (##) as the title. - pub fn render( - &self, - entries_dir: impl AsRef, - predefined_sections: &[String], - ) -> anyhow::Result { + /// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections + pub fn render(&self, entries_dir: impl AsRef, config: &Config) -> anyhow::Result { let mut entries_per_section = HashMap::::new(); let entries_dir = entries_dir.as_ref(); let unnamed = "".to_string(); @@ -256,6 +261,9 @@ impl Release { let mut current_section = unnamed.clone(); for line in reader.lines() { let line = line?; + if line.trim().is_empty() { + continue; + } if line.trim().starts_with('#') { // It is a section name let section = line.trim_matches(|c| c == '#' || c == ' '); @@ -278,7 +286,7 @@ impl Release { reordered_sections.push(("".to_string(), unlabelled)); } - for section_name in [unnamed].iter().chain(predefined_sections.iter()) { + for section_name in [unnamed].iter().chain(config.sections.iter()) { if let Some(content) = entries_per_section.remove(section_name) { reordered_sections.push((section_name.clone(), content)); } @@ -288,14 +296,21 @@ impl Release { reordered_sections.push((section_name, content)); } - let mut buffer = String::new(); + let date = chrono::Local::now(); + let mut buffer = format!( + "## {}\n", + config + .release_header + .replace("{VERSION}", &self.version) + .replace("{DATE}", &date.format(&config.date_format).to_string()) + ); for (section_name, content) in reordered_sections { if !section_name.is_empty() { - buffer.push_str(&format!("## {}\n", section_name)); + buffer.push_str(&format!("\n### {}\n\n", section_name)); } - buffer.push_str(&content); - buffer.push('\n'); + buffer.push_str(content.trim_end()); + buffer.push_str("\n\n"); } Ok(buffer) @@ -318,13 +333,18 @@ struct ChannelReleaseStore { impl ChannelReleaseStore { /// Load from a versions file fn load(releases_file: PathBuf, channel_name: ChannelName) -> anyhow::Result { + println!( + "Loading versions for channel {} from: {}", + channel_name, + releases_file.display() + ); let releases = if !releases_file.exists() { - // File did not exist yet, create it + // File did not exist yet, create it - this catches error with write access early let mut f = OpenOptions::new() .write(true) .create(true) .open(&releases_file)?; - f.write_all("{}".as_bytes())?; + f.write_all("[]".as_bytes())?; Default::default() } else { let channel_json = read_to_string(&releases_file)?; @@ -384,14 +404,16 @@ impl ChannelReleaseStore { })?; if !entry.metadata()?.is_file() || !fname.ends_with(".md") { - eprintln!( - "{}", - format!( - "Unexpected item in changelog entries dir: {}", - entry.path().display() - ) - .yellow() - ); + if fname != ".gitkeep" { + eprintln!( + "{}", + format!( + "Unexpected item in changelog entries dir: {}", + entry.path().display() + ) + .yellow() + ); + } continue; } @@ -404,7 +426,7 @@ impl ChannelReleaseStore { .flatten() .any(|entryname| entryname == basename) { - found.push(fname); + found.push(basename.to_string()); } }