Skip to content
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");
    }
}