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 anyhow::Result;
21use clap::{Parser, Subcommand};
22
23pub(crate) static CWD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
39
40pub(crate) struct CwdGuard {
49 original: std::path::PathBuf,
50 _lock: tokio::sync::MutexGuard<'static, ()>,
51}
52
53impl CwdGuard {
54 pub(crate) async fn enter<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
56 let lock = CWD_MUTEX.lock().await;
57 let original =
58 std::env::current_dir().map_err(|e| anyhow::anyhow!("current_dir failed: {e}"))?;
59 std::env::set_current_dir(path.as_ref())
60 .map_err(|e| anyhow::anyhow!("set_current_dir failed: {e}"))?;
61 Ok(Self {
62 original,
63 _lock: lock,
64 })
65 }
66}
67
68impl Drop for CwdGuard {
69 fn drop(&mut self) {
70 let _ = std::env::set_current_dir(&self.original);
71 }
72}
73
74pub(super) fn read_interactive_line(
80 reader: &mut (dyn std::io::BufRead + Send),
81) -> std::io::Result<Option<String>> {
82 let mut input = String::new();
83 let bytes = reader.read_line(&mut input)?;
84 if bytes == 0 {
85 Ok(None)
86 } else {
87 Ok(Some(input))
88 }
89}
90
91pub(crate) fn parse_beta_header(s: &str) -> Result<(String, String)> {
93 let (k, v) = s
94 .split_once(':')
95 .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
96 Ok((k.to_string(), v.to_string()))
97}
98
99#[derive(Parser)]
101pub struct GitCommand {
102 #[command(subcommand)]
104 pub command: GitSubcommands,
105}
106
107#[derive(Subcommand)]
109pub enum GitSubcommands {
110 Commit(CommitCommand),
112 Branch(BranchCommand),
114}
115
116#[derive(Parser)]
118pub struct CommitCommand {
119 #[command(subcommand)]
121 pub command: CommitSubcommands,
122}
123
124#[derive(Subcommand)]
126pub enum CommitSubcommands {
127 Message(MessageCommand),
129}
130
131#[derive(Parser)]
133pub struct MessageCommand {
134 #[command(subcommand)]
136 pub command: MessageSubcommands,
137}
138
139#[derive(Subcommand)]
141pub enum MessageSubcommands {
142 View(ViewCommand),
144 Amend(AmendCommand),
146 Twiddle(TwiddleCommand),
148 Check(CheckCommand),
150 Staged(StagedCommand),
152}
153
154#[derive(Parser)]
156pub struct BranchCommand {
157 #[command(subcommand)]
159 pub command: BranchSubcommands,
160}
161
162#[derive(Subcommand)]
164pub enum BranchSubcommands {
165 Info(InfoCommand),
167 Create(CreateCommand),
169}
170
171#[derive(Parser)]
173pub struct CreateCommand {
174 #[command(subcommand)]
176 pub command: CreateSubcommands,
177}
178
179#[derive(Subcommand)]
181pub enum CreateSubcommands {
182 Pr(CreatePrCommand),
184}
185
186impl GitCommand {
187 pub async fn execute(self) -> Result<()> {
189 match self.command {
190 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute().await,
191 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute().await,
192 }
193 }
194}
195
196impl CommitCommand {
197 pub async fn execute(self) -> Result<()> {
199 match self.command {
200 CommitSubcommands::Message(message_cmd) => message_cmd.execute().await,
201 }
202 }
203}
204
205impl MessageCommand {
206 pub async fn execute(self) -> Result<()> {
208 match self.command {
209 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
210 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
211 MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute().await,
212 MessageSubcommands::Check(check_cmd) => check_cmd.execute().await,
213 MessageSubcommands::Staged(staged_cmd) => staged_cmd.execute().await,
214 }
215 }
216}
217
218impl BranchCommand {
219 pub async fn execute(self) -> Result<()> {
221 match self.command {
222 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
223 BranchSubcommands::Create(create_cmd) => create_cmd.execute().await,
224 }
225 }
226}
227
228impl CreateCommand {
229 pub async fn execute(self) -> Result<()> {
231 match self.command {
232 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
233 }
234 }
235}
236
237#[cfg(test)]
238#[allow(clippy::unwrap_used, clippy::expect_used)]
239mod tests {
240 use super::*;
241 use crate::cli::Cli;
242 use clap::Parser as _ClapParser;
244
245 #[test]
246 fn parse_beta_header_valid() {
247 let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
248 assert_eq!(key, "anthropic-beta");
249 assert_eq!(value, "output-128k-2025-02-19");
250 }
251
252 #[test]
253 fn parse_beta_header_multiple_colons() {
254 let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
256 assert_eq!(key, "key");
257 assert_eq!(value, "value:with:colons");
258 }
259
260 #[test]
261 fn parse_beta_header_missing_colon() {
262 let result = parse_beta_header("no-colon-here");
263 assert!(result.is_err());
264 let err_msg = result.unwrap_err().to_string();
265 assert!(err_msg.contains("no-colon-here"));
266 }
267
268 #[test]
269 fn parse_beta_header_empty_value() {
270 let (key, value) = parse_beta_header("key:").unwrap();
271 assert_eq!(key, "key");
272 assert_eq!(value, "");
273 }
274
275 #[test]
276 fn parse_beta_header_empty_key() {
277 let (key, value) = parse_beta_header(":value").unwrap();
278 assert_eq!(key, "");
279 assert_eq!(value, "value");
280 }
281
282 #[test]
283 fn cli_parses_git_commit_message_view() {
284 let cli = Cli::try_parse_from([
285 "omni-dev",
286 "git",
287 "commit",
288 "message",
289 "view",
290 "HEAD~3..HEAD",
291 ]);
292 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
293 }
294
295 #[test]
296 fn cli_parses_git_commit_message_amend() {
297 let cli = Cli::try_parse_from([
298 "omni-dev",
299 "git",
300 "commit",
301 "message",
302 "amend",
303 "amendments.yaml",
304 ]);
305 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
306 }
307
308 #[test]
309 fn cli_parses_git_branch_info() {
310 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
311 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
312 }
313
314 #[test]
315 fn cli_parses_git_branch_info_with_base() {
316 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
317 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
318 }
319
320 #[test]
321 fn cli_parses_config_models_show() {
322 let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
323 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
324 }
325
326 #[test]
327 fn cli_parses_help_all() {
328 let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
329 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
330 }
331
332 #[test]
333 fn cli_rejects_unknown_command() {
334 let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
335 assert!(cli.is_err());
336 }
337
338 #[test]
339 fn cli_parses_twiddle_with_options() {
340 let cli = Cli::try_parse_from([
341 "omni-dev",
342 "git",
343 "commit",
344 "message",
345 "twiddle",
346 "--auto-apply",
347 "--no-context",
348 "--concurrency",
349 "8",
350 ]);
351 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
352 }
353
354 #[test]
355 fn cli_parses_check_with_options() {
356 let cli = Cli::try_parse_from([
357 "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
358 "json",
359 ]);
360 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
361 }
362
363 #[test]
364 fn cli_parses_git_commit_message_staged() {
365 let cli = Cli::try_parse_from(["omni-dev", "git", "commit", "message", "staged"]);
366 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
367 }
368
369 #[test]
370 fn cli_parses_git_commit_message_staged_print_only() {
371 let cli = Cli::try_parse_from([
372 "omni-dev",
373 "git",
374 "commit",
375 "message",
376 "staged",
377 "--print-only",
378 ]);
379 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
380 }
381
382 #[test]
383 fn cli_parses_git_commit_message_staged_with_model_and_beta() {
384 let cli = Cli::try_parse_from([
385 "omni-dev",
386 "git",
387 "commit",
388 "message",
389 "staged",
390 "--model",
391 "claude-sonnet-4-6",
392 "--beta-header",
393 "anthropic-beta:output-128k-2025-02-19",
394 ]);
395 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
396 }
397
398 #[test]
399 fn cli_parses_commands_generate_all() {
400 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
401 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
402 }
403
404 #[test]
405 fn cli_parses_ai_chat() {
406 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
407 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
408 }
409
410 #[test]
411 fn cli_parses_ai_chat_with_model() {
412 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
413 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
414 }
415
416 #[test]
417 fn cli_parses_ai_claude_cli_model_resolve() {
418 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
419 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
420 }
421
422 #[test]
423 fn read_interactive_line_returns_input() {
424 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
425 let result = read_interactive_line(&mut reader).unwrap();
426 assert_eq!(result, Some("hello\n".to_string()));
427 }
428
429 #[test]
430 fn read_interactive_line_eof_returns_none() {
431 let mut reader = std::io::Cursor::new(b"" as &[u8]);
432 let result = read_interactive_line(&mut reader).unwrap();
433 assert_eq!(result, None);
434 }
435
436 #[test]
437 fn read_interactive_line_empty_line() {
438 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
439 let result = read_interactive_line(&mut reader).unwrap();
440 assert_eq!(result, Some("\n".to_string()));
441 }
442
443 #[tokio::test]
444 async fn cwd_guard_invalid_path_returns_error() {
445 let result = CwdGuard::enter("/no/such/path/exists").await;
450 assert!(result.is_err(), "expected error for nonexistent path");
451 }
452}