Skip to main content

omni_dev/cli/
git.rs

1//! Git-related CLI commands.
2
3mod amend;
4mod check;
5mod create_pr;
6mod formatting;
7mod info;
8mod twiddle;
9mod view;
10
11pub use amend::AmendCommand;
12pub use check::CheckCommand;
13pub use create_pr::{CreatePrCommand, PrContent};
14pub use info::InfoCommand;
15pub use twiddle::TwiddleCommand;
16pub use view::ViewCommand;
17
18use anyhow::Result;
19use clap::{Parser, Subcommand};
20
21/// Reads one line of interactive input from `reader`.
22///
23/// Returns `Some(line)` on success, or `None` when the reader reaches EOF
24/// (i.e., `read_line` returns 0 bytes). Callers handle the `None` case
25/// with context-specific warnings and control flow.
26pub(super) fn read_interactive_line(
27    reader: &mut (dyn std::io::BufRead + Send),
28) -> std::io::Result<Option<String>> {
29    let mut input = String::new();
30    let bytes = reader.read_line(&mut input)?;
31    if bytes == 0 {
32        Ok(None)
33    } else {
34        Ok(Some(input))
35    }
36}
37
38/// Parses a `--beta-header key:value` string into a `(key, value)` tuple.
39pub(crate) fn parse_beta_header(s: &str) -> Result<(String, String)> {
40    let (k, v) = s
41        .split_once(':')
42        .ok_or_else(|| anyhow::anyhow!("Invalid --beta-header format '{s}'. Expected key:value"))?;
43    Ok((k.to_string(), v.to_string()))
44}
45
46/// Git operations.
47#[derive(Parser)]
48pub struct GitCommand {
49    /// Git subcommand to execute.
50    #[command(subcommand)]
51    pub command: GitSubcommands,
52}
53
54/// Git subcommands.
55#[derive(Subcommand)]
56pub enum GitSubcommands {
57    /// Commit-related operations.
58    Commit(CommitCommand),
59    /// Branch-related operations.
60    Branch(BranchCommand),
61}
62
63/// Commit operations.
64#[derive(Parser)]
65pub struct CommitCommand {
66    /// Commit subcommand to execute.
67    #[command(subcommand)]
68    pub command: CommitSubcommands,
69}
70
71/// Commit subcommands.
72#[derive(Subcommand)]
73pub enum CommitSubcommands {
74    /// Commit message operations.
75    Message(MessageCommand),
76}
77
78/// Message operations.
79#[derive(Parser)]
80pub struct MessageCommand {
81    /// Message subcommand to execute.
82    #[command(subcommand)]
83    pub command: MessageSubcommands,
84}
85
86/// Message subcommands.
87#[derive(Subcommand)]
88pub enum MessageSubcommands {
89    /// Analyzes commits and outputs repository information in YAML format.
90    View(ViewCommand),
91    /// Amends commit messages based on a YAML configuration file.
92    Amend(AmendCommand),
93    /// AI-powered commit message improvement using Claude.
94    Twiddle(TwiddleCommand),
95    /// Checks commit messages against guidelines without modifying them.
96    Check(CheckCommand),
97}
98
99/// Branch operations.
100#[derive(Parser)]
101pub struct BranchCommand {
102    /// Branch subcommand to execute.
103    #[command(subcommand)]
104    pub command: BranchSubcommands,
105}
106
107/// Branch subcommands.
108#[derive(Subcommand)]
109pub enum BranchSubcommands {
110    /// Analyzes branch commits and outputs repository information in YAML format.
111    Info(InfoCommand),
112    /// Create operations.
113    Create(CreateCommand),
114}
115
116/// Create operations.
117#[derive(Parser)]
118pub struct CreateCommand {
119    /// Create subcommand to execute.
120    #[command(subcommand)]
121    pub command: CreateSubcommands,
122}
123
124/// Create subcommands.
125#[derive(Subcommand)]
126pub enum CreateSubcommands {
127    /// Creates a pull request with AI-generated description.
128    Pr(CreatePrCommand),
129}
130
131impl GitCommand {
132    /// Executes the git command.
133    pub async fn execute(self) -> Result<()> {
134        match self.command {
135            GitSubcommands::Commit(commit_cmd) => commit_cmd.execute().await,
136            GitSubcommands::Branch(branch_cmd) => branch_cmd.execute().await,
137        }
138    }
139}
140
141impl CommitCommand {
142    /// Executes the commit command.
143    pub async fn execute(self) -> Result<()> {
144        match self.command {
145            CommitSubcommands::Message(message_cmd) => message_cmd.execute().await,
146        }
147    }
148}
149
150impl MessageCommand {
151    /// Executes the message command.
152    pub async fn execute(self) -> Result<()> {
153        match self.command {
154            MessageSubcommands::View(view_cmd) => view_cmd.execute(),
155            MessageSubcommands::Amend(amend_cmd) => amend_cmd.execute(),
156            MessageSubcommands::Twiddle(twiddle_cmd) => twiddle_cmd.execute().await,
157            MessageSubcommands::Check(check_cmd) => check_cmd.execute().await,
158        }
159    }
160}
161
162impl BranchCommand {
163    /// Executes the branch command.
164    pub async fn execute(self) -> Result<()> {
165        match self.command {
166            BranchSubcommands::Info(info_cmd) => info_cmd.execute(),
167            BranchSubcommands::Create(create_cmd) => create_cmd.execute().await,
168        }
169    }
170}
171
172impl CreateCommand {
173    /// Executes the create command.
174    pub async fn execute(self) -> Result<()> {
175        match self.command {
176            CreateSubcommands::Pr(pr_cmd) => pr_cmd.execute().await,
177        }
178    }
179}
180
181#[cfg(test)]
182#[allow(clippy::unwrap_used, clippy::expect_used)]
183mod tests {
184    use super::*;
185    use crate::cli::Cli;
186    // Parser trait must be in scope for try_parse_from
187    use clap::Parser as _ClapParser;
188
189    #[test]
190    fn parse_beta_header_valid() {
191        let (key, value) = parse_beta_header("anthropic-beta:output-128k-2025-02-19").unwrap();
192        assert_eq!(key, "anthropic-beta");
193        assert_eq!(value, "output-128k-2025-02-19");
194    }
195
196    #[test]
197    fn parse_beta_header_multiple_colons() {
198        // Only splits on the first colon
199        let (key, value) = parse_beta_header("key:value:with:colons").unwrap();
200        assert_eq!(key, "key");
201        assert_eq!(value, "value:with:colons");
202    }
203
204    #[test]
205    fn parse_beta_header_missing_colon() {
206        let result = parse_beta_header("no-colon-here");
207        assert!(result.is_err());
208        let err_msg = result.unwrap_err().to_string();
209        assert!(err_msg.contains("no-colon-here"));
210    }
211
212    #[test]
213    fn parse_beta_header_empty_value() {
214        let (key, value) = parse_beta_header("key:").unwrap();
215        assert_eq!(key, "key");
216        assert_eq!(value, "");
217    }
218
219    #[test]
220    fn parse_beta_header_empty_key() {
221        let (key, value) = parse_beta_header(":value").unwrap();
222        assert_eq!(key, "");
223        assert_eq!(value, "value");
224    }
225
226    #[test]
227    fn cli_parses_git_commit_message_view() {
228        let cli = Cli::try_parse_from([
229            "omni-dev",
230            "git",
231            "commit",
232            "message",
233            "view",
234            "HEAD~3..HEAD",
235        ]);
236        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
237    }
238
239    #[test]
240    fn cli_parses_git_commit_message_amend() {
241        let cli = Cli::try_parse_from([
242            "omni-dev",
243            "git",
244            "commit",
245            "message",
246            "amend",
247            "amendments.yaml",
248        ]);
249        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
250    }
251
252    #[test]
253    fn cli_parses_git_branch_info() {
254        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info"]);
255        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
256    }
257
258    #[test]
259    fn cli_parses_git_branch_info_with_base() {
260        let cli = Cli::try_parse_from(["omni-dev", "git", "branch", "info", "develop"]);
261        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
262    }
263
264    #[test]
265    fn cli_parses_config_models_show() {
266        let cli = Cli::try_parse_from(["omni-dev", "config", "models", "show"]);
267        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
268    }
269
270    #[test]
271    fn cli_parses_help_all() {
272        let cli = Cli::try_parse_from(["omni-dev", "help-all"]);
273        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
274    }
275
276    #[test]
277    fn cli_rejects_unknown_command() {
278        let cli = Cli::try_parse_from(["omni-dev", "nonexistent"]);
279        assert!(cli.is_err());
280    }
281
282    #[test]
283    fn cli_parses_twiddle_with_options() {
284        let cli = Cli::try_parse_from([
285            "omni-dev",
286            "git",
287            "commit",
288            "message",
289            "twiddle",
290            "--auto-apply",
291            "--no-context",
292            "--concurrency",
293            "8",
294        ]);
295        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
296    }
297
298    #[test]
299    fn cli_parses_check_with_options() {
300        let cli = Cli::try_parse_from([
301            "omni-dev", "git", "commit", "message", "check", "--strict", "--quiet", "--format",
302            "json",
303        ]);
304        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
305    }
306
307    #[test]
308    fn cli_parses_commands_generate_all() {
309        let cli = Cli::try_parse_from(["omni-dev", "commands", "generate", "all"]);
310        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
311    }
312
313    #[test]
314    fn cli_parses_ai_chat() {
315        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat"]);
316        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
317    }
318
319    #[test]
320    fn cli_parses_ai_chat_with_model() {
321        let cli = Cli::try_parse_from(["omni-dev", "ai", "chat", "--model", "claude-sonnet-4"]);
322        assert!(cli.is_ok(), "Failed to parse: {:?}", cli.err());
323    }
324
325    #[test]
326    fn read_interactive_line_returns_input() {
327        let mut reader = std::io::Cursor::new(b"hello\n" as &[u8]);
328        let result = read_interactive_line(&mut reader).unwrap();
329        assert_eq!(result, Some("hello\n".to_string()));
330    }
331
332    #[test]
333    fn read_interactive_line_eof_returns_none() {
334        let mut reader = std::io::Cursor::new(b"" as &[u8]);
335        let result = read_interactive_line(&mut reader).unwrap();
336        assert_eq!(result, None);
337    }
338
339    #[test]
340    fn read_interactive_line_empty_line() {
341        let mut reader = std::io::Cursor::new(b"\n" as &[u8]);
342        let result = read_interactive_line(&mut reader).unwrap();
343        assert_eq!(result, Some("\n".to_string()));
344    }
345}