1mod amend;
4mod check;
5mod create_pr;
6pub(crate) mod formatting;
7mod info;
8mod lint;
9mod staged;
10mod twiddle;
11mod view;
12mod worktree;
13
14pub use amend::{run_amend, AmendCommand, AmendOutcome};
15pub use check::{run_check, CheckCommand, CheckOutcome};
16pub use create_pr::{run_create_pr, CreatePrCommand, CreatePrOutcome, PrContent};
17pub use info::{run_info, InfoCommand};
18pub use lint::{run_lint, LintCommand, LintInput, LintOutcome};
19pub use staged::{run_staged, StagedCommand, StagedOutcome};
20pub use twiddle::{run_twiddle, TwiddleCommand, TwiddleOutcome};
21pub use view::{run_view, ViewCommand};
22pub use worktree::WorktreeCommand;
23
24use std::path::Path;
25
26use anyhow::Result;
27use clap::{Parser, Subcommand};
28
29pub(super) fn read_interactive_line(
35 reader: &mut (dyn std::io::BufRead + Send),
36) -> std::io::Result<Option<String>> {
37 let mut input = String::new();
38 let bytes = reader.read_line(&mut input)?;
39 if bytes == 0 {
40 Ok(None)
41 } else {
42 Ok(Some(input))
43 }
44}
45
46pub(crate) fn default_commit_range(repo: &crate::git::GitRepository) -> Result<String> {
50 match repo.resolve_default_base_branch() {
51 Some(base) => Ok(format!("{base}..HEAD")),
52 None => anyhow::bail!(
53 "No default base branch found (checked origin/main, origin/master, main, master). \
54 Pass an explicit commit range (e.g. 'origin/develop..HEAD') or base branch."
55 ),
56 }
57}
58
59#[derive(Parser)]
61pub struct GitCommand {
62 #[command(subcommand)]
64 pub command: GitSubcommands,
65}
66
67#[derive(Subcommand)]
69pub enum GitSubcommands {
70 Commit(CommitCommand),
72 Branch(BranchCommand),
74 Worktree(WorktreeCommand),
76}
77
78#[derive(Parser)]
80pub struct CommitCommand {
81 #[command(subcommand)]
83 pub command: CommitSubcommands,
84}
85
86#[derive(Subcommand)]
88pub enum CommitSubcommands {
89 Message(MessageCommand),
91}
92
93#[derive(Parser)]
95pub struct MessageCommand {
96 #[command(subcommand)]
98 pub command: MessageSubcommands,
99}
100
101#[derive(Subcommand)]
103pub enum MessageSubcommands {
104 View(ViewCommand),
106 Amend(AmendCommand),
108 Twiddle(TwiddleCommand),
110 Check(CheckCommand),
112 Lint(LintCommand),
114 Staged(StagedCommand),
116}
117
118#[derive(Parser)]
120pub struct BranchCommand {
121 #[command(subcommand)]
123 pub command: BranchSubcommands,
124}
125
126#[derive(Subcommand)]
128pub enum BranchSubcommands {
129 Info(InfoCommand),
131 Create(CreateCommand),
133}
134
135#[derive(Parser)]
137pub struct CreateCommand {
138 #[command(subcommand)]
140 pub command: CreateSubcommands,
141}
142
143#[derive(Subcommand)]
145pub enum CreateSubcommands {
146 Pr(CreatePrCommand),
148}
149
150impl GitCommand {
151 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
157 match self.command {
158 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(repo).await,
159 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(repo).await,
160 GitSubcommands::Worktree(worktree_cmd) => worktree_cmd.execute(repo),
161 }
162 }
163}
164
165impl CommitCommand {
166 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
168 match self.command {
169 CommitSubcommands::Message(message_cmd) => message_cmd.execute(repo).await,
170 }
171 }
172}
173
174impl MessageCommand {
175 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
177 match self.command {
178 MessageSubcommands::View(view_cmd) => view_cmd.execute(repo),
179 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(repo),
180 MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute(repo).await,
181 MessageSubcommands::Check(check_cmd) => check_cmd.execute(repo).await,
182 MessageSubcommands::Lint(lint_cmd) => lint_cmd.execute(repo).await,
183 MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute(repo).await,
184 }
185 }
186}
187
188impl BranchCommand {
189 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
191 match self.command {
192 BranchSubcommands::Info(info_cmd) => info_cmd.execute(repo),
193 BranchSubcommands::Create(create_cmd) => create_cmd.execute(repo).await,
194 }
195 }
196}
197
198impl CreateCommand {
199 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
201 match self.command {
202 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute(repo).await,
203 }
204 }
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210 use super::*;
211 use crate::cli::Cli;
212 use clap::Parser as _ClapParser;
214
215 #[test]
216 fn cli_parses_git_commit_message_view() {
217 let cli = Cli::try_parse_from([
218 "omni-dev",
219 "git",
220 "commit",
221 "message",
222 "view",
223 "HEAD~3..HEAD",
224 ]);
225 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
226 }
227
228 #[test]
229 fn cli_parses_git_commit_message_amend() {
230 let cli = Cli::try_parse_from([
231 "omni-dev",
232 "git",
233 "commit",
234 "message",
235 "amend",
236 "amendments.yaml",
237 ]);
238 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
239 }
240
241 #[test]
242 fn cli_parses_git_branch_info() {
243 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
244 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
245 }
246
247 #[test]
248 fn cli_parses_git_branch_info_with_base() {
249 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
250 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
251 }
252
253 #[test]
254 fn cli_parses_config_models_show() {
255 let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
256 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
257 }
258
259 #[test]
260 fn cli_parses_config_scopes_usage() {
261 let cli = Cli::try_parse_from(["omni-dev", "config", "scopes", "usage"]);
262 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
263 }
264
265 #[test]
266 fn cli_parses_config_scopes_usage_with_options() {
267 let cli = Cli::try_parse_from([
268 "omni-dev",
269 "config",
270 "scopes",
271 "usage",
272 "-n",
273 "300",
274 "--project-only",
275 "-o",
276 "json",
277 ]);
278 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
279 }
280
281 #[test]
282 fn cli_rejects_config_scopes_usage_range_and_max_count_together() {
283 let cli = Cli::try_parse_from([
284 "omni-dev",
285 "config",
286 "scopes",
287 "usage",
288 "HEAD~10..HEAD",
289 "-n",
290 "300",
291 ]);
292 assert!(
293 cli.is_err(),
294 "COMMIT_RANGE and -n/--max-count must be mutually exclusive"
295 );
296 }
297
298 #[test]
299 fn cli_parses_help_all() {
300 let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
301 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
302 }
303
304 #[test]
305 fn cli_rejects_unknown_command() {
306 let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
307 assert!(cli.is_err());
308 }
309
310 #[test]
311 fn cli_parses_twiddle_with_options() {
312 let cli = Cli::try_parse_from([
313 "omni-dev",
314 "git",
315 "commit",
316 "message",
317 "twiddle",
318 "--auto-apply",
319 "--no-context",
320 "--concurrency",
321 "8",
322 ]);
323 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
324 }
325
326 #[test]
327 fn cli_parses_check_with_options() {
328 let cli = Cli::try_parse_from([
329 "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
330 "json",
331 ]);
332 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
333 }
334
335 #[test]
336 fn cli_parses_lint_with_options() {
337 let cli = Cli::try_parse_from([
338 "omni-dev", "git", "commit", "message", "lint", "--strict", "--quiet", "--output",
339 "json",
340 ]);
341 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
342 }
343
344 #[test]
345 fn cli_parses_lint_with_stdin() {
346 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "lint", "--stdin"]);
347 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
348 }
349
350 #[test]
351 fn cli_parses_lint_suggest_and_fix() {
352 let cli = Cli::try_parse_from([
353 "omni-dev",
354 "git",
355 "commit",
356 "message",
357 "lint",
358 "--suggest",
359 "--fix",
360 "--allow-pushed",
361 ]);
362 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
363 }
364
365 #[test]
366 fn cli_parses_git_commit_message_staged() {
367 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
368 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
369 }
370
371 #[test]
372 fn cli_parses_git_commit_message_staged_no_ai() {
373 let cli =
374 Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged", "--no-ai"]);
375 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
376 }
377
378 #[test]
379 fn cli_parses_git_commit_message_staged_print_only() {
380 let cli = Cli::try_parse_from([
381 "omni-dev",
382 "git",
383 "commit",
384 "message",
385 "staged",
386 "--print-only",
387 ]);
388 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
389 }
390
391 #[test]
392 fn cli_parses_git_commit_message_staged_with_model_and_beta() {
393 let cli = Cli::try_parse_from([
394 "omni-dev",
395 "git",
396 "commit",
397 "message",
398 "staged",
399 "--model",
400 "claude-sonnet-4-6",
401 "--beta-header",
402 "anthropic-beta:output-128k-2025-02-19",
403 ]);
404 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
405 }
406
407 #[test]
408 fn cli_parses_commands_generate_all() {
409 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
410 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
411 }
412
413 #[test]
414 fn cli_parses_ai_chat() {
415 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
416 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
417 }
418
419 #[test]
420 fn cli_parses_ai_chat_with_model() {
421 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
422 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
423 }
424
425 #[test]
426 fn cli_parses_ai_claude_cli_model_resolve() {
427 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
428 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
429 }
430
431 #[test]
432 fn read_interactive_line_returns_input() {
433 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
434 let result = read_interactive_line(&mut reader).unwrap();
435 assert_eq!(result, Some("hello\n".to_string()));
436 }
437
438 #[test]
439 fn read_interactive_line_eof_returns_none() {
440 let mut reader = std::io::Cursor::new(b"" as &[u8]);
441 let result = read_interactive_line(&mut reader).unwrap();
442 assert_eq!(result, None);
443 }
444
445 #[test]
446 fn read_interactive_line_empty_line() {
447 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
448 let result = read_interactive_line(&mut reader).unwrap();
449 assert_eq!(result, Some("\n".to_string()));
450 }
451
452 fn repo_on_branch(branch: &str) -> (tempfile::TempDir, crate::git::GitRepository) {
455 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
456 std::fs::create_dir_all(&tmp_root).unwrap();
457 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
458 let p = temp_dir.path();
459 for args in [
460 vec!["init"],
461 vec!["checkout", "-b", branch],
462 vec!["commit", "--allow-empty", "-m", "init"],
463 ] {
464 let output = std::process::Command::new("git")
465 .current_dir(p)
466 .args([
467 "-c",
468 "user.email=test@example.com",
469 "-c",
470 "user.name=Test",
471 "-c",
472 "commit.gpgsign=false",
473 ])
474 .args(&args)
475 .output()
476 .unwrap();
477 assert!(
478 output.status.success(),
479 "git {args:?} failed: {}",
480 String::from_utf8_lossy(&output.stderr)
481 );
482 }
483 let repo = crate::git::GitRepository::open_at(p).unwrap();
484 (temp_dir, repo)
485 }
486
487 #[test]
488 fn default_commit_range_uses_resolved_base() {
489 let (_tmp, repo) = repo_on_branch("main");
490 assert_eq!(default_commit_range(&repo).unwrap(), "main..HEAD");
491 }
492
493 #[test]
494 fn default_commit_range_errors_without_mainline() {
495 let (_tmp, repo) = repo_on_branch("dev");
496 let err = default_commit_range(&repo).unwrap_err().to_string();
497 assert!(
498 err.contains("No default base branch found") && err.contains("origin/main"),
499 "unexpected error: {err}"
500 );
501 }
502
503 #[tokio::test]
512 async fn repo_flag_rejected_for_unconverted_commands() {
513 let unconverted: [&[&str]; 0] = [];
514 for args in unconverted {
515 let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
516 let err = cli
517 .execute()
518 .await
519 .expect_err("unconverted command must reject --repo");
520 let msg = format!("{err:#}");
521 assert!(
522 msg.contains("not yet supported"),
523 "args {args:?} -> unexpected error: {msg}"
524 );
525 }
526 }
527}