Skip to main content

parley/cli/
command.rs

1use super::args::{AiProviderArg, AiSessionModeArg, AuthorArg, SideArg, StateArg};
2use clap::Parser;
3
4#[derive(Debug, Parser)]
5#[command(
6    name = "parley",
7    about = "Local AI code review sessions for git changes"
8)]
9pub struct Cli {
10    #[command(subcommand)]
11    pub command: Command,
12}
13
14#[derive(Debug, Parser)]
15pub enum Command {
16    #[command(name = "tui")]
17    Tui {
18        /// Review name to open in the TUI.
19        #[arg(long, required = true)]
20        review: Option<String>,
21        /// Disable mouse capture and mouse interaction in the TUI.
22        #[arg(long)]
23        no_mouse: bool,
24        /// Show diff for a single commit (against its first parent).
25        #[arg(long, conflicts_with_all = &["base", "head"])]
26        commit: Option<String>,
27        /// Review current repository files without requiring a git diff.
28        #[arg(long, conflicts_with_all = &["commit", "base", "head"])]
29        root: bool,
30        /// Base revision for an explicit diff range.
31        #[arg(long, conflicts_with = "commit")]
32        base: Option<String>,
33        /// Head revision for an explicit diff range (defaults to HEAD).
34        #[arg(long, requires = "base", conflicts_with = "commit")]
35        head: Option<String>,
36    },
37    #[command(name = "review")]
38    Review {
39        #[command(subcommand)]
40        command: ReviewCommand,
41    },
42    #[command(name = "mcp")]
43    Mcp,
44}
45
46#[derive(Debug, Parser)]
47pub enum ReviewCommand {
48    #[command(name = "create")]
49    Create { name: String },
50    #[command(name = "start")]
51    Start { name: String },
52    #[command(name = "list")]
53    List,
54    #[command(name = "show")]
55    Show {
56        name: String,
57        /// Print review details as pretty JSON.
58        #[arg(long)]
59        json: bool,
60    },
61    #[command(name = "set-state")]
62    SetState { name: String, state: StateArg },
63    #[command(name = "add-comment")]
64    AddComment {
65        name: String,
66        /// File path for the comment location.
67        #[arg(long)]
68        file: String,
69        /// Diff side for the comment location (`left` or `right`).
70        #[arg(long)]
71        side: SideArg,
72        /// Line number on the old (left) side of the diff.
73        #[arg(long)]
74        old_line: Option<u32>,
75        /// Line number on the new (right) side of the diff.
76        #[arg(long)]
77        new_line: Option<u32>,
78        /// Comment text body.
79        #[arg(long)]
80        body: String,
81        /// Comment author (`user` or `ai`, default: `user`).
82        #[arg(long, default_value = "user")]
83        author: AuthorArg,
84    },
85    #[command(name = "add-reply")]
86    AddReply {
87        name: String,
88        /// Target comment id to reply to.
89        #[arg(long)]
90        comment_id: u64,
91        /// Reply text body.
92        #[arg(long)]
93        body: String,
94        /// Reply author (`user` or `ai`, default: `ai`).
95        #[arg(long, default_value = "ai")]
96        author: AuthorArg,
97    },
98    #[command(name = "mark-addressed")]
99    MarkAddressed {
100        name: String,
101        /// Target comment id to mark as addressed.
102        #[arg(long)]
103        comment_id: u64,
104        /// Actor marking the comment (`user` or `ai`, default: `user`).
105        #[arg(long, default_value = "user")]
106        author: AuthorArg,
107    },
108    #[command(name = "mark-open")]
109    MarkOpen {
110        name: String,
111        /// Target comment id to mark as open.
112        #[arg(long)]
113        comment_id: u64,
114        /// Actor reopening the comment (`user` or `ai`, default: `user`).
115        #[arg(long, default_value = "user")]
116        author: AuthorArg,
117    },
118    #[command(name = "run-ai-session")]
119    RunAiSession {
120        name: String,
121        /// AI provider to run for the session.
122        #[arg(long)]
123        provider: AiProviderArg,
124        /// Session mode override (for example `reply` or `refactor`).
125        #[arg(long)]
126        mode: Option<AiSessionModeArg>,
127        /// One or more comment ids to target (repeat `--comment-id`).
128        #[arg(long = "comment-id")]
129        comment_ids: Vec<u64>,
130    },
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{Cli, Command};
136    use clap::Parser;
137
138    #[test]
139    fn tui_command_parses_no_mouse_flag() {
140        let cli = Cli::parse_from(["parley", "tui", "--review", "parser-cleanup", "--no-mouse"]);
141
142        match cli.command {
143            Command::Tui {
144                review,
145                no_mouse,
146                commit,
147                root,
148                base,
149                head,
150            } => {
151                assert_eq!(review.as_deref(), Some("parser-cleanup"));
152                assert!(no_mouse);
153                assert_eq!(commit, None);
154                assert!(!root);
155                assert_eq!(base, None);
156                assert_eq!(head, None);
157            }
158            other => panic!("unexpected command: {other:?}"),
159        }
160    }
161
162    #[test]
163    fn tui_command_parses_commit_source() {
164        let cli = Cli::parse_from([
165            "parley",
166            "tui",
167            "--review",
168            "parser-cleanup",
169            "--commit",
170            "HEAD~2",
171        ]);
172
173        match cli.command {
174            Command::Tui {
175                commit,
176                root,
177                base,
178                head,
179                ..
180            } => {
181                assert_eq!(commit.as_deref(), Some("HEAD~2"));
182                assert!(!root);
183                assert_eq!(base, None);
184                assert_eq!(head, None);
185            }
186            other => panic!("unexpected command: {other:?}"),
187        }
188    }
189
190    #[test]
191    fn tui_command_requires_review_name() {
192        let error = Cli::try_parse_from(["parley", "tui", "--commit", "HEAD~2"])
193            .expect_err("cli should require review name");
194
195        let message = error.to_string();
196        assert!(message.contains("--review"));
197    }
198
199    #[test]
200    fn tui_command_rejects_head_without_base() {
201        let error = Cli::try_parse_from(["parley", "tui", "--head", "HEAD~1"])
202            .expect_err("cli should reject head without base");
203
204        let message = error.to_string();
205        assert!(message.contains("--base"));
206    }
207
208    #[test]
209    fn tui_command_rejects_commit_and_base_combination() {
210        let error = Cli::try_parse_from(["parley", "tui", "--commit", "HEAD", "--base", "HEAD~1"])
211            .expect_err("cli should reject conflicting diff sources");
212
213        let message = error.to_string();
214        assert!(message.contains("--commit"));
215        assert!(message.contains("--base"));
216    }
217
218    #[test]
219    fn tui_command_parses_root_source() {
220        let cli = Cli::parse_from(["parley", "tui", "--review", "root-review", "--root"]);
221
222        match cli.command {
223            Command::Tui { review, root, .. } => {
224                assert_eq!(review.as_deref(), Some("root-review"));
225                assert!(root);
226            }
227            other => panic!("unexpected command: {other:?}"),
228        }
229    }
230
231    #[test]
232    fn tui_command_requires_review_name_with_root() {
233        let error = Cli::try_parse_from(["parley", "tui", "--root"])
234            .expect_err("cli should require review name with root");
235
236        let message = error.to_string();
237        assert!(message.contains("--review"));
238    }
239
240    #[test]
241    fn tui_command_rejects_root_and_commit_combination() {
242        let error = Cli::try_parse_from([
243            "parley",
244            "tui",
245            "--review",
246            "root-review",
247            "--root",
248            "--commit",
249            "HEAD",
250        ])
251        .expect_err("cli should reject conflicting root and commit sources");
252
253        let message = error.to_string();
254        assert!(message.contains("--root"));
255        assert!(message.contains("--commit"));
256    }
257}