From e6ff1520ee4fcf8464b3a626107c9f252c924c16 Mon Sep 17 00:00:00 2001 From: ondra Date: Sun, 14 Sep 2025 19:29:46 +0200 Subject: [PATCH] pack command --- src/action_log.rs | 1 - src/action_pack.rs | 86 +++++++++++- src/config.rs | 14 +- src/main.rs | 14 +- src/store.rs | 326 +++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 415 insertions(+), 26 deletions(-) diff --git a/src/action_log.rs b/src/action_log.rs index 7824fa4..85bd8d6 100644 --- a/src/action_log.rs +++ b/src/action_log.rs @@ -8,7 +8,6 @@ use colored::Colorize; /// Perform the action of adding a new log entry pub(crate) fn cl_log(ctx: AppContext) -> anyhow::Result<()> { let store = Store::new(&ctx, false)?; - store.ensure_internal_subdirs_exist()?; let branch = get_branch_name(&ctx); let issue = branch diff --git a/src/action_pack.rs b/src/action_pack.rs index 0289fa7..0cdb727 100644 --- a/src/action_pack.rs +++ b/src/action_pack.rs @@ -1,16 +1,92 @@ use crate::AppContext; 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, manual_channel: Option<&str>) -> anyhow::Result<()> { +pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> { + let mut store = Store::new(&ctx, false)?; let branch = get_branch_name(&ctx); - let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).transpose()?.flatten(); - let version = branch.as_ref().map(|b| b.parse_version(&ctx)).transpose()?.flatten(); + let channel_detected = branch + .as_ref() + .map(|b| b.parse_channel(&ctx)) + .transpose()? + .flatten(); + + // If the branch is named rel/3.40, this can extract 3.40. + // TODO try to get something better from git! + let version_base = branch + .as_ref() + .map(|b| b.parse_version(&ctx)) + .transpose()? + .flatten(); + + // TODO detect version from git query? + + // TODO remove this eprintln!( "Branch name: {:?}, channel: {:?}, version: {:?}", - branch, channel, version + branch, channel_detected, version_base ); - todo!(); + // 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); + } + query.prompt()?.to_string() + } else { + // Just one channel, so use that + ctx.config.default_channel.clone() + }; + println!("Channel: {}", channel.green().bold()); + + let unreleased = store.find_unreleased_changes(&channel)?; + + if unreleased.is_empty() { + eprintln!("No unreleased changes."); + return Ok(()); + } + + println!(); + println!("Changes waiting for release:\n"); + for entry in &unreleased { + println!("+ {entry}\n"); + } + println!(); + + // Ask for the version + let mut version = version_base.unwrap_or_default(); + loop { + // Ask for full version + version = inquire::Text::new("Version:") + .with_initial_value(&version) + .prompt()?; + + if version.is_empty() { + bail!("Cancelled"); + } + + if store.version_exists(&version) { + println!("{}", "Version already exists, try again or cancel.".red()); + } else { + break; + } + } + + store.create_release( + channel, + Release { + version, + entries: unreleased, + }, + ) } diff --git a/src/config.rs b/src/config.rs index 5d2cf84..4889369 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,15 @@ use serde::{Deserialize, Serialize}; use smart_default::SmartDefault; use std::collections::HashMap; +/// e.g. default, stable, eap +pub type ChannelName = String; + +/// e.g. 1.2.3 +pub type VersionName = String; + +/// e.g. SW-1234-stuff-is-broken (without .md) +pub type EntryName = String; + /// Main app configuration file #[derive(Debug, Serialize, Deserialize, SmartDefault)] #[serde(deny_unknown_fields, default)] @@ -28,6 +37,9 @@ pub struct Config { /// - `{channel}`, `{Channel}`, `{CHANNEL}` - Channel ID in the respective capitalization #[default = "CHANGELOG-{CHANNEL}.md"] pub changelog_file_channel: String, + + #[default = "# Changelog\n\n"] + pub changelog_header: String, /// Changelog sections suggested when creating a new entry. /// @@ -57,7 +69,7 @@ pub struct Config { #[default(HashMap::from([ ("default".to_string(), "/^(?:main|master)$/".to_string()) ]))] - pub channels: HashMap, + pub channels: HashMap, /// Regex pattern to extract issue number from a branch name. /// There should be one capture group that is the number. diff --git a/src/main.rs b/src/main.rs index 9eba35b..e993e88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,13 +54,7 @@ fn main_try() -> anyhow::Result<()> { .subcommand( clap::Command::new("pack") .visible_alias("release") - .about("Create a release changelog entry for the current channel") - .arg( - clap::Arg::new("CHANNEL") - .help("Channel ID, possible values depend on project config. None for main channel.") - .value_parser(NonEmptyStringValueParser::new()) - .required(false), - ), + .about("Pack changelog entries to a changelog section"), ) .subcommand(clap::Command::new("add") .visible_alias("log") @@ -128,11 +122,11 @@ fn main_try() -> anyhow::Result<()> { // eprintln!("AppCtx: {:?}", ctx); match args.subcommand() { - Some(("pack", subargs)) => { - let manual_channel = subargs.get_one::("CHANNEL"); - cl_pack(ctx, manual_channel.map(String::as_str))?; + Some(("pack", _)) => { + cl_pack(ctx)?; } None | Some(("add", _)) => cl_log(ctx)?, + // TODO: status, flush Some((other, _)) => { bail!("Subcommand {other} is not implemented yet"); } diff --git a/src/store.rs b/src/store.rs index 9147a3d..f3ad784 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,14 +1,26 @@ use crate::AppContext; +use crate::config::{ChannelName, EntryName, VersionName}; use anyhow::bail; +use colored::Colorize; use faccess::PathExt; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashMap; +use std::fs::{OpenOptions, read_to_string}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +const DIR_ENTRIES: &str = "entries"; +const DIR_CHANNELS: &str = "channels"; + +/// Changelog store struct pub struct Store<'a> { + /// App context, including config ctx: &'a AppContext, - + /// Path to the changelog directory store_path: PathBuf, + /// Loaded version history for all channels + versions: HashMap, } impl<'a> Store<'a> { @@ -36,9 +48,14 @@ impl<'a> Store<'a> { ); } - let store = Self { store_path, ctx }; + let mut store = Self { + store_path, + ctx, + versions: HashMap::new(), + }; store.ensure_internal_subdirs_exist()?; + store.load_versions()?; Ok(store) } @@ -47,7 +64,7 @@ impl<'a> Store<'a> { /// This is a file in the entries storage fn make_entry_path(&self, filename: &str) -> PathBuf { self.store_path - .join("entries") + .join(DIR_ENTRIES) .join(format!("{filename}.md")) } @@ -58,9 +75,25 @@ impl<'a> Store<'a> { path.exists() } + /// Load release lists for all channels + fn load_versions(&mut self) -> anyhow::Result<()> { + let channels_dir = self.store_path.join(DIR_CHANNELS); + + for ch in self.ctx.config.channels.keys() { + let channel_file = channels_dir.join(format!("{}.json", ch)); + self.versions.insert( + ch.clone(), + ChannelReleaseStore::load(channel_file, ch.clone())?, + ); + } + + Ok(()) + } + /// Check and create internal subdirs for the clpack system pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> { - self.ensure_subdir_exists("entries")?; + self.ensure_subdir_exists(DIR_ENTRIES)?; + self.ensure_subdir_exists(DIR_CHANNELS)?; // TODO Ok(()) @@ -93,13 +126,288 @@ impl<'a> Store<'a> { } /// Create a changelog entry file and write content to it - pub fn create_entry(&self, name: String, content: String) -> anyhow::Result<()> { + pub fn create_entry(&self, name: EntryName, content: String) -> anyhow::Result<()> { let path = self.make_entry_path(name.as_str()); let mut file = OpenOptions::new().write(true).create(true).open(&path)?; - eprintln!("Writing to file: {}", path.display()); + eprintln!("Writing changelog entry to file: {}", path.display()); file.write_all(content.as_bytes())?; Ok(()) } + + /// Check if a version was already released (on any channel) - prevents the user from making a mistake in version naming + pub fn version_exists(&self, version: &str) -> bool { + for v in self.versions.values() { + if v.version_exists(version) { + return true; + } + } + false + } + + /// Find unreleased changelog entries on a channel + pub fn find_unreleased_changes(&self, channel: &ChannelName) -> anyhow::Result> { + let Some(store) = self.versions.get(channel) else { + bail!("Channel {channel} does not exist."); + }; + + store.find_unreleased_entries(self.store_path.join(DIR_ENTRIES)) + } + + /// 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 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 { + Cow::Borrowed(config.changelog_file_default.as_str()) + } else { + Cow::Owned( + config + .changelog_file_channel + .replace("{channel}", &channel.to_lowercase()) + .replace("{CHANNEL}", &channel.to_uppercase()) + .replace("{Channel}", &ucfirst(&channel)), + ) + } + .as_ref(), + ); + + if changelog_file.exists() { + let changelog_file_content = read_to_string(&changelog_file)?; + let old_content = changelog_file_content + .strip_prefix(&config.changelog_header) + .unwrap_or(&changelog_file_content); + + let mut outfile = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(changelog_file)?; + + outfile.write_all( + format!("{}{}{}", config.changelog_header, rendered, old_content).as_bytes(), + )?; + } else { + let mut outfile = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(changelog_file)?; + + outfile.write_all(format!("{}{}", config.changelog_header, rendered).as_bytes())?; + } + + store.add_version(release)?; + // Write to the changelog file for this channel + store.write_to_file()?; + Ok(()) + } +} + +/// Uppercase first char of a string +fn ucfirst(input: &str) -> String { + let mut c = input.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } +} + +/// Summary of a release +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Release { + /// Name of the version + pub version: VersionName, + /// List of entries included in this version + pub entries: Vec, +} + +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 { + let mut entries_per_section = HashMap::::new(); + let entries_dir = entries_dir.as_ref(); + let unnamed = "".to_string(); + + for entry in &self.entries { + let entry_file = entries_dir.join(&format!("{entry}.md")); + + if !entry_file.exists() || !entry_file.readable() { + bail!( + "Changelog entry file missing or not readable: {}", + entry_file.display() + ); + } + + let file = OpenOptions::new().read(true).open(&entry_file)?; + let reader = BufReader::new(file); + + let mut current_section = unnamed.clone(); + for line in reader.lines() { + let line = line?; + if line.trim().starts_with('#') { + // It is a section name + let section = line.trim_matches(|c| c == '#' || c == ' '); + current_section = section.to_string(); + } else { + if let Some(buffer) = entries_per_section.get_mut(¤t_section) { + buffer.push('\n'); + buffer.push_str(&line); + } else { + entries_per_section.insert(current_section.clone(), line); + } + } + } + } + + 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) + if let Some(unlabelled) = entries_per_section.remove("") { + reordered_sections.push(("".to_string(), unlabelled)); + } + + for section_name in [unnamed].iter().chain(predefined_sections.iter()) { + if let Some(content) = entries_per_section.remove(section_name) { + reordered_sections.push((section_name.clone(), content)); + } + } + // Leftovers (names authors invented when writing changelog) + for (section_name, content) in entries_per_section { + reordered_sections.push((section_name, content)); + } + + let mut buffer = String::new(); + + for (section_name, content) in reordered_sections { + if !section_name.is_empty() { + buffer.push_str(&format!("## {}\n", section_name)); + } + buffer.push_str(&content); + buffer.push('\n'); + } + + Ok(buffer) + } +} + +/// List of releases, deserialized from a file +type ReleaseList = Vec; + +/// Versions store for one channel +struct ChannelReleaseStore { + /// File where the list of versions is stored + backing_file: PathBuf, + /// Name of the channel, for error messages + channel_name: ChannelName, + /// List of releases, load from the file + releases: ReleaseList, +} + +impl ChannelReleaseStore { + /// Load from a versions file + fn load(releases_file: PathBuf, channel_name: ChannelName) -> anyhow::Result { + let releases = if !releases_file.exists() { + // File did not exist yet, create it + let mut f = OpenOptions::new() + .write(true) + .create(true) + .open(&releases_file)?; + f.write_all("{}".as_bytes())?; + Default::default() + } else { + let channel_json = read_to_string(&releases_file)?; + serde_json::from_str::(&channel_json)? + }; + + Ok(Self { + backing_file: releases_file, + channel_name, + releases, + }) + } + + /// Check if a version is included in a release + fn version_exists(&self, version: &str) -> bool { + self.releases.iter().any(|rel| rel.version == version) + } + + /// Add a version to the channel buffer + /// The release entry, borrowed, is returned for further use + fn add_version(&mut self, release: Release) -> anyhow::Result<()> { + if self.version_exists(&release.version) { + bail!( + "Version {} already exists on channel {}", + release.version, + self.channel_name + ); + } + self.releases.push(release); + Ok(()) + } + + /// Write the versions list contained in this store into the backing file. + fn write_to_file(&self) -> anyhow::Result<()> { + let encoded = serde_json::to_string_pretty(&self.releases)?; + let mut f = OpenOptions::new() + .write(true) + .create(true) + .open(&self.backing_file)?; + f.write_all(encoded.as_bytes())?; + Ok(()) + } + + /// Find entries not yet included in this release channel + fn find_unreleased_entries( + &self, + entries_dir: impl AsRef, + ) -> anyhow::Result> { + let mut found = vec![]; + + for entry in entries_dir.as_ref().read_dir()? { + let entry = entry?; + + let fname_os = entry.file_name(); + let fname = fname_os.into_string().map_err(|_| { + anyhow::anyhow!("Failed to parse file name: {}", entry.path().display()) + })?; + + if !entry.metadata()?.is_file() || !fname.ends_with(".md") { + eprintln!( + "{}", + format!( + "Unexpected item in changelog entries dir: {}", + entry.path().display() + ) + .yellow() + ); + continue; + } + + let basename = fname.strip_suffix(".md").unwrap(); + + if !self + .releases + .iter() + .map(|rel| &rel.entries) + .flatten() + .any(|entryname| entryname == basename) + { + found.push(fname); + } + } + + Ok(found) + } }