1mod amend;
4mod check;
5mod create_pr;
6pub(crate) mod formatting;
7mod info;
8mod twiddle;
9mod view;
10
11pub use amend::AmendCommand;
12pub use check::{run_check, CheckCommand, CheckOutcome};
13pub use create_pr::{run_create_pr, CreatePrCommand, CreatePrOutcome, PrContent};
14pub use info::{run_info, InfoCommand};
15pub use twiddle::{run_twiddle, TwiddleCommand, TwiddleOutcome};
16pub use view::{run_view, ViewCommand};
17
18use anyhow::Result;
19use clap::{Parser, Subcommand};
20
21pub(crate) static CWD_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
37
38pub(crate) struct CwdGuard {
47 original: std::path::PathBuf,
48 _lock: tokio::sync::MutexGuard<'static, ()>,
49}
50
51impl CwdGuard {
52 pub(crate) async fn enter<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
54 let lock = CWD_MUTEX.lock().await;
55 let original =
56 std::env::current_dir().map_err(|e| anyhow::anyhow!("current_dir failed: {e}"))?;
57 std::env::set_current_dir(path.as_ref())
58 .map_err(|e| anyhow::anyhow!("set_current_dir failed: {e}"))?;
59 Ok(Self {
60 original,
61 _lock: lock,
62 })
63 }
64}
65
66impl Drop for CwdGuard {
67 fn drop(&mut self) {
68 let _ = std::env::set_current_dir(&self.original);
69 }
70}
71
72pub(super) fn read_interactive_line(
78 reader: &mut (dyn std::io::BufRead + Send),
79) -> std::io::Result<Option<String>> {
80 let mut input = String::new();
81 let bytes = reader.read_line(&mut input)?;
82 if bytes == 0 {
83 Ok(None)
84 } else {
85 Ok(Some(input))
86 }
87}
88
89pub(crate) fn parse_beta_header(s: &str) -> Result<(String, String)> {
91 let (k, v) = s
92 .split_once(':')
93 .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
94 Ok((k.to_string(), v.to_string()))
95}
96
97#[derive(Parser)]
99pub struct GitCommand {
100 #[command(subcommand)]
102 pub command: GitSubcommands,
103}
104
105#[derive(Subcommand)]
107pub enum GitSubcommands {
108 Commit(CommitCommand),
110 Branch(BranchCommand),
112}
113
114#[derive(Parser)]
116pub struct CommitCommand {
117 #[command(subcommand)]
119 pub command: CommitSubcommands,
120}
121
122#[derive(Subcommand)]
124pub enum CommitSubcommands {
125 Message(MessageCommand),
127}
128
129#[derive(Parser)]
131pub struct MessageCommand {
132 #[command(subcommand)]
134 pub command: MessageSubcommands,
135}
136
137#[derive(Subcommand)]
139pub enum MessageSubcommands {
140 View(ViewCommand),
142 Amend(AmendCommand),
144 Twiddle(TwiddleCommand),
146 Check(CheckCommand),
148}
149
150#[derive(Parser)]
152pub struct BranchCommand {
153 #[command(subcommand)]
155 pub command: BranchSubcommands,
156}
157
158#[derive(Subcommand)]
160pub enum BranchSubcommands {
161 Info(InfoCommand),
163 Create(CreateCommand),
165}
166
167#[derive(Parser)]
169pub struct CreateCommand {
170 #[command(subcommand)]
172 pub command: CreateSubcommands,
173}
174
175#[derive(Subcommand)]
177pub enum CreateSubcommands {
178 Pr(CreatePrCommand),
180}
181
182impl GitCommand {
183 pub async fn execute(self) -> Result<()> {
185 match self.command {
186 GitSubcommands::Commit(commit_cmd) => commit_cmd.execute().await,
187 GitSubcommands::Branch(branch_cmd) => branch_cmd.execute().await,
188 }
189 }
190}
191
192impl CommitCommand {
193 pub async fn execute(self) -> Result<()> {
195 match self.command {
196 CommitSubcommands::Message(message_cmd) => message_cmd.execute().await,
197 }
198 }
199}
200
201impl MessageCommand {
202 pub async fn execute(self) -> Result<()> {
204 match self.command {
205 MessageSubcommands::View(view_cmd) => view_cmd.execute(),
206 MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
207 MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute().await,
208 MessageSubcommands::Check(check_cmd) => check_cmd.execute().await,
209 }
210 }
211}
212
213impl BranchCommand {
214 pub async fn execute(self) -> Result<()> {
216 match self.command {
217 BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
218 BranchSubcommands::Create(create_cmd) => create_cmd.execute().await,
219 }
220 }
221}
222
223impl CreateCommand {
224 pub async fn execute(self) -> Result<()> {
226 match self.command {
227 CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
228 }
229 }
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used)]
234mod tests {
235 use super::*;
236 use crate::cli::Cli;
237 use clap::Parser as _ClapParser;
239
240 #[test]
241 fn parse_beta_header_valid() {
242 let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
243 assert_eq!(key, "anthropic-beta");
244 assert_eq!(value, "output-128k-2025-02-19");
245 }
246
247 #[test]
248 fn parse_beta_header_multiple_colons() {
249 let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
251 assert_eq!(key, "key");
252 assert_eq!(value, "value:with:colons");
253 }
254
255 #[test]
256 fn parse_beta_header_missing_colon() {
257 let result = parse_beta_header("no-colon-here");
258 assert!(result.is_err());
259 let err_msg = result.unwrap_err().to_string();
260 assert!(err_msg.contains("no-colon-here"));
261 }
262
263 #[test]
264 fn parse_beta_header_empty_value() {
265 let (key, value) = parse_beta_header("key:").unwrap();
266 assert_eq!(key, "key");
267 assert_eq!(value, "");
268 }
269
270 #[test]
271 fn parse_beta_header_empty_key() {
272 let (key, value) = parse_beta_header(":value").unwrap();
273 assert_eq!(key, "");
274 assert_eq!(value, "value");
275 }
276
277 #[test]
278 fn cli_parses_git_commit_message_view() {
279 let cli = Cli::try_parse_from([
280 "omni-dev",
281 "git",
282 "commit",
283 "message",
284 "view",
285 "HEAD~3..HEAD",
286 ]);
287 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
288 }
289
290 #[test]
291 fn cli_parses_git_commit_message_amend() {
292 let cli = Cli::try_parse_from([
293 "omni-dev",
294 "git",
295 "commit",
296 "message",
297 "amend",
298 "amendments.yaml",
299 ]);
300 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
301 }
302
303 #[test]
304 fn cli_parses_git_branch_info() {
305 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
306 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
307 }
308
309 #[test]
310 fn cli_parses_git_branch_info_with_base() {
311 let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
312 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
313 }
314
315 #[test]
316 fn cli_parses_config_models_show() {
317 let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
318 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
319 }
320
321 #[test]
322 fn cli_parses_help_all() {
323 let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
324 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
325 }
326
327 #[test]
328 fn cli_rejects_unknown_command() {
329 let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
330 assert!(cli.is_err());
331 }
332
333 #[test]
334 fn cli_parses_twiddle_with_options() {
335 let cli = Cli::try_parse_from([
336 "omni-dev",
337 "git",
338 "commit",
339 "message",
340 "twiddle",
341 "--auto-apply",
342 "--no-context",
343 "--concurrency",
344 "8",
345 ]);
346 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
347 }
348
349 #[test]
350 fn cli_parses_check_with_options() {
351 let cli = Cli::try_parse_from([
352 "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
353 "json",
354 ]);
355 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
356 }
357
358 #[test]
359 fn cli_parses_commands_generate_all() {
360 let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
361 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
362 }
363
364 #[test]
365 fn cli_parses_ai_chat() {
366 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
367 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
368 }
369
370 #[test]
371 fn cli_parses_ai_chat_with_model() {
372 let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
373 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
374 }
375
376 #[test]
377 fn cli_parses_ai_claude_cli_model_resolve() {
378 let cli = Cli::try_parse_from(["omni-dev", "ai", "claude", "cli", "model", "resolve"]);
379 assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
380 }
381
382 #[test]
383 fn read_interactive_line_returns_input() {
384 let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
385 let result = read_interactive_line(&mut reader).unwrap();
386 assert_eq!(result, Some("hello\n".to_string()));
387 }
388
389 #[test]
390 fn read_interactive_line_eof_returns_none() {
391 let mut reader = std::io::Cursor::new(b"" as &[u8]);
392 let result = read_interactive_line(&mut reader).unwrap();
393 assert_eq!(result, None);
394 }
395
396 #[test]
397 fn read_interactive_line_empty_line() {
398 let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
399 let result = read_interactive_line(&mut reader).unwrap();
400 assert_eq!(result, Some("\n".to_string()));
401 }
402
403 #[tokio::test]
404 async fn cwd_guard_invalid_path_returns_error() {
405 let result = CwdGuard::enter("/no/such/path/exists").await;
410 assert!(result.is_err(), "expected error for nonexistent path");
411 }
412}