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