Skip to main content

parley/
lib.rs

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
20/// # Errors
21///
22/// Returns an error when CLI command handling, repository access, persistence, MCP I/O, or TUI
23/// execution fails.
24pub 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}