parent
8393eb03ee
commit
c00c6dfcd7
@ -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 |
||||||
|
@ -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(()) |
||||||
} |
} |
||||||
|
@ -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