malvin 0.2.5

Non-interactive research and coding agent
use clap::Args;

use crate::cli::SharedOpts;

pub(crate) mod prep;
mod run;

pub use run::run_write;

#[derive(Args, Debug, Clone)]
#[command(override_usage = "malvin write [OPTION]... [REQUEST]")]
pub struct WriteArgs {
    #[command(flatten)]
    pub shared: SharedOpts,
    /// Existing `.md` path or literal text describing what to write about
    pub request: Option<String>,
    /// Workspace path for the LaTeX output (PDF uses the same stem with `.pdf`)
    #[arg(long, default_value = "write.tex")]
    pub out_path: String,
    /// Outer loop budget (CLI compatibility; write uses aggregated initial + `write_b`)
    #[arg(long, default_value_t = crate::malvin_config_file::DEFAULT_MAX_LOOPS_CODE)]
    pub max_loops: usize,
    /// Hypothesis budget
    #[arg(long, default_value_t = crate::malvin_config_file::DEFAULT_WRITE_MAX_HYPOTHESES)]
    pub max_hypotheses: usize,
    /// Expand to `--max-acp-retries=9999` and `--max-loops=9999`
    #[arg(long, default_value_t = crate::cli::loop_opts::DEFAULT_TENACIOUS)]
    pub tenacious: bool,
    #[arg(skip)]
    pub out_path_explicit: bool,
}

#[cfg(test)]
mod tests {
    use crate::cli::args::{Cli, Commands};
    use clap::{CommandFactory, FromArgMatches, Parser};

    #[test]
    fn write_args_default_out_path_is_write_tex() {
        let cli = Cli::try_parse_from(["malvin", "write", "topic"]).expect("parse");
        match cli.command {
            Some(Commands::Write(e)) => assert_eq!(e.out_path, "write.tex"),
            other => panic!("expected Write, got {other:?}"),
        }
    }

    #[test]
    fn write_out_path_flag_overrides_default() {
        let cli = Cli::try_parse_from(["malvin", "write", "topic", "--out-path", "docs/paper.tex"])
            .expect("parse");
        match cli.command {
            Some(Commands::Write(e)) => assert_eq!(e.out_path, "docs/paper.tex"),
            other => panic!("expected Write, got {other:?}"),
        }
    }

    #[test]
    fn write_out_path_accepts_equals_form() {
        let cli = Cli::try_parse_from(["malvin", "write", "topic", "--out-path=docs/paper.tex"])
            .expect("parse");
        match cli.command {
            Some(Commands::Write(e)) => assert_eq!(e.out_path, "docs/paper.tex"),
            other => panic!("expected Write, got {other:?}"),
        }
    }

    #[test]
    fn write_parses_request_positional() {
        let cli = Cli::try_parse_from(["malvin", "write", "how it works"]).expect("parse");
        match cli.command {
            Some(Commands::Write(e)) => assert_eq!(e.request.as_deref(), Some("how it works")),
            other => panic!("expected Write, got {other:?}"),
        }
    }

    #[test]
    fn write_rejects_extra_positional() {
        assert!(Cli::try_parse_from(["malvin", "write", "a", "b"]).is_err());
    }

    #[test]
    fn help_lists_write_subcommand() {
        let help = Cli::command().render_help().to_string();
        assert!(help.contains("write"));
    }

    #[test]
    fn write_tenacious_expands_loops_and_retries() {
        use crate::cli::loop_opts::{
            GateLoopTenaciousApply, TENACIOUS_MAX_ACP_RETRIES, TENACIOUS_MAX_LOOPS,
            apply_gate_loop_tenacious,
        };
        let matches = Cli::command().get_matches_from(["malvin", "write", "topic", "--tenacious"]);
        let cli = Cli::from_arg_matches(&matches).expect("parse");
        let Some(Commands::Write(mut write_args)) = cli.command else {
            panic!("expected Write");
        };
        let mut shared = cli.shared;
        apply_gate_loop_tenacious(GateLoopTenaciousApply {
            subcommand: "write",
            max_loops: &mut write_args.max_loops,
            tenacious: write_args.tenacious,
            no_tenacious: shared.no_tenacious,
            max_acp_retries: &mut shared.max_acp_retries,
            matches: &matches,
        });
        assert_eq!(write_args.max_loops, TENACIOUS_MAX_LOOPS);
        assert_eq!(shared.max_acp_retries, TENACIOUS_MAX_ACP_RETRIES);
    }
}