From c00c6dfcd72016bfb148aa463ecf799f72b50382 Mon Sep 17 00:00:00 2001 From: ondra Date: Sat, 13 Sep 2025 15:16:57 +0200 Subject: [PATCH] MVP "log" action added --- .gitignore | 4 + Cargo.lock | 122 ++++++++++++++++++++++++++++++- Cargo.toml | 7 +- src/action_log.rs | 102 +++++++++++++++++++++++++- src/action_pack.rs | 7 +- src/config.rs | 81 +++++++++++++++++++++ src/git.rs | 112 +++++++++++++++++----------- src/main.rs | 178 ++++++++++++++++++++------------------------- src/store.rs | 105 ++++++++++++++++++++++++++ 9 files changed, 566 insertions(+), 152 deletions(-) create mode 100644 src/config.rs create mode 100644 src/store.rs diff --git a/.gitignore b/.gitignore index c403c34..627fd0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /target .idea/ + +# Remove when the lib is stable enough and useful to keep our own changelog... +changelog +clpack.toml diff --git a/Cargo.lock b/Cargo.lock index f55c62d..44f29ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "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]] name = "autocfg" version = "1.5.0" @@ -122,7 +128,10 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clpack" version = "0.1.0" dependencies = [ + "anyhow", "clap", + "colored", + "faccess", "inquire", "regex", "serde", @@ -138,6 +147,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "crossterm" version = "0.25.0" @@ -175,6 +193,33 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fuzzy-matcher" version = "0.3.7" @@ -193,6 +238,18 @@ dependencies = [ "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]] name = "hashbrown" version = "0.15.5" @@ -222,6 +279,7 @@ dependencies = [ "fxhash", "newline-converter", "once_cell", + "tempfile", "unicode-segmentation", "unicode-width", ] @@ -244,6 +302,12 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "lock_api" version = "0.4.13" @@ -274,7 +338,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.48.0", ] @@ -340,6 +404,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "redox_syscall" version = "0.5.17" @@ -378,6 +448,19 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ryu" version = "1.0.20" @@ -495,6 +578,19 @@ dependencies = [ "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]] name = "thiserror" 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" 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]] name = "winapi" version = "0.3.9" @@ -830,3 +944,9 @@ name = "winnow" version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c573471f125075647d03df72e026074b7203790d41351cd6edc96f46bcccd36" diff --git a/Cargo.toml b/Cargo.toml index ab3644c..f93d002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,15 +6,18 @@ authors = ["Ondřej Hruška "] description = "Manage changelog across multiple release channels" [dependencies] -clap = "4.5" +clap = { version = "4.5", features = ["string"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.9" smart-default = "0.7" regex = "1" +anyhow = "1" +colored = "3" +faccess = "0.2" # input # sadly this looks mostly abandoned. Alternative is "dialoguer" -inquire = "0.7.5" +inquire = { version = "0.7.5", features = ["editor"] } diff --git a/src/action_log.rs b/src/action_log.rs index f3ffdd6..945db4e 100644 --- a/src/action_log.rs +++ b/src/action_log.rs @@ -1,11 +1,105 @@ use crate::AppContext; +use crate::git::BranchOpt; 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 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(()) } diff --git a/src/action_pack.rs b/src/action_pack.rs index 497d5cc..0289fa7 100644 --- a/src/action_pack.rs +++ b/src/action_pack.rs @@ -1,10 +1,11 @@ use crate::AppContext; 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 channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).flatten(); - let version = branch.as_ref().map(|b| b.parse_version(&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)).transpose()?.flatten(); eprintln!( "Branch name: {:?}, channel: {:?}, version: {:?}", diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..5d2cf84 --- /dev/null +++ b/src/config.rs @@ -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, + + /// 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, + + /// 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, + + /// 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, +} diff --git a/src/git.rs b/src/git.rs index e0502ac..3be027a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,7 +1,7 @@ use crate::AppContext; +use anyhow::bail; use std::fmt::Display; use std::fmt::Formatter; -use std::process::exit; #[derive(Debug, Clone)] pub struct BranchName(pub String); @@ -39,86 +39,86 @@ impl BranchName { /// /// pat_s - the regex pattern /// 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 { + fn parse_using_regex( + &self, + template: &str, + regex_conf_name: &str, + ) -> anyhow::Result> { 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}" ); - exit(1); - //return None; }; let pat = match regex::Regex::new(pat_s) { Ok(pat) => pat, Err(e) => { - eprintln!("Invalid regex in \"{regex_conf_name}\": {pat_s}"); - eprintln!("Error: {e}"); - exit(1); - //return None; + bail!("Invalid regex in \"{regex_conf_name}\": {pat_s}\nError: {e}"); } }; let num_captures = pat.captures_len(); if num_captures != 2 { - eprintln!("The pattern \"{regex_conf_name}\" is not applicable: {pat_s}"); - eprintln!( - "There must be exactly one capturing group. Found {}", + // capture "0" is the whole matched string + bail!( + "The pattern \"{regex_conf_name}\" is not applicable: {pat_s}\nThere must be exactly one capturing group. Found {}", num_captures - 1 ); - exit(1); - //return None; } - let matches = pat.captures(&self.0)?; - Some(matches.get(1)?.as_str().to_owned()) + let Some(matches) = pat.captures(&self.0) else { + 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. /// /// Aborts if the configured regex pattern is invalid. - pub fn parse_version(&self, ctx: &AppContext) -> Option { - self.parse_using_regex( - ctx.config.branch_version_pattern.as_ref()?, - "branch_version_pattern", - ) + pub fn parse_version(&self, ctx: &AppContext) -> anyhow::Result> { + let Some(pat) = ctx.config.branch_version_pattern.as_ref() else { + return Ok(None); + }; + self.parse_using_regex(pat, "branch_version_pattern") } /// Parse issue number from this branch name. /// /// Aborts if the configured regex pattern is invalid. - pub fn parse_issue(&self, ctx: &AppContext) -> Option { - self.parse_using_regex( - ctx.config.branch_issue_pattern.as_ref()?, - "branch_issue_pattern", - ) + pub fn parse_issue(&self, ctx: &AppContext) -> anyhow::Result> { + let Some(pat) = ctx.config.branch_issue_pattern.as_ref() else { + return Ok(None); + }; + self.parse_using_regex(pat, "branch_issue_pattern") } /// Try to detect a release channel from this branch name (e.g. stable, EAP) - pub fn parse_channel(&self, ctx: &AppContext) -> Option { + pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result> { for (channel_id, template) in &ctx.config.channels { if let Some(pat_s) = as_regex_pattern(template) { let pat = match regex::Regex::new(pat_s) { Ok(pat) => pat, Err(e) => { - eprintln!("Invalid regex for channel \"{channel_id}\": {template}"); - eprintln!("Error: {e}"); - exit(1); + bail!("Invalid regex for channel \"{channel_id}\": {template}\nError: {e}"); } }; if pat.is_match(&self.0) { - return Some(channel_id.to_owned()); + return Ok(Some(channel_id.to_owned())); } } else { // No regex - match it verbatim if &self.0 == template { - return Some(channel_id.to_owned()); + return Ok(Some(channel_id.to_owned())); } else { continue; } } } - None + Ok(None) } } @@ -127,6 +127,16 @@ fn as_regex_pattern(input: &str) -> Option<&str> { input.strip_prefix('/')?.strip_suffix('/') } +pub trait BranchOpt { + fn as_str_or_default(&self) -> &str; +} + +impl BranchOpt for Option { + fn as_str_or_default(&self) -> &str { + self.as_ref().map(|b| b.0.as_str()).unwrap_or_default() + } +} + #[cfg(test)] mod test { use super::*; @@ -143,37 +153,52 @@ mod test { #[test] fn test_parse_version() { let ctx = AppContext { + binary_name: "cl".to_string(), config: Default::default(), root: PathBuf::from("/tmp/"), // will not be used }; 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()) ); - assert_eq!(BranchName("rel/foo".to_string()).parse_version(&ctx), None); + assert_eq!( + BranchName("rel/foo".to_string()) + .parse_version(&ctx) + .unwrap(), + None + ); } #[test] fn test_parse_issue() { let ctx = AppContext { + binary_name: "cl".to_string(), config: Default::default(), root: PathBuf::from("/tmp/"), // will not be used }; 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()) ); 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()) ); assert_eq!( - BranchName("nie-spierdalaj-mordo".to_string()).parse_issue(&ctx), + BranchName("nie-spierdalaj-mordo".to_string()) + .parse_issue(&ctx) + .unwrap(), None ); } @@ -181,22 +206,27 @@ mod test { #[test] fn test_parse_channel() { let ctx = AppContext { + binary_name: "cl".to_string(), config: Default::default(), root: PathBuf::from("/tmp/"), // will not be used }; assert_eq!( - BranchName("main".to_string()).parse_channel(&ctx), + BranchName("main".to_string()).parse_channel(&ctx).unwrap(), Some("default".to_string()) ); assert_eq!( - BranchName("master".to_string()).parse_channel(&ctx), + BranchName("master".to_string()) + .parse_channel(&ctx) + .unwrap(), Some("default".to_string()) ); assert_eq!( - BranchName("my-cool-feature".to_string()).parse_version(&ctx), + BranchName("my-cool-feature".to_string()) + .parse_version(&ctx) + .unwrap(), None ); } diff --git a/src/main.rs b/src/main.rs index ce84813..a3beacd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,106 +1,56 @@ use crate::action_log::cl_log; use crate::action_pack::cl_pack; +use crate::config::Config; +use crate::store::Store; +use anyhow::bail; use clap::builder::NonEmptyStringValueParser; -use serde::{Deserialize, Serialize}; -use smart_default::SmartDefault; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use colored::Colorize; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; use std::process::exit; +mod config; + mod git; mod action_log; mod action_pack; +mod store; + #[derive(Debug)] -struct AppContext { - config: Config, +pub struct AppContext { + /// Name of the cl binary + pub binary_name: String, - root: PathBuf, -} + /// Config loaded from file or defaults + pub config: Config, -/// Main app configuration file -#[derive(Debug, Serialize, Deserialize, SmartDefault)] -#[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, - - /// 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, - - /// 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, - - /// 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, + /// Root of the project + pub root: PathBuf, } 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")) .author(env!("CARGO_PKG_AUTHORS")) .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( clap::Command::new("pack") .visible_alias("release") @@ -130,19 +80,41 @@ fn main() { 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 { - eprintln!("Failed to get current directory - is it deleted / inaccessible?"); - exit(1); + bail!("Failed to get current directory - is it deleted / inaccessible?"); }; - let config_path = if config_file_name.starts_with("/") { - // It's an absolute path - PathBuf::from(config_file_name) - } else { - root.join(&config_file_name) - }; + let config_path = root.join(&config_file_name); // if absolute, it is replaced by it + + if let Some(("init", _)) = args.subcommand() { + let mut default_config = Config::default(); + + 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 @@ -150,34 +122,38 @@ fn main() { match toml::from_str(&config_file_content) { Ok(config) => config, Err(e) => { - eprintln!( + bail!( "Failed to parse config file ({}): {}", config_path.display(), e ); - exit(1); } } } else if specified_config_file.is_some() { // Failed to load config the user specifically asked for - make it an error - eprintln!("Failed to load config file at {}", config_path.display()); - exit(1); + bail!("Failed to load config file at {}", config_path.display()); } else { Default::default() }; - let ctx = AppContext { config, root }; + let ctx = AppContext { + binary_name, + config, + root, + }; // 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)); + cl_pack(ctx, manual_channel.map(String::as_str))?; } - None | Some(("add", _)) => cl_log(ctx), + None | Some(("add", _)) => cl_log(ctx)?, Some((other, _)) => { - unimplemented!("Subcommand {other} is not implemented yet"); + bail!("Subcommand {other} is not implemented yet"); } } + + Ok(()) } diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..9147a3d --- /dev/null +++ b/src/store.rs @@ -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 { + 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(()) + } +}