1use crate::cli::{Cli, Command, ReviewCommand};
2use crate::git::diff::DiffSource;
3use crate::services::review_service::ReviewService;
4use anyhow::{Context, Result, anyhow};
5use clap::Parser;
6use std::ffi::OsString;
7use std::io::IsTerminal;
8
9pub mod cli;
10pub mod docs;
11pub mod domain;
12pub mod error;
13pub mod git;
14pub mod mcp;
15pub mod persistence;
16pub mod services;
17pub mod tui;
18pub mod utils;
19
20pub async fn run() -> Result<()> {
25 let args: Vec<OsString> = std::env::args_os().collect();
26 let command = if should_run_mcp(&args) {
27 Command::Mcp
28 } else {
29 Cli::parse().command
30 };
31
32 let project_root =
33 std::env::current_dir().context("failed to read current working directory")?;
34 let store = crate::persistence::store::Store::from_project_root(&project_root);
35 let service = ReviewService::new(store);
36
37 match command {
38 Command::Tui {
39 review,
40 no_mouse,
41 commit,
42 root,
43 base,
44 head,
45 } => {
46 let diff_source = resolve_tui_diff_source(commit, root, base, head);
47 let review_name = review.context("missing required --review")?;
48 tui::run_tui(service, review_name, no_mouse, diff_source, false).await?;
49 }
50 Command::Review { command } => {
51 handle_review_command(command, &service).await?;
52 }
53 Command::Mcp => {
54 mcp::run_mcp(service).await?;
55 }
56 }
57
58 Ok(())
59}
60
61fn should_run_mcp(args: &[OsString]) -> bool {
62 if args.len() == 1 && !std::io::stdin().is_terminal() && !std::io::stdout().is_terminal() {
63 return true;
64 }
65
66 let first_arg = args.get(1).and_then(|value| value.to_str());
67 if matches!(first_arg, Some("mcp")) {
68 return true;
69 }
70
71 args.iter()
72 .skip(1)
73 .filter_map(|value| value.to_str())
74 .any(|value| matches!(value, "--stdio" | "--mcp"))
75}
76
77fn resolve_tui_diff_source(
78 commit: Option<String>,
79 root: bool,
80 base: Option<String>,
81 head: Option<String>,
82) -> DiffSource {
83 if root {
84 DiffSource::RootDirectory
85 } else if let Some(rev) = commit {
86 DiffSource::Commit { rev }
87 } else if let Some(base) = base {
88 DiffSource::Range {
89 base,
90 head: head.unwrap_or_else(|| "HEAD".to_string()),
91 }
92 } else {
93 DiffSource::WorkingTree
94 }
95}
96
97async fn handle_review_command(command: ReviewCommand, service: &ReviewService) -> Result<()> {
98 use crate::domain::review::ReviewState;
99 use crate::services::ai_session::{RunAiSessionInput, default_ai_session_mode, run_ai_session};
100 use crate::services::review_service::{AddCommentInput, AddReplyInput};
101
102 match command {
103 ReviewCommand::Create { name } => {
104 let review = service.create_review(&name).await?;
105 println!("created review {} in {:?}", review.name, review.state);
106 }
107 ReviewCommand::Start { name } => {
108 let review = service.set_state(&name, ReviewState::UnderReview).await?;
109 println!("review {} started in {:?}", review.name, review.state);
110 }
111 ReviewCommand::List => {
112 for review_name in service.list_reviews().await? {
113 println!("{review_name}");
114 }
115 }
116 ReviewCommand::Show { name, json } => {
117 let review = service.load_review(&name).await?;
118 if json {
119 println!("{}", serde_json::to_string_pretty(&review)?);
120 } else {
121 println!("name: {}", review.name);
122 println!("state: {:?}", review.state);
123 println!("comments: {}", review.comments.len());
124 for comment in review.comments {
125 println!(
126 " #{} [{}] {}:{} {}",
127 comment.id,
128 comment.status.as_str(),
129 comment
130 .old_line
131 .map_or_else(|| "_".into(), |value| value.to_string()),
132 comment
133 .new_line
134 .map_or_else(|| "_".into(), |value| value.to_string()),
135 comment.body
136 );
137 }
138 }
139 }
140 ReviewCommand::SetState { name, state } => {
141 let review = service.set_state(&name, state.0).await?;
142 println!("state updated to {:?}", review.state);
143 }
144 ReviewCommand::AddComment {
145 name,
146 file,
147 side,
148 old_line,
149 new_line,
150 body,
151 author,
152 } => {
153 if old_line.is_none() && new_line.is_none() {
154 return Err(anyhow!("provide --old-line or --new-line"));
155 }
156
157 let review = service
158 .add_comment(
159 &name,
160 AddCommentInput {
161 file_path: file,
162 old_line,
163 new_line,
164 line_range: None,
165 side: side.0,
166 line_anchor: None,
167 original_anchor: None,
168 body,
169 author: author.0,
170 },
171 )
172 .await?;
173 println!("comment added. total comments: {}", review.comments.len());
174 }
175 ReviewCommand::AddReply {
176 name,
177 comment_id,
178 body,
179 author,
180 } => {
181 service
182 .add_reply(
183 &name,
184 AddReplyInput {
185 comment_id,
186 author: author.0,
187 body,
188 },
189 )
190 .await?;
191 println!("reply added to comment #{comment_id}");
192 }
193 ReviewCommand::MarkAddressed {
194 name,
195 comment_id,
196 author,
197 } => {
198 service.mark_addressed(&name, comment_id, author.0).await?;
199 println!("comment #{comment_id} marked addressed");
200 }
201 ReviewCommand::MarkOpen {
202 name,
203 comment_id,
204 author,
205 } => {
206 service.mark_open(&name, comment_id, author.0).await?;
207 println!("comment #{comment_id} marked open");
208 }
209 ReviewCommand::RunAiSession {
210 name,
211 provider,
212 mode,
213 comment_ids,
214 } => {
215 let mode = mode.map_or_else(|| default_ai_session_mode(&comment_ids), |value| value.0);
216 let result = run_ai_session(
217 service,
218 RunAiSessionInput {
219 review_name: name,
220 provider: provider.0,
221 transport: None,
222 comment_ids,
223 mode,
224 diff_source: DiffSource::WorkingTree,
225 },
226 )
227 .await?;
228 println!("{}", serde_json::to_string_pretty(&result)?);
229 }
230 }
231
232 Ok(())
233}
234
235#[cfg(test)]
236mod tests {
237 use super::should_run_mcp;
238 use std::ffi::OsString;
239
240 #[test]
241 fn should_run_mcp_when_first_arg_is_mcp() {
242 let args = vec![OsString::from("parley"), OsString::from("mcp")];
243 assert!(should_run_mcp(&args));
244 }
245
246 #[test]
247 fn should_run_mcp_when_stdio_flag_is_present() {
248 let args = vec![OsString::from("parley"), OsString::from("--stdio")];
249 assert!(should_run_mcp(&args));
250 }
251}