mod amend;
mod check;
mod create_pr;
pub(crate) mod formatting;
mod info;
mod staged;
mod twiddle;
mod view;
pub use amend::{run_amend, AmendCommand, AmendOutcome};
pub use check::{run_check, CheckCommand, CheckOutcome};
pub use create_pr::{run_create_pr, CreatePrCommand, CreatePrOutcome, PrContent};
pub use info::{run_info, InfoCommand};
pub use staged::{run_staged, StagedCommand, StagedOutcome};
pub use twiddle::{run_twiddle, TwiddleCommand, TwiddleOutcome};
pub use view::{run_view, ViewCommand};
use std::path::Path;
use anyhow::Result;
use clap::{Parser, Subcommand};
pub(super) fn read_interactive_line(
reader: &mut (dyn std::io::BufRead + Send),
) -> std::io::Result<Option<String>> {
let mut input = String::new();
let bytes = reader.read_line(&mut input)?;
if bytes == 0 {
Ok(None)
} else {
Ok(Some(input))
}
}
pub(crate) fn default_commit_range(repo: &crate::git::GitRepository) -> Result<String> {
match repo.resolve_default_base_branch() {
Some(base) => Ok(format!("{base}..HEAD")),
None => anyhow::bail!(
"No default base branch found (checked origin/main, origin/master, main, master). \
Pass an explicit commit range (e.g. 'origin/develop..HEAD') or base branch."
),
}
}
#[derive(Parser)]
pub struct GitCommand {
#[command(subcommand)]
pub command: GitSubcommands,
}
#[derive(Subcommand)]
pub enum GitSubcommands {
Commit(CommitCommand),
Branch(BranchCommand),
}
#[derive(Parser)]
pub struct CommitCommand {
#[command(subcommand)]
pub command: CommitSubcommands,
}
#[derive(Subcommand)]
pub enum CommitSubcommands {
Message(MessageCommand),
}
#[derive(Parser)]
pub struct MessageCommand {
#[command(subcommand)]
pub command: MessageSubcommands,
}
#[derive(Subcommand)]
pub enum MessageSubcommands {
View(ViewCommand),
Amend(AmendCommand),
Twiddle(TwiddleCommand),
Check(CheckCommand),
Staged(StagedCommand),
}
#[derive(Parser)]
pub struct BranchCommand {
#[command(subcommand)]
pub command: BranchSubcommands,
}
#[derive(Subcommand)]
pub enum BranchSubcommands {
Info(InfoCommand),
Create(CreateCommand),
}
#[derive(Parser)]
pub struct CreateCommand {
#[command(subcommand)]
pub command: CreateSubcommands,
}
#[derive(Subcommand)]
pub enum CreateSubcommands {
Pr(CreatePrCommand),
}
impl GitCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(repo).await,
GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(repo).await,
}
}
}
impl CommitCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
CommitSubcommands::Message(message_cmd) => message_cmd.execute(repo).await,
}
}
}
impl MessageCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
MessageSubcommands::View(view_cmd) => view_cmd.execute(repo),
MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(repo),
MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute(repo).await,
MessageSubcommands::Check(check_cmd) => check_cmd.execute(repo).await,
MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute(repo).await,
}
}
}
impl BranchCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
BranchSubcommands::Info(info_cmd) => info_cmd.execute(repo),
BranchSubcommands::Create(create_cmd) => create_cmd.execute(repo).await,
}
}
}
impl CreateCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute(repo).await,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser as _ClapParser;
#[test]
fn cli_parses_git_commit_message_view() {
let cli = Cli::try_parse_from([
"omni-dev",
"git",
"commit",
"message",
"view",
"HEAD~3..HEAD",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_commit_message_amend() {
let cli = Cli::try_parse_from([
"omni-dev",
"git",
"commit",
"message",
"amend",
"amendments.yaml",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_branch_info() {
let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_branch_info_with_base() {
let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_config_models_show() {
let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_help_all() {
let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_rejects_unknown_command() {
let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
assert!(cli.is_err());
}
#[test]
fn cli_parses_twiddle_with_options() {
let cli = Cli::try_parse_from([
"omni-dev",
"git",
"commit",
"message",
"twiddle",
"--auto-apply",
"--no-context",
"--concurrency",
"8",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_check_with_options() {
let cli = Cli::try_parse_from([
"omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
"json",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_commit_message_staged() {
let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_commit_message_staged_print_only() {
let cli = Cli::try_parse_from([
"omni-dev",
"git",
"commit",
"message",
"staged",
"--print-only",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_git_commit_message_staged_with_model_and_beta() {
let cli = Cli::try_parse_from([
"omni-dev",
"git",
"commit",
"message",
"staged",
"--model",
"claude-sonnet-4-6",
"--beta-header",
"anthropic-beta:output-128k-2025-02-19",
]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_commands_generate_all() {
let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_ai_chat() {
let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_ai_chat_with_model() {
let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn cli_parses_ai_claude_cli_model_resolve() {
let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
}
#[test]
fn read_interactive_line_returns_input() {
let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
let result = read_interactive_line(&mut reader).unwrap();
assert_eq!(result, Some("hello\n".to_string()));
}
#[test]
fn read_interactive_line_eof_returns_none() {
let mut reader = std::io::Cursor::new(b"" as &[u8]);
let result = read_interactive_line(&mut reader).unwrap();
assert_eq!(result, None);
}
#[test]
fn read_interactive_line_empty_line() {
let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
let result = read_interactive_line(&mut reader).unwrap();
assert_eq!(result, Some("\n".to_string()));
}
fn repo_on_branch(branch: &str) -> (tempfile::TempDir, crate::git::GitRepository) {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root).unwrap();
let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
let p = temp_dir.path();
for args in [
vec!["init"],
vec!["checkout", "-b", branch],
vec!["commit", "--allow-empty", "-m", "init"],
] {
let output = std::process::Command::new("git")
.current_dir(p)
.args([
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
])
.args(&args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let repo = crate::git::GitRepository::open_at(p).unwrap();
(temp_dir, repo)
}
#[test]
fn default_commit_range_uses_resolved_base() {
let (_tmp, repo) = repo_on_branch("main");
assert_eq!(default_commit_range(&repo).unwrap(), "main..HEAD");
}
#[test]
fn default_commit_range_errors_without_mainline() {
let (_tmp, repo) = repo_on_branch("dev");
let err = default_commit_range(&repo).unwrap_err().to_string();
assert!(
err.contains("No default base branch found") && err.contains("origin/main"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn repo_flag_rejected_for_unconverted_commands() {
let unconverted: [&[&str]; 0] = [];
for args in unconverted {
let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
let err = cli
.execute()
.await
.expect_err("unconverted command must reject --repo");
let msg = format!("{err:#}");
assert!(
msg.contains("not yet supported"),
"args {args:?} -> unexpected error: {msg}"
);
}
}
}