MVP "log" action added

cl-status
Ondřej Hruška 4 days ago
parent 8393eb03ee
commit c00c6dfcd7
  1. 4
      .gitignore
  2. 122
      Cargo.lock
  3. 7
      Cargo.toml
  4. 102
      src/action_log.rs
  5. 7
      src/action_pack.rs
  6. 81
      src/config.rs
  7. 112
      src/git.rs
  8. 178
      src/main.rs
  9. 105
      src/store.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

122
Cargo.lock generated

@ -61,6 +61,12 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "anyhow"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@ -122,7 +128,10 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
name = "clpack" name = "clpack"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"clap", "clap",
"colored",
"faccess",
"inquire", "inquire",
"regex", "regex",
"serde", "serde",
@ -138,6 +147,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "colored"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e"
dependencies = [
"windows-sys 0.48.0",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.25.0" version = "0.25.0"
@ -175,6 +193,33 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "faccess"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ae66425802d6a903e268ae1a08b8c38ba143520f227a205edf4e9c7e3e26d5"
dependencies = [
"bitflags 1.3.2",
"libc",
"winapi",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "fuzzy-matcher" name = "fuzzy-matcher"
version = "0.3.7" version = "0.3.7"
@ -193,6 +238,18 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.5+wasi-0.2.4",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -222,6 +279,7 @@ dependencies = [
"fxhash", "fxhash",
"newline-converter", "newline-converter",
"once_cell", "once_cell",
"tempfile",
"unicode-segmentation", "unicode-segmentation",
"unicode-width", "unicode-width",
] ]
@ -244,6 +302,12 @@ 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.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.13" version = "0.4.13"
@ -274,7 +338,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
@ -340,6 +404,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.17" version = "0.5.17"
@ -378,6 +448,19 @@ 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 = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.4",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.20" version = "1.0.20"
@ -495,6 +578,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.16" version = "2.0.16"
@ -593,6 +689,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.5+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4494f6290a82f5fe584817a676a34b9d6763e8d9d18204009fb31dceca98fd4"
dependencies = [
"wasip2",
]
[[package]]
name = "wasip2"
version = "1.0.0+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03fa2761397e5bd52002cd7e73110c71af2109aca4e521a9f40473fe685b0a24"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -830,3 +944,9 @@ name = "winnow"
version = "0.7.13" version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
[[package]]
name = "wit-bindgen"
version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36"

@ -6,15 +6,18 @@ 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 = "4.5" clap = { version = "4.5", features = ["string"] }
thiserror = "2" thiserror = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.9" toml = "0.9"
smart-default = "0.7" smart-default = "0.7"
regex = "1" regex = "1"
anyhow = "1"
colored = "3"
faccess = "0.2"
# input # input
# sadly this looks mostly abandoned. Alternative is "dialoguer" # sadly this looks mostly abandoned. Alternative is "dialoguer"
inquire = "0.7.5" inquire = { version = "0.7.5", features = ["editor"] }

@ -1,11 +1,105 @@
use crate::AppContext; use crate::AppContext;
use crate::git::BranchOpt;
use crate::git::get_branch_name; use crate::git::get_branch_name;
use crate::store::Store;
use anyhow::bail;
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()?;
pub(crate) fn cl_log(ctx: AppContext) {
let branch = get_branch_name(&ctx); let branch = get_branch_name(&ctx);
let issue = branch.as_ref().map(|b| b.parse_issue(&ctx)).flatten(); let issue = branch
.as_ref()
.map(|b| b.parse_issue(&ctx))
.transpose()?
.flatten();
if let Some(num) = &issue {
println!("{}", format!("Issue # parsed from branch: {num}").green());
} else {
eprintln!(
"{}",
format!(
"Issue not recognized from branch name! (\"{}\")",
branch.as_str_or_default()
)
.yellow()
);
}
let mut entry_name = branch.as_str_or_default().to_string();
// Space
println!();
loop {
// Ask for filename
let mut query = inquire::Text::new("Log entry name:")
.with_help_message("Used as a filename, without extension");
if issue.is_some() {
query = query.with_initial_value(&entry_name);
}
entry_name = query.prompt()?;
if entry_name.is_empty() {
bail!("Cancelled");
}
if store.entry_exists(&entry_name) {
println!("{}", "Entry already exists, try different name.".red());
} else {
break;
}
}
// Space
println!();
// Ask for sections
let sections = inquire::MultiSelect::new(
"Choose changelog sections to pre-generate (at least one)",
ctx.config.sections.clone(),
)
.prompt()?;
if sections.is_empty() {
bail!("Cancelled");
}
let mut prefill_text = String::new();
for section in sections {
if !prefill_text.is_empty() {
prefill_text.push('\n');
}
prefill_text.push_str(&format!("# {section}\n"));
if let Some(num) = &issue {
prefill_text.push_str(&format!("- (#{num})\n"));
} else {
prefill_text.push_str("- \n");
}
}
println!(
"\nPreview of changelog entry \"{entry_name}\" (not yet saved)\n\n{}\n",
prefill_text
);
// Edit the file
let mut text = inquire::Editor::new("Edit as needed, then confirm")
.with_predefined_text(&prefill_text)
.with_file_extension("md")
.prompt()?;
if text.is_empty() {
text = prefill_text;
}
eprintln!("Branch name: {:?}, issue: {:?}", branch, issue); store.create_entry(entry_name, text)?;
todo!(); println!("{}", "Done.".green());
Ok(())
} }

