#![forbid(unsafe_code)]
use std::{
fs,
io::{self, Read, Write},
};
use anyhow::{bail, Context as _, Result};
use lexopt::{
Arg::{Long, Short, Value},
ValueExt,
};
use parse_changelog::Parser;
static USAGE: &str = "parse-changelog
Simple changelog parser, written in Rust.
Parses changelog and returns a release note for the specified version.
USAGE:
parse-changelog [OPTIONS] <PATH> [VERSION]
ARGS:
<PATH> Path to the changelog file (use '-' for standard input)
[VERSION] Specify version (by default, select the latest release)
OPTIONS:
-t, --title Returns title instead of notes
--title-no-link Similar to --title, but remove links from title
--json Returns JSON representation of all releases in changelog
--version-format <PATTERN> Specify version format
--prefix-format <PATTERN> Specify prefix format [aliases: prefix]
-h, --help Print help information
-V, --version Print version information
";
struct Args {
path: String,
release: Option<String>,
title: bool,
title_no_link: bool,
json: bool,
version_format: Option<String>,
prefix_format: Option<String>,
}
impl Args {
fn parse() -> Result<Self> {
let mut path = None;
let mut release = None;
let mut title = false;
let mut title_no_link = false;
let mut json = false;
let mut version_format = None;
let mut prefix_format = None;
let mut parser = lexopt::Parser::from_env();
while let Some(arg) = parser.next()? {
match arg {
Short('t') | Long("title") if !title && !title_no_link => title = true,
Long("title-no-link") if !title && !title_no_link => title_no_link = true,
Long("json") if !json => json = true,
Long("version-format") if version_format.is_none() => {
version_format = Some(parser.value()?.parse()?);
}
Long("prefix-format" | "prefix") if prefix_format.is_none() => {
prefix_format = Some(parser.value()?.parse()?);
}
Short('h') | Long("help") => {
print!("{USAGE}");
std::process::exit(0);
}
Short('V') | Long("version") => {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
Value(val) if path.is_none() => path = Some(val.parse()?),
Value(val) if release.is_none() => release = Some(val.parse()?),
_ => return Err(arg.unexpected().into()),
}
}
let Some(path) = path else { bail!("no changelog path specified") };
Ok(Self { path, release, title, title_no_link, json, version_format, prefix_format })
}
}
fn main() {
if let Err(e) = try_main() {
eprintln!("error: {e:#}");
std::process::exit(1)
}
}
fn try_main() -> Result<()> {
let args = Args::parse()?;
let mut parser = Parser::new();
if let Some(version_format) = &args.version_format {
parser.version_format(version_format)?;
}
if let Some(prefix_format) = &args.prefix_format {
parser.prefix_format(prefix_format)?;
}
let text = if args.path == "-" {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).context("failed to read standard input")?;
buf
} else {
fs::read_to_string(&args.path).with_context(|| format!("failed to read {}", args.path))?
};
let changelog = parser.parse(&text)?;
if args.json {
let stdout = io::stdout();
let mut stdout = stdout.lock();
serde_json::to_writer(&mut stdout, &changelog)?;
stdout.flush()?;
return Ok(());
}
let release = if let Some(version) = args.release.as_deref() {
if let Some(release) = changelog.get(version) {
release
} else {
bail!("not found release note for '{}'", version);
}
} else {
let (entry_key, entry_value) = changelog.first().context("not found release")?;
if entry_key == &"Unreleased" {
(
changelog.get_index(1).with_context(||
format!("not found release; to get 'Unreleased' section specify release explicitly: `parse-changelog {} Unreleased`", args.path)
)?
).1
} else {
entry_value
}
};
let text = if args.title {
release.title.into()
} else if args.title_no_link {
release.title_no_link()
} else {
release.notes.into()
};
let stdout = io::stdout();
let mut stdout = stdout.lock();
stdout.write_all(text.as_bytes())?;
stdout.write_all(b"\n")?;
stdout.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::{
env, fs,
io::Write,
path::Path,
process::{Command, Stdio},
};
use anyhow::Result;
use crate::USAGE;
#[track_caller]
fn assert_diff(expected_path: impl AsRef<Path>, actual: impl AsRef<str>) {
let actual = actual.as_ref();
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest_dir =
manifest_dir.strip_prefix(env::current_dir().unwrap()).unwrap_or(manifest_dir);
let expected_path = &manifest_dir.join(expected_path);
if !expected_path.is_file() {
fs::write(expected_path, "").unwrap();
}
let expected = fs::read_to_string(expected_path).unwrap();
if expected != actual {
if env::var_os("CI").is_some() {
let mut child = Command::new("git")
.args(["--no-pager", "diff", "--no-index", "--"])
.arg(expected_path)
.arg("-")
.stdin(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(actual.as_bytes()).unwrap();
assert!(!child.wait().unwrap().success());
panic!("assertion failed; please run test locally and commit resulting changes, or apply above diff as patch");
} else {
fs::write(expected_path, actual).unwrap();
}
}
}
#[test]
fn update_readme() -> Result<()> {
let new = USAGE;
let path = &Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md");
let base = fs::read_to_string(path)?;
let mut out = String::with_capacity(base.capacity());
let mut lines = base.lines();
let mut start = false;
let mut end = false;
while let Some(line) = lines.next() {
out.push_str(line);
out.push('\n');
if line == "<!-- readme-long-help:start -->" {
start = true;
out.push_str("```console\n");
out.push_str("$ parse-changelog --help\n");
out.push_str(new);
for line in &mut lines {
if line == "<!-- readme-long-help:end -->" {
out.push_str("```\n");
out.push_str(line);
out.push('\n');
end = true;
break;
}
}
}
}
if start && end {
assert_diff(path, out);
} else if start {
panic!("missing `<!-- readme-long-help:end -->` comment in README.md");
} else {
panic!("missing `<!-- readme-long-help:start -->` comment in README.md");
}
Ok(())
}
}