parent
8393eb03ee
commit
c00c6dfcd7
@ -1,2 +1,6 @@ |
||||
/target |
||||
.idea/ |
||||
|
||||
# Remove when the lib is stable enough and useful to keep our own changelog... |
||||
changelog |
||||
clpack.toml |
||||
|
@ -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(()) |
||||
} |
||||
|
@ -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>, |
||||
} |
@ -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…
Reference in new issue