import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const DEFAULT_CHANGELOG = 'CHANGELOG.md';
const DEFAULT_CARGO_TOML = path.join('src-tauri', 'Cargo.toml');
const RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
interface CliOptions {
changelog?: string;
help?: boolean;
out?: string;
tag?: string;
}
export function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const nextValue = () => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${arg}`);
}
index += 1;
return value;
};
if (arg === '--') {
continue;
} else if (arg === '--tag') {
options.tag = nextValue();
} else if (arg.startsWith('--tag=')) {
options.tag = arg.slice('--tag='.length);
} else if (arg === '--out') {
options.out = nextValue();
} else if (arg.startsWith('--out=')) {
options.out = arg.slice('--out='.length);
} else if (arg === '--changelog') {
options.changelog = nextValue();
} else if (arg.startsWith('--changelog=')) {
options.changelog = arg.slice('--changelog='.length);
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function readVersionFromCargoToml(cargoTomlPath = DEFAULT_CARGO_TOML): string {
const data = fs.readFileSync(cargoTomlPath, 'utf8');
const match = data.match(/^version = "([^"]+)"/m);
if (!match?.[1]) {
throw new Error(`Could not find version in ${cargoTomlPath}`);
}
return `v${match[1]}`;
}
export function resolveReleaseNotes(
tag: string,
changelogPath = DEFAULT_CHANGELOG,
): string {
if (!RELEASE_TAG_RE.test(tag)) {
throw new Error(`Invalid release tag: ${tag}`);
}
const data = fs.readFileSync(changelogPath, 'utf8');
const lines = data.split(/\r?\n/);
const releases = new Map<string, string[]>();
let currentTag = '';
for (const line of lines) {
const heading = line.match(/^##\s+(v\d+\.\d+\.\d+(?:[-+][\w.-]+)?)\s*$/);
if (heading?.[1]) {
currentTag = heading[1];
if (releases.has(currentTag)) {
throw new Error(`Duplicate changelog section: ${currentTag}`);
}
releases.set(currentTag, []);
continue;
}
if (/^---\s*$/.test(line)) {
currentTag = '';
continue;
}
if (currentTag) {
releases.get(currentTag)?.push(line);
}
}
if (!releases.has(tag)) {
throw new Error(`Could not find ${tag} in ${changelogPath}`);
}
const notes = releases.get(tag)?.join('\n').trim();
if (!notes) {
throw new Error(`Changelog section is empty: ${tag}`);
}
return notes;
}
function printHelp(): void {
console.log(`Usage: pnpm release-notes -- --tag v1.0.0 --out release-notes.md
Options:
--tag Release tag to extract. Defaults to src-tauri/Cargo.toml version.
--out Output file. Defaults to stdout.
--changelog Changelog file. Defaults to CHANGELOG.md.`);
}
function main(): void {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const tag = options.tag ?? readVersionFromCargoToml();
const notes = `${resolveReleaseNotes(tag, options.changelog)}\n`;
if (options.out) {
fs.writeFileSync(options.out, notes, 'utf8');
console.log(`Wrote ${options.out} from ${tag}`);
} else {
process.stdout.write(notes);
}
}
function isEntrypoint(): boolean {
return process.argv[1]
? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
: false;
}
if (isEntrypoint()) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}