1mod amend;
4mod check;
5mod create_pr;
6pub(crate) mod formatting;
7mod info;
8mod staged;
9mod twiddle;
10mod view;
11
12pub use amend::AmendCommand;
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 parse_beta_header(s: &str) -> Result<(String, String)> {
44 let (k, v) = s
45 .split_once(':')
46 .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
47 Ok((k.to_string(), v.to_string()))
48}
49
50#[derive(Parser)]
52pub struct GitCommand {
53 #[command(subcommand)]
55 pub command: GitSubcommands,
56}
57
58#[derive(Subcommand)]
60pub enum GitSubcommands {
61 Commit(CommitCommand),
63 Branch(BranchCommand),
65}
66
67#[derive(Parser)]
69pub struct CommitCommand {
70 #[command(subcommand)]
72 pub command: CommitSubcommands,
73}
74
75#[derive(Subcommand)]
77pub enum CommitSubcommands {
78 Message(MessageCommand),
80}
81
82#[derive(Parser)]
84pub struct MessageCommand {
85 #[command(subcommand)]
87 pub command: MessageSubcommands,
88}
89
90#[derive(Subcommand)]
92pub enum MessageSubcommands {
93 View(ViewCommand),
95 Amend(AmendCommand),
97 Twiddle(TwiddleCommand),
99 Check(CheckCommand),
101 Staged(StagedCommand),
103}
104
105#[derive(Parser)]
107pub struct BranchCommand {
108 #[command(subcommand)]
110 pub command: BranchSubcommands,
111}
112
113#[derive(Subcommand)]
115pub enum BranchSubcommands {
116 Info(InfoCommand),
118 Create(CreateCommand),
120}
121
122#[derive(Parser)]
124pub struct CreateCommand {
125 #[command(subcommand)]
127 pub command: CreateSubcommands,
128}
129
130#[derive(Subcommand)]
132pub enum CreateSubcommands {
133 Pr(CreatePrCommand),
135}
136
137impl GitCommand {
138 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
144 match self.command {
145 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute(repo).await,
146 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute(repo).await,
147 }
148 }
149}
150
151impl CommitCommand {
152 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
154 match self.command {
155 CommitSubcommands::Message(message_cmd) => message_cmd.execute(repo).await,
156 }
157 }
158}
159
160impl MessageCommand {
161 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
163 match self.command {
164 MessageSubcommands::View(view_cmd) => view_cmd.execute(repo),
165 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(repo),
166 MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute(repo).await,
167 MessageSubcommands::Check(check_cmd) => check_cmd.execute(repo).await,
168 MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute(repo).await,
169 }
170 }
171}
172
173impl BranchCommand {
174 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
176 match self.command {
177 BranchSubcommands::Info(info_cmd) => info_cmd.execute(repo),
178 BranchSubcommands::Create(create_cmd) => create_cmd.execute(repo).await,
179 }
180 }
181}
182
183impl CreateCommand {
184 pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
186 match self.command {
187 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute(repo).await,
188 }
189 }
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195 use super::*;
196 use crate::cli::Cli;
197 use clap::Parser as _ClapParser;
199
200 #[test]
201 fn parse_beta_header_valid() {
202 let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
203 assert_eq!(key, "anthropic-beta");
204 assert_eq!(value, "output-128k-2025-02-19");
205 }
206
207 #[test]
208 fn parse_beta_header_multiple_colons() {
209 let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
211 assert_eq!(key, "key");
212 assert_eq!(value, "value:with:colons");
213 }
214
215 #[test]
216 fn parse_beta_header_missing_colon() {
217 let result = parse_beta_header("no-colon-here");
218 assert!(result.is_err());
219 let err_msg = result.unwrap_err().to_string();
220 assert!(err_msg.contains("no-colon-here"));
221 }
222
223 #[test]
224 fn parse_beta_header_empty_value() {
225 let (key, value) = parse_beta_header("key:").unwrap();
226 assert_eq!(key, "key");
227 assert_eq!(value, "");
228 }
229
230 #[test]
231 fn parse_beta_header_empty_key() {
232 let (key, value) = parse_beta_header(":value").unwrap();
233 assert_eq!(key, "");
234 assert_eq!(value, "value");
235 }
236
237 #[test]
238 fn cli_parses_git_commit_message_view() {
239 let cli = Cli::try_parse_from([
240 "omni-dev",
241 "git",
242 "commit",
243 "message",
244 "view",
245 "HEAD~3..HEAD",
246 ]);
247 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
248 }
249
250 #[test]
251 fn cli_parses_git_commit_message_amend() {
252 let cli = Cli::try_parse_from([
253 "omni-dev",
254 "git",
255 "commit",
256 "message",
257 "amend",
258 "amendments.yaml",
259 ]);
260 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
261 }
262
263 #[test]
264 fn cli_parses_git_branch_info() {
265 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
266 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
267 }
268
269 #[test]
270 fn cli_parses_git_branch_info_with_base() {
271 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
272 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
273 }
274
275 #[test]
276 fn cli_parses_config_models_show() {
277 let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
278 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
279 }
280
281 #[test]
282 fn cli_parses_help_all() {
283 let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
284 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
285 }
286
287 #[test]
288 fn cli_rejects_unknown_command() {
289 let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
290 assert!(cli.is_err());
291 }
292
293 #[test]
294 fn cli_parses_twiddle_with_options() {
295 let cli = Cli::try_parse_from([
296 "omni-dev",
297 "git",
298 "commit",
299 "message",
300 "twiddle",
301 "--auto-apply",
302 "--no-context",
303 "--concurrency",
304 "8",
305 ]);
306 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
307 }
308
309 #[test]
310 fn cli_parses_check_with_options() {
311 let cli = Cli::try_parse_from([
312 "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
313 "json",
314 ]);
315 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
316 }
317
318 #[test]
319 fn cli_parses_git_commit_message_staged() {
320 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
321 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
322 }
323
324 #[test]
325 fn cli_parses_git_commit_message_staged_print_only() {
326 let cli = Cli::try_parse_from([
327 "omni-dev",
328 "git",
329 "commit",
330 "message",
331 "staged",
332 "--print-only",
333 ]);
334 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
335 }
336
337 #[test]
338 fn cli_parses_git_commit_message_staged_with_model_and_beta() {
339 let cli = Cli::try_parse_from([
340 "omni-dev",
341 "git",
342 "commit",
343 "message",
344 "staged",
345 "--model",
346 "claude-sonnet-4-6",
347 "--beta-header",
348 "anthropic-beta:output-128k-2025-02-19",
349 ]);
350 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
351 }
352
353 #[test]
354 fn cli_parses_commands_generate_all() {
355 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
356 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
357 }
358
359 #[test]
360 fn cli_parses_ai_chat() {
361 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
362 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
363 }
364
365 #[test]
366 fn cli_parses_ai_chat_with_model() {
367 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
368 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
369 }
370
371 #[test]
372 fn cli_parses_ai_claude_cli_model_resolve() {
373 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
374 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
375 }
376
377 #[test]
378 fn read_interactive_line_returns_input() {
379 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
380 let result = read_interactive_line(&mut reader).unwrap();
381 assert_eq!(result, Some("hello\n".to_string()));
382 }
383
384 #[test]
385 fn read_interactive_line_eof_returns_none() {
386 let mut reader = std::io::Cursor::new(b"" as &[u8]);
387 let result = read_interactive_line(&mut reader).unwrap();
388 assert_eq!(result, None);
389 }
390
391 #[test]
392 fn read_interactive_line_empty_line() {
393 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
394 let result = read_interactive_line(&mut reader).unwrap();
395 assert_eq!(result, Some("\n".to_string()));
396 }
397
398 #[tokio::test]
407 async fn repo_flag_rejected_for_unconverted_commands() {
408 let unconverted: [&[&str]; 0] = [];
409 for args in unconverted {
410 let cli = Cli::try_parse_from(args.iter().copied()).unwrap();
411 let err = cli
412 .execute()
413 .await
414 .expect_err("unconverted command must reject --repo");
415 let msg = format!("{err:#}");
416 assert!(
417 msg.contains("not yet supported"),
418 "args {args:?} -> unexpected error: {msg}"
419 );
420 }
421 }
422}