use clap::{Parser, Subcommand, ValueEnum};
use log::error;
use simplelog::{ColorChoice, LevelFilter, TermLogger, TerminalMode};
use std::path::{Path, PathBuf};
use unclog::{Changelog, Config, Error, PlatformId, Result};
const RELEASE_SUMMARY_TEMPLATE: &str = r#"<!--
Add a summary for the release here.
If you don't change this message, or if this file is empty, the release
will not be created. -->
"#;
const ADD_CHANGE_TEMPLATE: &str = r#"<!--
Add your entry's details here (in Markdown format).
If you don't change this message, or if this file is empty, the entry will
not be created. -->
"#;
const DEFAULT_CHANGELOG_DIR: &str = ".changelog";
const DEFAULT_CONFIG_FILENAME: &str = "config.toml";
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Opt {
#[arg(short, long, default_value = DEFAULT_CHANGELOG_DIR)]
path: PathBuf,
#[arg(short, long, default_value = DEFAULT_CONFIG_FILENAME)]
config_file: PathBuf,
#[arg(short, long)]
verbose: bool,
#[arg(short, long)]
quiet: bool,
#[command(subcommand)]
cmd: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(name = "prologue", short, long)]
maybe_prologue_path: Option<PathBuf>,
#[arg(name = "epilogue", short, long)]
maybe_epilogue_path: Option<PathBuf>,
#[arg(short, long)]
gen_config: bool,
#[arg(short, long, default_value = "origin")]
remote: String,
},
GenerateConfig {
#[arg(short, long, default_value = "origin")]
remote: String,
#[arg(short, long)]
force: bool,
},
Add {
#[arg(long, env = "EDITOR")]
editor: PathBuf,
#[arg(name = "component", short, long)]
maybe_component: Option<String>,
#[arg(short, long)]
section: String,
#[arg(short, long)]
id: String,
#[arg(name = "issue_no", short = 'n', long = "issue-no")]
maybe_issue_no: Option<u32>,
#[arg(name = "pull_request", short, long = "pull-request")]
maybe_pull_request: Option<u32>,
#[arg(name = "message", short, long)]
maybe_message: Option<String>,
},
FindDuplicates {
#[arg(long)]
include_changelog_path: bool,
#[arg(value_enum, short, long, default_value = "simple")]
format: DuplicatesOutputFormat,
},
Build {
#[arg(short, long)]
all: bool,
#[arg(short, long)]
unreleased_only: bool,
},
Release {
#[arg(long, env = "EDITOR")]
editor: PathBuf,
version: String,
},
}
#[derive(Debug, Clone, Default, Copy, ValueEnum)]
enum DuplicatesOutputFormat {
#[default]
Simple,
AsciiTable,
}
fn main() {
let opt: Opt = Opt::parse();
TermLogger::init(
if opt.quiet {
LevelFilter::Off
} else if opt.verbose {
LevelFilter::Debug
} else {
LevelFilter::Info
},
Default::default(),
TerminalMode::Stderr,
ColorChoice::Auto,
)
.unwrap();
let config_path = if opt.config_file.is_relative() {
opt.path.join(opt.config_file)
} else {
opt.config_file
};
let config = Config::read_from_file(&config_path).unwrap();
let result = match opt.cmd {
Command::Init {
maybe_prologue_path,
maybe_epilogue_path,
gen_config,
remote,
} => init_changelog(
&config,
&opt.path,
&config_path,
maybe_prologue_path,
maybe_epilogue_path,
gen_config,
&remote,
),
Command::GenerateConfig { remote, force } => {
Changelog::generate_config(&config_path, opt.path, remote, force)
}
Command::Build {
all,
unreleased_only,
} => build_changelog(&config, &opt.path, all, unreleased_only),
Command::Add {
editor,
maybe_component,
section,
id,
maybe_issue_no,
maybe_pull_request,
maybe_message,
} => match maybe_message {
Some(message) => match maybe_issue_no {
Some(issue_no) => match maybe_pull_request {
Some(_) => Err(Error::EitherIssueNoOrPullRequest),
None => Changelog::add_unreleased_entry_from_template(
&config,
&opt.path,
§ion,
maybe_component,
&id,
PlatformId::Issue(issue_no),
&message,
),
},
None => match maybe_pull_request {
Some(pull_request) => Changelog::add_unreleased_entry_from_template(
&config,
&opt.path,
§ion,
maybe_component,
&id,
PlatformId::PullRequest(pull_request),
&message,
),
None => Err(Error::MissingIssueNoOrPullRequest),
},
},
None => add_unreleased_entry_with_editor(
&config,
&editor,
&opt.path,
§ion,
maybe_component,
&id,
),
},
Command::FindDuplicates {
include_changelog_path,
format,
} => find_duplicates(&config, &opt.path, include_changelog_path, format),
Command::Release { editor, version } => {
prepare_release(&config, &editor, &opt.path, &version)
}
};
if let Err(e) = result {
error!("Failed: {}", e);
std::process::exit(1);
}
}
fn init_changelog(
config: &Config,
path: &Path,
config_path: &Path,
maybe_prologue_path: Option<PathBuf>,
maybe_epilogue_path: Option<PathBuf>,
gen_config: bool,
remote: &str,
) -> Result<()> {
Changelog::init_dir(config, path, maybe_prologue_path, maybe_epilogue_path)?;
if gen_config {
Changelog::generate_config(config_path, path, remote, true)
} else {
Ok(())
}
}
fn build_changelog(config: &Config, path: &Path, all: bool, unreleased_only: bool) -> Result<()> {
if all && unreleased_only {
return Err(Error::CommandLine(
"cannot combine --all and --unreleased-only flags when building the changelog"
.to_string(),
));
}
let changelog = Changelog::read_from_dir(config, path)?;
log::info!("Success!");
if unreleased_only {
println!("{}", changelog.render_unreleased(config)?);
} else if all {
println!("{}", changelog.render_all(config));
} else {
println!("{}", changelog.render_released(config));
}
Ok(())
}
fn add_unreleased_entry_with_editor(
config: &Config,
editor: &Path,
path: &Path,
section: &str,
component: Option<String>,
id: &str,
) -> Result<()> {
let entry_path = Changelog::get_entry_path(
config,
path,
&config.unreleased.folder,
section,
component.clone(),
id,
);
if std::fs::metadata(&entry_path).is_ok() {
return Err(Error::FileExists(entry_path.display().to_string()));
}
let tmpdir =
tempfile::tempdir().map_err(|e| Error::Io(Path::new("tempdir").to_path_buf(), e))?;
let tmpfile_path = tmpdir.path().join("entry.md");
std::fs::write(&tmpfile_path, ADD_CHANGE_TEMPLATE)
.map_err(|e| Error::Io(tmpfile_path.clone(), e))?;
let _ = std::process::Command::new(editor)
.arg(&tmpfile_path)
.status()
.map_err(|e| Error::Subprocess(editor.to_str().unwrap().to_string(), e))?;
let tmpfile_content = std::fs::read_to_string(&tmpfile_path)
.map_err(|e| Error::Io(tmpfile_path.to_path_buf(), e))?;
if tmpfile_content.is_empty() || tmpfile_content == ADD_CHANGE_TEMPLATE {
log::info!("No changes to entry - not adding new entry to changelog");
return Ok(());
}
Changelog::add_unreleased_entry(config, path, section, component, id, &tmpfile_content)
}
fn find_duplicates(
config: &Config,
path: &Path,
include_changelog_path: bool,
output_format: DuplicatesOutputFormat,
) -> Result<()> {
let changelog = Changelog::read_from_dir(config, path)?;
let dups = changelog.find_duplicates_across_releases();
if dups.is_empty() {
log::info!("No duplicates found");
return Ok(());
}
log::info!("Found {} duplicate(s)", dups.len());
let mut table = comfy_table::Table::new();
table.load_preset(match output_format {
DuplicatesOutputFormat::Simple => comfy_table::presets::NOTHING,
DuplicatesOutputFormat::AsciiTable => comfy_table::presets::ASCII_FULL,
});
for (entry_path_a, entry_path_b) in dups {
let base_path = if include_changelog_path {
path.to_owned()
} else {
PathBuf::new()
};
table.add_row(vec![
base_path.join(entry_path_a.as_path(config)).display(),
base_path.join(entry_path_b.as_path(config)).display(),
]);
}
println!("{table}");
Ok(())
}
fn prepare_release(config: &Config, editor: &Path, path: &Path, version: &str) -> Result<()> {
let summary_path = path
.join(&config.unreleased.folder)
.join(&config.change_sets.summary_filename);
if std::fs::metadata(&summary_path).is_err() {
std::fs::write(&summary_path, RELEASE_SUMMARY_TEMPLATE)
.map_err(|e| Error::Io(summary_path.clone(), e))?;
}
let _ = std::process::Command::new(editor)
.arg(&summary_path)
.status()
.map_err(|e| Error::Subprocess(editor.to_str().unwrap().to_string(), e))?;
let summary_content =
std::fs::read_to_string(&summary_path).map_err(|e| Error::Io(summary_path.clone(), e))?;
if summary_content.is_empty() || summary_content == RELEASE_SUMMARY_TEMPLATE {
log::info!("No changes to release summary - not creating a new release");
return Ok(());
}
Changelog::prepare_release_dir(config, path, version)
}