@ -1,10 +1,11 @@
use crate::AppContext; use crate::AppContext;
use crate::git::get_branch_name; use crate::git::get_branch_name;
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) { /// Perform the action of packing changelog entries for a release
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) -> anyhow::Result<()> {
let branch = get_branch_name(&ctx); let branch = get_branch_name(&ctx);
let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).flatten(); let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).transpose()?.flatten();
let version = branch.as_ref().map(|b| b.parse_version(&ctx)).flatten(); let version = branch.as_ref().map(|b| b.parse_version(&ctx)).transpose()?.flatten();
eprintln!( eprintln!(
"Branch name: {:?}, channel: {:?}, version: {:?}", "Branch name: {:?}, channel: {:?}, version: {:?}",

@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
use smart_default::SmartDefault;
use std::collections::HashMap;
/// Main app configuration file
#[derive(Debug, Serialize, Deserialize, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
/// 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"]
pub data_folder: String,
/// ID of the default channel - this only matters inside this config file
#[default = "default"]
pub default_channel: String,
/// 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"]
pub changelog_file_default: String,
/// 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
#[default = "CHANGELOG-{CHANNEL}.md"]
pub changelog_file_channel: String,
/// Changelog sections suggested when creating a new entry.
///
/// Users may also specify a custom section name.
///
/// Changelog entries under each section will be grouped in the packed changelog.
#[default(vec![
"Fixes".to_string(),
"Improvements".to_string(),
"New features".to_string(),
"Internal".to_string(),
])]
pub sections: Vec<String>,
/// Changelog channels - how to identify them from git branch names
///
/// - Key - changelog ID; this can be used in the channel file name. Examples: default, eap, beta
/// - Value - git branch name to recognize the channel. This is a regex pattern.
///
/// At least one channel must be defined, with the name defined in `default_channel`
///
/// # Value format
/// For simple branch names without special symbols that do not change, e.g. `main`, `master`, `test`, you can just use the name as is.
/// 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/.*`
#[default(HashMap::from([
("default".to_string(), "/^(?:main|master)$/".to_string())
]))]
pub channels: HashMap<String, String>,
/// Regex pattern to extract issue number from a branch name.
/// There should be one capture group that is the number.
///
/// Example: `/^(SW-\d+)-.*$/` or `/^(\d+)-.*$/`
///
/// If None, no branch identification will be attempted.
#[default(Some(r"/^((?:SW-)?\d+)-.*/".to_string()))]
pub branch_issue_pattern: Option<String>,
/// Regex pattern to extract release number from a branch name.
/// There should be one capture group that is the version.
///
/// Example: `/^rel\/(\d+.\d+)$/`
///
/// If None, no branch identification will be attempted.
///
/// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others
#[default(Some(r"/^rel\/(\d+\.\d+)$/".to_string()))]
pub branch_version_pattern: Option<String>,
}

