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_git_commit_message_staged() {
352 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
353 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
354 }
355
356 #[test]
357 fn cli_parses_git_commit_message_staged_print_only() {
358 let cli = Cli::try_parse_from([
359 "omni-dev",
360 "git",
361 "commit",
362 "message",
363 "staged",
364 "--print-only",
365 ]);
366 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
367 }
368
369 #[test]
370 fn cli_parses_git_commit_message_staged_with_model_and_beta() {
371 let cli = Cli::try_parse_from([
372 "omni-dev",
373 "git",
374 "commit",
375 "message",
376 "staged",
377 "--model",
378 "claude-sonnet-4-6",
379 "--beta-header",
380 "anthropic-beta:output-128k-2025-02-19",
381 ]);
382 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
383 }
384
385 #[test]
386 fn cli_parses_commands_generate_all() {
387 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
388 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
389 }
390
391 #[test]
392 fn cli_parses_ai_chat() {
393 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
394 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
395 }
396
397 #[test]
398 fn cli_parses_ai_chat_with_model() {
399 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
400 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
401 }
402
403 #[test]
404 fn cli_parses_ai_claude_cli_model_resolve() {
405 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
406 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
407 }
408
409 #[test]
410 fn read_interactive_line_returns_input() {
411 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
412 let result = read_interactive_line(&mut reader).unwrap();
413 assert_eq!(result, Some("hello\n".to_string()));
414 }
415
416 #[test]
417 fn read_interactive_line_eof_returns_none() {
418 let mut reader = std::io::Cursor::new(b"" as &[u8]);
419 let result = read_interactive_line(&mut reader).unwrap();
420 assert_eq!(result, None);
421 }
422
423 #[test]
424 fn read_interactive_line_empty_line() {
425 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
426 let result = read_interactive_line(&mut reader).unwrap();
427 assert_eq!(result, Some("\n".to_string()));
428 }
429
430 fn repo_on_branch(branch: &str) -> (tempfile::TempDir, crate::git::GitRepository) {
433 let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
434 std::fs::create_dir_all(&tmp_root).unwrap();
435 let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
436 let p = temp_dir.path();
437 for args in [
438 vec!["init"],
439 vec!["checkout", "-b", branch],
440 vec!["commit", "--allow-empty", "-m", "init"],
441 ] {
442 let output = std::process::Command::new("git")
443 .current_dir(p)
444 .args([
445 "-c",
446 "user.email=test@example.com",
447 "-c",
448 "user.name=Test",
449 "-c",
450 "commit.gpgsign=false",
451 ])
452 .args(&args)
453 .output()
454 .unwrap();
455 assert!(
456 output.status.success(),
457 "git {args:?} failed: {}",
458 String::from_utf8_lossy(&output.stderr)
459 );
460 }
461 let repo = crate::git::GitRepository::open_at(p).unwrap();
462 (temp_dir, repo)
463 }
464
465 #[test]
466 fn default_commit_range_uses_resolved_base() {
467 let (_tmp, repo) = repo_on_branch("main");
468 assert_eq!(default_commit_range(&repo).unwrap(), "main..HEAD");
469 }
470
471 #[test]
472 fn default_commit_range_errors_without_mainline() {
473 let (_tmp, repo) = repo_on_branch("dev");
474 let err = default_commit_range(&repo).unwrap_err().to_string();
475 assert!(
476 err.contains("No default base branch found") && err.contains("origin/main"),
477 "unexpected error: {err}"
478 );
479 }
480
481 #[tokio::test]
490 async fn repo_flag_rejected_for_unconverted_commands() {
491 let unconverted: [&[&str]; 0] = [];
492 for args in unconverted {
493 let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
494 let err = cli
495 .execute()
496 .await
497 .expect_err("unconverted command must reject --repo");
498 let msg = format!("{err:#}");
499 assert!(
500 msg.contains("not yet supported"),
501 "args {args:?} -> unexpected error: {msg}"
502 );
503 }
504 }
505}