pack command

cl-status
Ondřej Hruška 3 days ago
parent 04d65a47c3
commit e6ff1520ee
  1. 1
      src/action_log.rs
  2. 86
      src/action_pack.rs
  3. 14
      src/config.rs
  4. 14
      src/main.rs
  5. 326
      src/store.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

@ -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::<Vec<_>>();
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,
},
)
}

@ -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)]
@ -29,6 +38,9 @@ pub struct Config {
#[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.
///
/// Users may also specify a custom section name.
@ -57,7 +69,7 @@ pub struct Config {
#[default(HashMap::from([
("default".to_string(), "/^(?:main|master)$/".to_string())
]))]
pub channels: HashMap<String, String>,
pub channels: HashMap<ChannelName, String>,
/// Regex pattern to extract issue number from a branch name.
/// There should be one capture group that is the number.

@ -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::<String>("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");
}

@ -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<ChannelName, ChannelReleaseStore>,
}
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<Vec<EntryName>> {
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::<String>() + 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<EntryName>,
}
impl Release {
/// Render the entry into a Markdown fragment, using h2 (##) as the title.
pub fn render(
&self,
entries_dir: impl AsRef<Path>,
predefined_sections: &[String],
) -> anyhow::Result<String> {
let mut entries_per_section = HashMap::<String, String>::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(&current_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<Release>;
/// 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<Self> {
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::<ReleaseList>(&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<Path>,
) -> anyhow::Result<Vec<EntryName>> {
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)
}
}

Loading…
Cancel
Save