@ -1,7 +1,7 @@
use crate::AppContext; use crate::AppContext;
use anyhow::bail;
use std::fmt::Display; use std::fmt::Display;
use std::fmt::Formatter; use std::fmt::Formatter;
use std::process::exit;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct BranchName(pub String); pub struct BranchName(pub String);
@ -39,86 +39,86 @@ impl BranchName {
/// ///
/// pat_s - the regex pattern /// pat_s - the regex pattern
/// regex_conf_name - name of the conf field the regex came from, for error messages /// regex_conf_name - name of the conf field the regex came from, for error messages
fn parse_using_regex(&self, template: &str, regex_conf_name: &str) -> Option<String> { fn parse_using_regex(
&self,
template: &str,
regex_conf_name: &str,
) -> anyhow::Result<Option<String>> {
let Some(pat_s) = as_regex_pattern(template) else { let Some(pat_s) = as_regex_pattern(template) else {
eprintln!( bail!(
"Config field \"{regex_conf_name}\" must contain a regex (encased in slashes). Found: {template}" "Config field \"{regex_conf_name}\" must contain a regex (encased in slashes). Found: {template}"
); );
exit(1);
//return None;
}; };
let pat = match regex::Regex::new(pat_s) { let pat = match regex::Regex::new(pat_s) {
Ok(pat) => pat, Ok(pat) => pat,
Err(e) => { Err(e) => {
eprintln!("Invalid regex in \"{regex_conf_name}\": {pat_s}"); bail!("Invalid regex in \"{regex_conf_name}\": {pat_s}\nError: {e}");
eprintln!("Error: {e}");
exit(1);
//return None;
} }
}; };
let num_captures = pat.captures_len(); let num_captures = pat.captures_len();
if num_captures != 2 { if num_captures != 2 {
eprintln!("The pattern \"{regex_conf_name}\" is not applicable: {pat_s}"); // capture "0" is the whole matched string
eprintln!( bail!(
"There must be exactly one capturing group. Found {}", "The pattern \"{regex_conf_name}\" is not applicable: {pat_s}\nThere must be exactly one capturing group. Found {}",
num_captures - 1 num_captures - 1
); );
exit(1);
//return None;
} }
let matches = pat.captures(&self.0)?; let Some(matches) = pat.captures(&self.0) else {
Some(matches.get(1)?.as_str().to_owned()) return Ok(None);
};
let Some(amatch) = matches.get(1) else {
return Ok(None);
};
Ok(Some(amatch.as_str().to_owned()))
} }
/// Parse version from this branch name. /// Parse version from this branch name.
/// ///
/// Aborts if the configured regex pattern is invalid. /// Aborts if the configured regex pattern is invalid.
pub fn parse_version(&self, ctx: &AppContext) -> Option<String> { pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
self.parse_using_regex( let Some(pat) = ctx.config.branch_version_pattern.as_ref() else {
ctx.config.branch_version_pattern.as_ref()?, return Ok(None);
"branch_version_pattern", };
) self.parse_using_regex(pat, "branch_version_pattern")
} }
/// Parse issue number from this branch name. /// Parse issue number from this branch name.
/// ///
/// Aborts if the configured regex pattern is invalid. /// Aborts if the configured regex pattern is invalid.
pub fn parse_issue(&self, ctx: &AppContext) -> Option<String> { pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
self.parse_using_regex( let Some(pat) = ctx.config.branch_issue_pattern.as_ref() else {
ctx.config.branch_issue_pattern.as_ref()?, return Ok(None);
"branch_issue_pattern", };
) self.parse_using_regex(pat, "branch_issue_pattern")
} }
/// Try to detect a release channel from this branch name (e.g. stable, EAP) /// Try to detect a release channel from this branch name (e.g. stable, EAP)
pub fn parse_channel(&self, ctx: &AppContext) -> Option<String> { pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
for (channel_id, template) in &ctx.config.channels { for (channel_id, template) in &ctx.config.channels {
if let Some(pat_s) = as_regex_pattern(template) { if let Some(pat_s) = as_regex_pattern(template) {
let pat = match regex::Regex::new(pat_s) { let pat = match regex::Regex::new(pat_s) {
Ok(pat) => pat, Ok(pat) => pat,
Err(e) => { Err(e) => {
eprintln!("Invalid regex for channel \"{channel_id}\": {template}"); bail!("Invalid regex for channel \"{channel_id}\": {template}\nError: {e}");
eprintln!("Error: {e}");
exit(1);
} }
}; };
if pat.is_match(&self.0) { if pat.is_match(&self.0) {
return Some(channel_id.to_owned()); return Ok(Some(channel_id.to_owned()));
} }
} else { } else {
// No regex - match it verbatim // No regex - match it verbatim
if &self.0 == template { if &self.0 == template {
return Some(channel_id.to_owned()); return Ok(Some(channel_id.to_owned()));
} else { } else {
continue; continue;
} }
} }
} }
None Ok(None)
} }
} }
@ -127,6 +127,16 @@ fn as_regex_pattern(input: &str) -> Option<&str> {
input.strip_prefix('/')?.strip_suffix('/') input.strip_prefix('/')?.strip_suffix('/')
} }
pub trait BranchOpt {
fn as_str_or_default(&self) -> &str;
}
impl BranchOpt for Option<BranchName> {
fn as_str_or_default(&self) -> &str {
self.as_ref().map(|b| b.0.as_str()).unwrap_or_default()
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -143,37 +153,52 @@ mod test {
#[test] #[test]
fn test_parse_version() { fn test_parse_version() {
let ctx = AppContext { let ctx = AppContext {
binary_name: "cl".to_string(),
config: Default::default(), config: Default::default(),
root: PathBuf::from("/tmp/"), // will not be used root: PathBuf::from("/tmp/"), // will not be used
}; };
assert_eq!( assert_eq!(
BranchName("rel/3.14".to_string()).parse_version(&ctx), BranchName("rel/3.14".to_string())
.parse_version(&ctx)
.unwrap(),
Some("3.14".to_string()) Some("3.14".to_string())
); );
assert_eq!(BranchName("rel/foo".to_string()).parse_version(&ctx), None); assert_eq!(
BranchName("rel/foo".to_string())
.parse_version(&ctx)
.unwrap(),
None
);
} }
#[test] #[test]
fn test_parse_issue() { fn test_parse_issue() {
let ctx = AppContext { let ctx = AppContext {
binary_name: "cl".to_string(),
config: Default::default(), config: Default::default(),
root: PathBuf::from("/tmp/"), // will not be used root: PathBuf::from("/tmp/"), // will not be used
}; };
assert_eq!( assert_eq!(
BranchName("1234-bober-kurwa".to_string()).parse_issue(&ctx), BranchName("1234-bober-kurwa".to_string())
.parse_issue(&ctx)
.unwrap(),
Some("1234".to_string()) Some("1234".to_string())
); );
assert_eq!( assert_eq!(
BranchName("SW-778-jakie-bydłe-jebane".to_string()).parse_issue(&ctx), BranchName("SW-778-jakie-bydłe-jebane".to_string())
.parse_issue(&ctx)
.unwrap(),
Some("SW-778".to_string()) Some("SW-778".to_string())
); );
assert_eq!( assert_eq!(
BranchName("nie-spierdalaj-mordo".to_string()).parse_issue(&ctx), BranchName("nie-spierdalaj-mordo".to_string())
.parse_issue(&ctx)
.unwrap(),
None None
); );
} }
@ -181,22 +206,27 @@ mod test {
#[test] #[test]
fn test_parse_channel() { fn test_parse_channel() {
let ctx = AppContext { let ctx = AppContext {
binary_name: "cl".to_string(),
config: Default::default(), config: Default::default(),
root: PathBuf::from("/tmp/"), // will not be used root: PathBuf::from("/tmp/"), // will not be used
}; };
assert_eq!( assert_eq!(
BranchName("main".to_string()).parse_channel(&ctx), BranchName("main".to_string()).parse_channel(&ctx).unwrap(),
Some("default".to_string()) Some("default".to_string())
); );
assert_eq!( assert_eq!(
BranchName("master".to_string()).parse_channel(&ctx), BranchName("master".to_string())
.parse_channel(&ctx)
.unwrap(),
Some("default".to_string()) Some("default".to_string())
); );
assert_eq!( assert_eq!(
BranchName("my-cool-feature".to_string()).parse_version(&ctx), BranchName("my-cool-feature".to_string())
.parse_version(&ctx)
.unwrap(),
None None
); );
} }

@ -1,106 +1,56 @@
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::Config;
use crate::store::Store;
use anyhow::bail;
use clap::builder::NonEmptyStringValueParser; use clap::builder::NonEmptyStringValueParser;
use serde::{Deserialize, Serialize}; use colored::Colorize;
use smart_default::SmartDefault; use std::fs::OpenOptions;
use std::collections::HashMap; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
mod config;
mod git; mod git;
mod action_log; mod action_log;
mod action_pack; mod action_pack;
mod store;
#[derive(Debug)] #[derive(Debug)]
struct AppContext { pub struct AppContext {
config: Config, /// Name of the cl binary
pub binary_name: String,
root: PathBuf, /// Config loaded from file or defaults
} pub config: Config,
/// Main app configuration file /// Root of the project
#[derive(Debug, Serialize, Deserialize, SmartDefault)] pub root: PathBuf,
#[serde(deny_unknown_fields, default)]
struct Config {
/// 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"]
data_folder: String,
/// ID of the default channel - this only matters inside this config file
#[default = "default"]
default_channel: String,
/// 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"]
changelog_file_default: String,
/// 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
#[default = "CHANGELOG-{CHANNEL}.md"]
changelog_file_channel: String,
/// Changelog sections suggested when creating a new entry.
///
/// Users may also specify a custom section name.
///
/// Changelog entries under each section will be grouped in the packed changelog.
#[default(vec![
"New features".to_string(),
"Improvements".to_string(),
"Fixes".to_string(),
])]
sections: Vec<String>,
/// Changelog channels - how to identify them from git branch names
///
/// - Key - changelog ID; this can be used in the channel file name. Examples: default, eap, beta
/// - Value - git branch name to recognize the channel. This is a regex pattern.
///
/// At least one channel must be defined, with the name defined in `default_channel`
///
/// # Value format
/// For simple branch names without special symbols that do not change, e.g. `main`, `master`, `test`, you can just use the name as is.
/// 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/.*`
#[default(HashMap::from([
("default".to_string(), "/^(?:main|master)$/".to_string())
]))]
channels: HashMap<String, String>,
/// Regex pattern to extract issue number from a branch name.
/// There should be one capture group that is the number.
///
/// Example: `/^(SW-\d+)-.*$/` or `/^(\d+)-.*$/`
///
/// If None, no branch identification will be attempted.
#[default(Some(r"/^((?:SW-)?\d+)-.*/".to_string()))]
branch_issue_pattern: Option<String>,
/// Regex pattern to extract release number from a branch name.
/// There should be one capture group that is the version.
///
/// Example: `/^rel\/(\d+.\d+)$/`
///
/// If None, no branch identification will be attempted.
///
/// TODO attempt to parse version from package.json, composer.json, Cargo.toml and others
#[default(Some(r"/^rel\/(\d+\.\d+)$/".to_string()))]
branch_version_pattern: Option<String>,
} }
fn main() { fn main() {
let args = clap::Command::new("cl") if let Err(e) = main_try() {
eprintln!("{}", e.to_string().red().bold());
exit(1);
}
}
fn main_try() -> anyhow::Result<()> {
let binary_name = std::env::current_exe()
.map(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
.ok()
.flatten()
.unwrap_or_else(|| "cl".to_string());
let args = clap::Command::new(&binary_name)
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS")) .author(env!("CARGO_PKG_AUTHORS"))
.about(env!("CARGO_PKG_DESCRIPTION")) .about(env!("CARGO_PKG_DESCRIPTION"))
.subcommand(clap::Command::new("init")
.about("Create the changelog folder and the default config file in the current working directory, if they do not exist yet."))
.subcommand( .subcommand(
clap::Command::new("pack") clap::Command::new("pack")
.visible_alias("release") .visible_alias("release")
@ -130,19 +80,41 @@ fn main() {
let config_file_name: &str = specified_config_file.unwrap_or("clpack.toml"); let config_file_name: &str = specified_config_file.unwrap_or("clpack.toml");
eprintln!("Loading configuration from {}", config_file_name); // eprintln!("Loading configuration from {}", config_file_name);
let Ok(root) = std::env::current_dir() else { let Ok(root) = std::env::current_dir() else {
eprintln!("Failed to get current directory - is it deleted / inaccessible?"); bail!("Failed to get current directory - is it deleted / inaccessible?");
exit(1);
}; };
let config_path = if config_file_name.starts_with("/") { let config_path = root.join(&config_file_name); // if absolute, it is replaced by it
// It's an absolute path
PathBuf::from(config_file_name) if let Some(("init", _)) = args.subcommand() {
} else { let mut default_config = Config::default();
root.join(&config_file_name)
}; if !config_path.exists() {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.open(&config_path)?;
println!("Creating clpack config file");
file.write_all(toml::to_string_pretty(&default_config)?.as_bytes())?;
} else {
println!("Loading existing config file: {}", config_path.display());
let file_text = std::fs::read_to_string(&config_path)?;
default_config = toml::from_str(&file_text)?;
}
let ctx = AppContext {
binary_name,
config: default_config,
root,
};
let _ = Store::new(&ctx, true)?;
println!("{}", "Changelog initialized.".green());
return Ok(());
}
// Load and parse config // Load and parse config
@ -150,34 +122,38 @@ fn main() {
match toml::from_str(&config_file_content) { match toml::from_str(&config_file_content) {
Ok(config) => config, Ok(config) => config,
Err(e) => { Err(e) => {
eprintln!( bail!(
"Failed to parse config file ({}): {}", "Failed to parse config file ({}): {}",
config_path.display(), config_path.display(),
e e
); );
exit(1);
} }
} }
} else if specified_config_file.is_some() { } else if specified_config_file.is_some() {
// Failed to load config the user specifically asked for - make it an error // Failed to load config the user specifically asked for - make it an error
eprintln!("Failed to load config file at {}", config_path.display()); bail!("Failed to load config file at {}", config_path.display());
exit(1);
} else { } else {
Default::default() Default::default()
}; };
let ctx = AppContext { config, root }; let ctx = AppContext {
binary_name,
config,
root,
};
// eprintln!("AppCtx: {:?}", ctx); // eprintln!("AppCtx: {:?}", ctx);
match args.subcommand() { match args.subcommand() {
Some(("pack", subargs)) => { Some(("pack", subargs)) => {
let manual_channel = subargs.get_one::<String>("CHANNEL"); let manual_channel = subargs.get_one::<String>("CHANNEL");
cl_pack(ctx, manual_channel.map(String::as_str)); cl_pack(ctx, manual_channel.map(String::as_str))?;
} }
None | Some(("add", _)) => cl_log(ctx), None | Some(("add", _)) => cl_log(ctx)?,
Some((other, _)) => { Some((other, _)) => {
unimplemented!("Subcommand {other} is not implemented yet"); bail!("Subcommand {other} is not implemented yet");
} }
} }
Ok(())
} }

@ -0,0 +1,105 @@
use crate::AppContext;
use anyhow::bail;
use faccess::PathExt;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
pub struct Store<'a> {
ctx: &'a AppContext,
store_path: PathBuf,
}
impl<'a> Store<'a> {
pub fn new(ctx: &'a AppContext, init: bool) -> anyhow::Result<Self> {
let store_path = ctx.root.join(&ctx.config.data_folder);
if !store_path.is_dir() {
if init {
// Try to create it
eprintln!("Creating changelog dir: {}", store_path.display());
std::fs::create_dir_all(&store_path)?;
} else {
bail!(
"Changelog directory does not exist: {}. Use `{} init` to create it.",
ctx.binary_name,
store_path.display()
);
}
}
if !store_path.writable() {
bail!(
"Changelog directory is not writable: {}",
store_path.display()
);
}
let store = Self { store_path, ctx };
store.ensure_internal_subdirs_exist()?;
Ok(store)
}
/// Build a log entry file path.
/// This is a file in the entries storage
fn make_entry_path(&self, filename: &str) -> PathBuf {
self.store_path
.join("entries")
.join(format!("{filename}.md"))
}
/// 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.
pub fn entry_exists(&self, name: &str) -> bool {
let path = self.make_entry_path(name);
path.exists()
}
/// Check and create internal subdirs for the clpack system
pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> {
self.ensure_subdir_exists("entries")?;
// TODO
Ok(())
}
/// 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<()> {
let subdir = self.store_path.join(name);
if !subdir.is_dir() {
if subdir.exists() {
bail!(
"Changelog subdir path is clobbered, must be a writable directory or not exist (will be crated): {}",
subdir.display()
);
}
eprintln!("Creating changelog subdir: {}", subdir.display());
std::fs::create_dir_all(&subdir)?;
}
if !subdir.writable() {
bail!("Changelog subdir is not writable: {}", subdir.display());
}
std::fs::File::create(subdir.join(".gitkeep"))?;
Ok(())
}
/// Create a changelog entry file and write content to it
pub fn create_entry(&self, name: String, 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());
file.write_all(content.as_bytes())?;
Ok(())
}
}
Loading…
Cancel
Save