1mod amend;
4mod check;
5mod create_pr;
6pub(crate) mod formatting;
7mod info;
8mod staged;
9mod twiddle;
10mod view;
11mod worktree;
12
13pub use amend::{run_amend, AmendCommand, AmendOutcome};
14pub use check::{run_check, CheckCommand, CheckOutcome};
15pub use create_pr::{run_create_pr, CreatePrCommand, CreatePrOutcome, PrContent};
16pub use info::{run_info, InfoCommand};
17pub use staged::{run_staged, StagedCommand, StagedOutcome};
18pub use twiddle::{run_twiddle, TwiddleCommand, TwiddleOutcome};
19pub use view::{run_view, ViewCommand};
20pub use worktree::WorktreeCommand;
21
22use std::path::Path;
23
24use anyhow::Result;
25use clap::{Parser, Subcommand};
26
27pub(super) fn read_interactive_line(
33 reader: &mut (dyn std::io::BufRead + Send),
34) -> std::io::Result<Option<String>> {
35 let mut input = String::new();
36 let bytes = reader.read_line(&mut input)?;
37 if bytes == 0 {
38 Ok(None)
39 } else {
40 Ok(Some(input))
41 }
42}
43
44pub(crate) fn default_commit_range(repo: &crate::git::GitRepository) -> Result<String> {
48 match repo.resolve_default_base_branch() {
49 Some(base) => Ok(format!("{base}..HEAD")),
50 None => anyhow::bail!(
51 "No default base branch found (checked origin/main, origin/master, main, master). \
52 Pass an explicit commit range (e.g. 'origin/develop..HEAD') or base branch."
53 ),
54 }
55}
56
57#[derive(Parser)]
59pub struct GitCommand {
60 #[command(subcommand)]
62 pub command: GitSubcommands,
63}
64
65#[derive(Subcommand)]
67pub enum GitSubcommands {
68 Commit(CommitCommand),
70 Branch(BranchCommand),
72 Worktree(WorktreeCommand),
74}
75
76#[derive(Parser)]
78pub struct CommitCommand {
79 #[command(subcommand)]
81 pub command: CommitSubcommands,
82}
83
84#[derive(Subcommand)]
86pub enum CommitSubcommands {
87 Message(MessageCommand),
89}
90
91#[derive(Parser)]
93pub struct MessageCommand {
94 #[command(subcommand)]
96 pub command: MessageSubcommands,
97}
98
99#[derive(Subcommand)]
101pub enum MessageSubcommands {
102 View(ViewCommand),
104 Amend(AmendCommand),
106 Twiddle(TwiddleCommand),
108 Check(CheckCommand),
110 Staged(StagedCommand),
112}
113
114#[derive(Parser)]
116pub struct BranchCommand {
117 #[command(subcommand)]
119 pub command: BranchSubcommands,
120}
121
122#[derive(Subcommand)]
124pub enum BranchSubcommands {
125 Info(InfoCommand),
127 Create(CreateCommand),
129}
130
131#[derive(Parser)]
133pub struct CreateCommand {
134 #[command(subcommand)]
136 pub command: CreateSubcommands,
137}
138
139#[derive(Subcommand)]
141pub enum CreateSubcommands {
142 Pr(CreatePrCommand),
144}
145
146impl GitCommand {
147 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
153 match self.command {
154 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(repo).await,
155 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(repo).await,
156 GitSubcommands::Worktree(worktree_cmd) => worktree_cmd.execute(repo),
157 }
158 }
159}
160
161impl CommitCommand {
162 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
164 match self.command {
165 CommitSubcommands::Message(message_cmd) => message_cmd.execute(repo).await,
166 }
167 }
168}
169
170impl MessageCommand {
171 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
173 match self.command {
174 MessageSubcommands::View(view_cmd) => view_cmd.execute(repo),
175 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(repo),
176 MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute(repo).await,
177 MessageSubcommands::Check(check_cmd) => check_cmd.execute(repo).await,
178 MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute(repo).await,
179 }
180 }
181}
182
183impl BranchCommand {
184 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
186 match self.command {
187 BranchSubcommands::Info(info_cmd) => info_cmd.execute(repo),
188 BranchSubcommands::Create(create_cmd) => create_cmd.execute(repo).await,
189 }
190 }
191}
192
193impl CreateCommand {
194 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
196 match self.command {
197 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute(repo).await,
198 }
199 }
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used)]
204mod tests {
205 use super::*;
206 use crate::cli::Cli;
207 use clap::Parser as _ClapParser;
209
210 #[test]
211 fn cli_parses_git_commit_message_view() {
212 let cli = Cli::try_parse_from([
213 "omni-dev",
214 "git",
215 "commit",
216 "message",
217 "view",
218 "HEAD~3..HEAD",
219 ]);
220 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
221 }
222
223 #[test]
224 fn cli_parses_git_commit_message_amend() {
225 let cli = Cli::try_parse_from([
226 "omni-dev",
227 "git",
228 "commit",
229 "message",
230 "amend",
231 "amendments.yaml",
232 ]);
233 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
234 }
235
236 #[test]
237 fn cli_parses_git_branch_info() {
238 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
239 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
240 }
241
242 #[test]
243 fn cli_parses_git_branch_info_with_base() {
244 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
245 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
246 }
247
248 #[test]
249 fn cli_parses_config_models_show() {
250 let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
251 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
252 }
253
254 #[test]
255 fn cli_parses_help_all() {
256 let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
257 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
258 }
259
260 #[test]
261 fn cli_rejects_unknown_command() {
262 let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
263 assert!(cli.is_err());
264 }
265
266 #[test]
267 fn cli_parses_twiddle_with_options() {
268 let cli = Cli::try_parse_from([
269 "omni-dev",
270 "git",
271 "commit",
272 "message",
273 "twiddle",
274 "--auto-apply",
275 "--no-context",
276 "--concurrency",
277 "8",
278 ]);
279 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
280 }
281
282 #[test]
283 fn cli_parses_check_with_options() {
284 let cli = Cli::try_parse_from([
285 "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
286 "json",
287 ]);
288 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
289 }
290
291 #[test]
292 fn cli_parses_git_commit_message_staged() {
293 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
294 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
295 }
296
297 #[test]
298 fn cli_parses_git_commit_message_staged_print_only() {
299 let cli = Cli::try_parse_from([
300 "omni-dev",
301 "git",
302 "commit",
303 "message",
304 "staged",
305 "--print-only",
306 ]);
307 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
308 }
309
310 #[test]
311 fn cli_parses_git_commit_message_staged_with_model_and_beta() {
312 let cli = Cli::try_parse_from([
313 "omni-dev",
314 "git",
315 "commit",
316 "message",
317 "staged",
318 "--model",
319 "claude-sonnet-4-6",
320 "--beta-header",
321 "anthropic-beta:output-128k-2025-02-19",
322 ]);
323 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
324 }
325
326 #[test]
327 fn cli_parses_commands_generate_all() {
328 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
329 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
330 }
331
332 #[test]
333 fn cli_parses_ai_chat() {
334 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
335 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
336 }
337
338 #[test]
339 fn cli_parses_ai_chat_with_model() {
340 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
341 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
342 }
343
344 #[test]
345 fn cli_parses_ai_claude_cli_model_resolve() {
346 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
347 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
348 }
349
350 #[test]
351 fn read_interactive_line_returns_input() {
352 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
353 let result = read_interactive_line(&mut reader).unwrap();
354 assert_eq!(result, Some("hello\n".to_string()));
355 }
356
357 #[test]
358 fn read_interactive_line_eof_returns_none() {
359 let mut reader = std::io::Cursor::new(b"" as &[u8]);
360 let result = read_interactive_line(&mut reader).unwrap();
361 assert_eq!(result, None);
362 }
363
364 #[test]
365 fn read_interactive_line_empty_line() {
366 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
367 let result = read_interactive_line(&mut reader).unwrap();
368 assert_eq!(result, Some("\n".to_string()));
369 }
370
371 fn repo_on_branch(branch: &str) -> (tempfile::TempDir, crate::git::GitRepository) {
374 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
375 std::fs::create_dir_all(&tmp_root).unwrap();
376 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
377 let p = temp_dir.path();
378 for args in [
379 vec!["init"],
380 vec!["checkout", "-b", branch],
381 vec!["commit", "--allow-empty", "-m", "init"],
382 ] {
383 let output = std::process::Command::new("git")
384 .current_dir(p)
385 .args([
386 "-c",
387 "user.email=test@example.com",
388 "-c",
389 "user.name=Test",
390 "-c",
391 "commit.gpgsign=false",
392 ])
393 .args(&args)
394 .output()
395 .unwrap();
396 assert!(
397 output.status.success(),
398 "git {args:?} failed: {}",
399 String::from_utf8_lossy(&output.stderr)
400 );
401 }
402 let repo = crate::git::GitRepository::open_at(p).unwrap();
403 (temp_dir, repo)
404 }
405
406 #[test]
407 fn default_commit_range_uses_resolved_base() {
408 let (_tmp, repo) = repo_on_branch("main");
409 assert_eq!(default_commit_range(&repo).unwrap(), "main..HEAD");
410 }
411
412 #[test]
413 fn default_commit_range_errors_without_mainline() {
414 let (_tmp, repo) = repo_on_branch("dev");
415 let err = default_commit_range(&repo).unwrap_err().to_string();
416 assert!(
417 err.contains("No default base branch found") && err.contains("origin/main"),
418 "unexpected error: {err}"
419 );
420 }
421
422 #[tokio::test]
431 async fn repo_flag_rejected_for_unconverted_commands() {
432 let unconverted: [&[&str]; 0] = [];
433 for args in unconverted {
434 let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
435 let err = cli
436 .execute()
437 .await
438 .expect_err("unconverted command must reject --repo");
439 let msg = format!("{err:#}");
440 assert!(
441 msg.contains("not yet supported"),
442 "args {args:?} -> unexpected error: {msg}"
443 );
444 }
445 }
446}