mod ai;
mod changelog;
mod git;
mod ir;
mod nanoargs;
mod prompt;
#[cfg(test)]
mod testutil;
use std::io::{BufRead, Write};
use std::path::Path;
use std::process::ExitCode;
use nanoargs::{ArgBuilder, Flag, Opt, ParseError, ParseResult, Pos};
use prompt::{InputMode, Prompter};
/// Add the shared interactivity flags (and their conflict) to a subcommand.
fn with_input_flags(builder: ArgBuilder) -> ArgBuilder {
builder
.flag(
Flag::new("interactive")
.short('i')
.desc("Prompt for any missing values"),
)
.flag(
Flag::new("no-input")
.desc("Never prompt; fail if required values are missing (CI mode)"),
)
.conflict("input mode", &["interactive", "no-input"])
}
/// Build the `changelog` subcommand parser.
fn build_changelog_cmd() -> nanoargs::ArgParser {
with_input_flags(
ArgBuilder::new()
.name("eris changelog")
.description(
"Generate a user-facing changelog from a git range or from stored IR files, \
using AI.",
)
.option(
Opt::new("ai")
.placeholder("COMMAND")
.desc("Command that reads a prompt on stdin and writes the changelog to stdout")
.env("ERIS_AI")
.default(changelog::DEFAULT_AI_COMMAND),
)
.option(
Opt::new("repo")
.short('C')
.placeholder("PATH")
.desc("Path to the git repository (defaults to the current directory)")
.default("."),
)
.option(
Opt::new("ir")
.placeholder("FILE")
.desc(
"Render from one or more IR TOML files instead of a git range (repeatable)",
)
.multi(),
)
.positional(Pos::new("from").desc("The starting ref/tag/branch (exclusive)"))
.positional(Pos::new("to").desc("The ending ref/tag/branch (inclusive)")),
)
.build()
.expect("changelog parser schema is valid")
}
/// Build the `ir` subcommand parser.
fn build_ir_cmd() -> nanoargs::ArgParser {
with_input_flags(
ArgBuilder::new()
.name("eris ir")
.description("Build a reviewable changelog IR (TOML) from a git range.")
.option(
Opt::new("repo")
.short('C')
.placeholder("PATH")
.desc("Path to the git repository (defaults to the current directory)")
.default("."),
)
.option(
Opt::new("output")
.short('o')
.placeholder("FILE")
.desc("Write the IR to a file instead of stdout"),
)
.positional(Pos::new("from").desc("The starting ref/tag/branch (exclusive)"))
.positional(Pos::new("to").desc("The ending ref/tag/branch (inclusive)")),
)
.build()
.expect("ir parser schema is valid")
}
/// Build the top-level CLI parser.
fn build_cli() -> nanoargs::ArgParser {
ArgBuilder::new()
.name("eris")
.description("Generate user-facing changelogs from git history using AI.")
.version(env!("CARGO_PKG_VERSION"))
.flag(Flag::new("help").short('h').desc("Print help"))
.subcommand(
"ir",
"Build a reviewable changelog IR (TOML) from a git range",
build_ir_cmd(),
)
.subcommand(
"changelog",
"Generate a changelog from a git range or IR files",
build_changelog_cmd(),
)
.build()
.expect("root parser schema is valid")
}
/// Determine the [`InputMode`] from the interactivity flags.
fn input_mode(args: &ParseResult) -> InputMode {
if args.get_flag("no-input") {
InputMode::Never
} else if args.get_flag("interactive") {
InputMode::Always
} else {
InputMode::Auto
}
}
/// Resolve the `from`/`to` revision pair from positionals, prompting for any
/// that are missing when the prompter is interactive.
fn resolve_range<R: BufRead, W: Write>(
args: &ParseResult,
prompter: &mut Prompter<R, W>,
) -> Result<(String, String), String> {
let pos = args.get_positionals();
let from = pos.first().cloned();
let to = pos.get(1).cloned();
if let (Some(f), Some(t)) = (&from, &to) {
return Ok((f.clone(), t.clone()));
}
if !prompter.interactive() {
return Err("provide <from> and <to>, or run with -i to be prompted.".to_string());
}
let from = match from {
Some(f) => f,
None => prompter
.require("from (starting ref/tag, exclusive)", None)
.map_err(|e| e.to_string())?,
};
let to = match to {
Some(t) => t,
None => prompter
.require("to (ending ref/tag, inclusive)", Some("HEAD"))
.map_err(|e| e.to_string())?,
};
Ok((from, to))
}
fn main() -> ExitCode {
let parser = build_cli();
let result = match parser.parse_env() {
Ok(r) => r,
Err(ParseError::HelpRequested(text) | ParseError::VersionRequested(text)) => {
println!("{text}");
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
match result.subcommand() {
Some("ir") => run_ir(
result
.subcommand_result()
.expect("ir subcommand has a result"),
),
Some("changelog") => run_changelog(
result
.subcommand_result()
.expect("changelog subcommand has a result"),
),
_ => {
eprintln!(
"no subcommand provided. Try `eris changelog <from> <to>` or `eris ir <from> <to>`."
);
ExitCode::FAILURE
}
}
}
/// Handle the `ir` subcommand: build IR TOML from a git range.
fn run_ir(args: &ParseResult) -> ExitCode {
let mut prompter = prompt::stdio(input_mode(args));
let (from, to) = match resolve_range(args, &mut prompter) {
Ok(pair) => pair,
Err(msg) => {
eprintln!("error: {msg}");
return ExitCode::FAILURE;
}
};
let repo = args.get_option("repo").unwrap_or(".");
let changes = match git::collect_changes(Path::new(repo), &from, &to) {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let toml = ir::Ir::from_changeset(&changes).to_toml();
if let Some(path) = args.get_option("output") {
if let Err(e) = std::fs::write(path, &toml) {
eprintln!("error: failed to write '{path}': {e}");
return ExitCode::FAILURE;
}
eprintln!("Wrote IR for {from}..{to} to {path}");
} else {
print!("{toml}");
}
ExitCode::SUCCESS
}
/// Handle the `changelog` subcommand.
fn run_changelog(args: &ParseResult) -> ExitCode {
let mut prompter = prompt::stdio(input_mode(args));
let ai_default = args
.get_option("ai")
.unwrap_or(changelog::DEFAULT_AI_COMMAND);
let ir_files = args.get_option_values("ir");
let result = if ir_files.is_empty() {
let (from, to) = match resolve_range(args, &mut prompter) {
Ok(pair) => pair,
Err(msg) => {
eprintln!("error: {msg}");
return ExitCode::FAILURE;
}
};
let ai_command = match resolve_ai(&mut prompter, ai_default) {
Ok(cmd) => cmd,
Err(msg) => {
eprintln!("error: {msg}");
return ExitCode::FAILURE;
}
};
let repo = args.get_option("repo").unwrap_or(".");
eprintln!("Generating changelog for {from}..{to} using AI command: {ai_command}");
changelog::generate_from_refs(Path::new(repo), &from, &to, &ai_command)
} else {
let ai_command = match resolve_ai(&mut prompter, ai_default) {
Ok(cmd) => cmd,
Err(msg) => {
eprintln!("error: {msg}");
return ExitCode::FAILURE;
}
};
let irs = match load_ir_files(ir_files) {
Ok(irs) => irs,
Err(code) => return code,
};
eprintln!(
"Generating changelog from {} IR file(s) using AI command: {ai_command}",
ir_files.len()
);
changelog::generate_from_irs(irs, &ai_command)
};
match result {
Ok(changelog) => {
println!("{changelog}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Resolve the AI command, offering the current default for confirmation when
/// running interactively.
fn resolve_ai<R: BufRead, W: Write>(
prompter: &mut Prompter<R, W>,
default: &str,
) -> Result<String, String> {
if prompter.interactive() {
prompter
.require("AI command", Some(default))
.map_err(|e| e.to_string())
} else {
Ok(default.to_string())
}
}
/// Load and parse a list of IR TOML files, preserving order.
fn load_ir_files(paths: &[String]) -> Result<Vec<ir::Ir>, ExitCode> {
let mut irs = Vec::with_capacity(paths.len());
for path in paths {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!("error: failed to read IR file '{path}': {e}");
return Err(ExitCode::FAILURE);
}
};
match ir::Ir::from_toml(&text) {
Ok(ir) => irs.push(ir),
Err(e) => {
eprintln!("error: failed to parse IR file '{path}': {e}");
return Err(ExitCode::FAILURE);
}
}
}
Ok(irs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
/// Build a changelog `ParseResult` for testing `resolve_range`.
fn parse_changelog(args: &[&str]) -> ParseResult {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
build_changelog_cmd()
.parse(&owned)
.expect("args parse cleanly")
}
fn test_prompter(input: &str, interactive: bool) -> Prompter<Cursor<Vec<u8>>, Vec<u8>> {
Prompter::new(
Cursor::new(input.as_bytes().to_vec()),
Vec::new(),
interactive,
)
}
#[test]
fn input_mode_flags() {
assert_eq!(input_mode(&parse_changelog(&["a", "b"])), InputMode::Auto);
assert_eq!(
input_mode(&parse_changelog(&["-i", "a", "b"])),
InputMode::Always
);
assert_eq!(
input_mode(&parse_changelog(&["--no-input", "a", "b"])),
InputMode::Never
);
}
#[test]
fn resolve_range_uses_positionals() {
let args = parse_changelog(&["v1", "v2"]);
let mut p = test_prompter("", false);
assert_eq!(
resolve_range(&args, &mut p).unwrap(),
("v1".to_string(), "v2".to_string())
);
}
#[test]
fn resolve_range_non_interactive_missing_errors() {
let args = parse_changelog(&[]);
let mut p = test_prompter("", false);
assert!(resolve_range(&args, &mut p).is_err());
}
#[test]
fn resolve_range_prompts_for_both_when_missing() {
let args = parse_changelog(&[]);
let mut p = test_prompter("v1\nv2\n", true);
assert_eq!(
resolve_range(&args, &mut p).unwrap(),
("v1".to_string(), "v2".to_string())
);
}
#[test]
fn resolve_range_prompts_only_for_missing_to() {
// Only `from` given as a positional; `to` should be prompted.
let args = parse_changelog(&["v1"]);
let mut p = test_prompter("release\n", true);
assert_eq!(
resolve_range(&args, &mut p).unwrap(),
("v1".to_string(), "release".to_string())
);
}
#[test]
fn resolve_range_to_defaults_to_head_on_empty() {
let args = parse_changelog(&["v1"]);
let mut p = test_prompter("\n", true);
assert_eq!(
resolve_range(&args, &mut p).unwrap(),
("v1".to_string(), "HEAD".to_string())
);
}
#[test]
fn resolve_ai_non_interactive_uses_default() {
let mut p = test_prompter("", false);
assert_eq!(resolve_ai(&mut p, "opencode -p").unwrap(), "opencode -p");
}
#[test]
fn resolve_ai_interactive_reads_override() {
let mut p = test_prompter("llm -m gpt-4o\n", true);
assert_eq!(resolve_ai(&mut p, "opencode -p").unwrap(), "llm -m gpt-4o");
}
#[test]
fn resolve_ai_interactive_empty_keeps_default() {
let mut p = test_prompter("\n", true);
assert_eq!(resolve_ai(&mut p, "opencode -p").unwrap(), "opencode -p");
}
}