Skip to main content

parley/
lib.rs

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