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