1use crate::cli::{Cli, Command, ConfigCommand, ReviewCommand, WorktreeCommand};
2use crate::domain::review::ReviewState;
3use crate::git::diff::DiffSource;
4use crate::git::worktree::{
5 RepositoryContext, discover_from_cwd, discover_with_worktree, list_worktrees,
6};
7use crate::persistence::store::Store;
8use crate::services::ai_session::{RunAiSessionInput, default_ai_session_mode, run_ai_session};
9use crate::services::review_service::{AddCommentInput, AddReplyInput, ReviewService};
10use anyhow::{Context, Result, anyhow};
11use clap::Parser;
12use std::env;
13use std::ffi::OsString;
14use std::io::{IsTerminal, stdin, stdout};
15use tokio::fs;
16
17pub mod cli;
18pub mod docs;
19pub mod domain;
20pub mod error;
21pub mod git;
22pub mod mcp;
23pub mod persistence;
24pub mod services;
25pub mod tui;
26pub mod utils;
27
28pub async fn run() -> Result<()> {
33 let args: Vec<OsString> = env::args_os().collect();
34 let cli = if should_run_mcp(&args) {
35 Cli {
36 worktree: None,
37 command: Command::Mcp,
38 }
39 } else {
40 Cli::parse()
41 };
42
43 let ctx = if let Some(ref wt) = cli.worktree {
44 discover_with_worktree(
45 env::current_dir().context("failed to read cwd")?,
46 Some(wt.as_str()),
47 )
48 .await?
49 } else {
50 discover_from_cwd().await?
51 };
52
53 match cli.command {
54 Command::Config { command } => {
55 handle_config_command(command, &ctx).await?;
56 }
57 Command::Tui {
58 review,
59 no_mouse,
60 commit,
61 root,
62 base,
63 head,
64 } => {
65 let store = Store::resolve_from_context(&ctx).await?;
66 let service = ReviewService::new(store);
67 let diff_source = resolve_tui_diff_source(commit, root, base, head);
68 let review_name = review.context("missing required --review")?;
69 tui::run_tui(service, review_name, no_mouse, diff_source, false, &ctx).await?;
70 }
71 Command::Review { command } => {
72 let store = Store::resolve_from_context(&ctx).await?;
73 let service = ReviewService::new(store);
74 handle_review_command(command, &service, &ctx).await?;
75 }
76 Command::Mcp => {
77 let store = Store::resolve_from_context(&ctx).await?;
78 let service = ReviewService::new(store);
79 mcp::run_mcp(service).await?;
80 }
81 Command::Worktree { command } => {
82 handle_worktree_command(command, &ctx).await?;
83 }
84 }
85
86 Ok(())
87}
88
89fn should_run_mcp(args: &[OsString]) -> bool {
90 if args.len() == 1 && !stdin().is_terminal() && !stdout().is_terminal() {
91 return true;
92 }
93
94 let first_arg = args.get(1).and_then(|value| value.to_str());
95 if matches!(first_arg, Some("mcp")) {
96 return true;
97 }
98
99 args.iter()
100 .skip(1)
101 .filter_map(|value| value.to_str())
102 .any(|value| matches!(value, "--stdio" | "--mcp"))
103}
104
105fn resolve_tui_diff_source(
106 commit: Option<String>,
107 root: bool,
108 base: Option<String>,
109 head: Option<String>,
110) -> DiffSource {
111 if root {
112 DiffSource::RootDirectory
113 } else if let Some(rev) = commit {
114 DiffSource::Commit { rev }
115 } else if let Some(base) = base {
116 DiffSource::Range {
117 base,
118 head: head.unwrap_or_else(|| "HEAD".to_string()),
119 }
120 } else {
121 DiffSource::WorkingTree
122 }
123}
124
125async fn handle_config_command(command: ConfigCommand, ctx: &RepositoryContext) -> Result<()> {
126 match command {
127 ConfigCommand::Path => {
128 println!("{}", ctx.storage_root.display());
129 }
130 ConfigCommand::UseLocal => {
131 let local_root = ctx.selected_worktree.join(".parley");
132 fs::create_dir_all(&local_root)
133 .await
134 .with_context(|| format!("failed to create {}", local_root.display()))?;
135 let store = Store::from_project_root(&ctx.selected_worktree);
136 store.ensure_dirs().await?;
137 println!("{}", store.root_path().display());
138 }
139 }
140
141 Ok(())
142}
143
144async fn handle_worktree_command(command: WorktreeCommand, ctx: &RepositoryContext) -> Result<()> {
145 match command {
146 WorktreeCommand::List => {
147 let worktrees = list_worktrees(&ctx.selected_worktree).await?;
148 for wt in worktrees {
149 let marker = if wt.is_current { " *" } else { "" };
150 let branch = wt
151 .branch
152 .as_deref()
153 .or(wt.head_summary.as_deref())
154 .unwrap_or("-");
155 println!("{}{}\t{}\t{}", wt.name, marker, wt.path.display(), branch);
156 }
157 }
158 WorktreeCommand::Current => {
159 println!("{}", ctx.selected_worktree.display());
160 if let Some(ref name) = ctx.current_worktree_name {
161 println!("{name}");
162 }
163 }
164 }
165 Ok(())
166}
167
168async fn handle_review_command(
169 command: ReviewCommand,
170 service: &ReviewService,
171 ctx: &RepositoryContext,
172) -> Result<()> {
173 match command {
174 ReviewCommand::Create { name } => {
175 let review = service.create_review(&name).await?;
176 println!("created review {} in {:?}", review.name, review.state);
177 }
178 ReviewCommand::Start { name } => {
179 let review = service.set_state(&name, ReviewState::UnderReview).await?;
180 println!("review {} started in {:?}", review.name, review.state);
181 }
182 ReviewCommand::List => {
183 for review_name in service.list_reviews().await? {
184 println!("{review_name}");
185 }
186 }
187 ReviewCommand::Show { name, json } => {
188 let review = service.load_review(&name).await?;
189 if json {
190 println!("{}", serde_json::to_string_pretty(&review)?);
191 } else {
192 println!("name: {}", review.name);
193 println!("state: {:?}", review.state);
194 println!("comments: {}", review.comments.len());
195 for comment in review.comments {
196 println!(
197 " #{} [{}] {}:{} {}",
198 comment.id,
199 comment.status.as_str(),
200 comment
201 .old_line
202 .map_or_else(|| "_".into(), |value| value.to_string()),
203 comment
204 .new_line
205 .map_or_else(|| "_".into(), |value| value.to_string()),
206 comment.body
207 );
208 }
209 }
210 }
211 ReviewCommand::SetState { name, state } => {
212 let review = service.set_state(&name, state.0).await?;
213 println!("state updated to {:?}", review.state);
214 }
215 ReviewCommand::AddComment {
216 name,
217 file,
218 side,
219 old_line,
220 new_line,
221 body,
222 author,
223 } => {
224 if old_line.is_none() && new_line.is_none() {
225 return Err(anyhow!("provide --old-line or --new-line"));
226 }
227
228 let review = service
229 .add_comment(
230 &name,
231 AddCommentInput {
232 file_path: file,
233 old_line,
234 new_line,
235 line_range: None,
236 side: side.0,
237 line_anchor: None,
238 original_anchor: None,
239 body,
240 author: author.0,
241 },
242 )
243 .await?;
244 println!("comment added. total comments: {}", review.comments.len());
245 }
246 ReviewCommand::AddReply {
247 name,
248 comment_id,
249 body,
250 author,
251 } => {
252 service
253 .add_reply(
254 &name,
255 AddReplyInput {
256 comment_id,
257 author: author.0,
258 body,
259 },
260 )
261 .await?;
262 println!("reply added to comment #{comment_id}");
263 }
264 ReviewCommand::MarkAddressed {
265 name,
266 comment_id,
267 author,
268 } => {
269 service.mark_addressed(&name, comment_id, author.0).await?;
270 println!("comment #{comment_id} marked addressed");
271 }
272 ReviewCommand::MarkOpen {
273 name,
274 comment_id,
275 author,
276 } => {
277 service.mark_open(&name, comment_id, author.0).await?;
278 println!("comment #{comment_id} marked open");
279 }
280 ReviewCommand::RunAiSession {
281 name,
282 provider,
283 mode,
284 comment_ids,
285 } => {
286 let mode = mode.map_or_else(|| default_ai_session_mode(&comment_ids), |value| value.0);
287 let result = run_ai_session(
288 service,
289 RunAiSessionInput {
290 review_name: name,
291 provider: provider.0,
292 transport: None,
293 comment_ids,
294 mode,
295 diff_source: DiffSource::WorkingTree,
296 worktree_path: Some(ctx.selected_worktree.clone()),
297 },
298 )
299 .await?;
300 println!("{}", serde_json::to_string_pretty(&result)?);
301 }
302 }
303
304 Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309 use super::handle_config_command;
310 use super::should_run_mcp;
311 use crate::cli::ConfigCommand;
312 use crate::git::worktree::RepositoryContext;
313 use std::ffi::OsString;
314 use tempfile::tempdir;
315 use tokio::fs as tokio_fs;
316
317 #[test]
318 fn should_run_mcp_when_first_arg_is_mcp() {
319 let args = vec![OsString::from("parley"), OsString::from("mcp")];
320 assert!(should_run_mcp(&args));
321 }
322
323 #[test]
324 fn should_run_mcp_when_stdio_flag_is_present() {
325 let args = vec![OsString::from("parley"), OsString::from("--stdio")];
326 assert!(should_run_mcp(&args));
327 }
328
329 #[tokio::test]
330 async fn config_use_local_should_create_local_store() -> anyhow::Result<()> {
331 let tempdir = tempdir()?;
332 let ctx = RepositoryContext {
333 selected_worktree: tempdir.path().to_path_buf(),
334 main_worktree: Some(tempdir.path().to_path_buf()),
335 common_git_dir: tempdir.path().join(".git"),
336 storage_root: tempdir.path().join(".parley"),
337 current_worktree_name: None,
338 };
339
340 handle_config_command(ConfigCommand::UseLocal, &ctx).await?;
341
342 assert!(tokio_fs::try_exists(tempdir.path().join(".parley/reviews")).await?);
343 Ok(())
344 }
345}