Skip to main content

parley/
lib.rs

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
28/// # Errors
29///
30/// Returns an error when CLI command handling, repository access, persistence, MCP I/O, or TUI
31/// execution fails.
32pub 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}