1use std::fmt;
8use std::io::ErrorKind;
9use std::path::Path;
10use std::time::Duration;
11
12use diffler_core::feedback;
13use diffler_core::model::{DiffModel, FileStatus};
14use diffler_core::session::{Comment, CommentStatus};
15use diffler_core::source::ReviewSource;
16use rmcp::handler::server::router::tool::ToolRouter;
17use rmcp::handler::server::wrapper::Parameters;
18use rmcp::model::{ServerCapabilities, ServerInfo};
19use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
20use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
21use rmcp::{ErrorData, Json, ServerHandler, schemars, tool, tool_handler, tool_router};
22use serde::Serialize;
23use tokio::sync::mpsc::UnboundedSender;
24use tokio::sync::{oneshot, watch};
25use tokio::task::JoinHandle;
26
27use crate::event::AppEvent;
28
29pub const AGENT_AUTHOR: &str = "agent";
31
32const DEFAULT_WAIT_SECONDS: u64 = 300;
33const MAX_WAIT_SECONDS: u64 = 540;
34pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
37
38pub struct McpRequest {
40 pub kind: McpRequestKind,
41 pub reply: oneshot::Sender<McpResponse>,
42}
43
44impl fmt::Debug for McpRequest {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.debug_struct("McpRequest")
47 .field("kind", &self.kind)
48 .finish_non_exhaustive()
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum McpRequestKind {
54 ReviewStatus,
55 GetDiff {
56 file: Option<String>,
57 },
58 GetComments {
59 status: Option<CommentStatus>,
60 },
61 ListReviews,
64 ReplyComment {
65 id: String,
66 body: String,
67 },
68 ProposeResolve {
69 id: String,
70 note: Option<String>,
71 },
72 MarkViewed {
73 file: String,
74 },
75 Feedback,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum McpResponse {
81 Status(ReviewStatusResponse),
82 Diff(String),
83 Comments(Vec<CommentInfo>),
84 Reviews(Vec<ReviewSummary>),
85 Replied {
86 status: String,
87 },
88 Ok,
89 Error(String),
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
94pub struct ReviewStatusResponse {
95 pub repo: String,
96 pub branch: Option<String>,
97 pub oid7: String,
98 pub files_changed: Vec<FileEntry>,
99 pub open_comments: usize,
100 pub replied_comments: usize,
101 pub resolved_comments: usize,
102 pub feedback_epoch: u64,
103 pub reviews: Vec<ReviewSummary>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
111pub struct ReviewSummary {
112 pub source: String,
114 pub label: String,
116 pub open_comments: usize,
117 pub replied_comments: usize,
118 pub resolved_comments: usize,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
122pub struct FileEntry {
123 pub path: String,
124 pub status: String,
125 pub viewed: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
129pub struct DiffResponse {
130 pub diff: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
134pub struct CommentInfo {
135 pub id: String,
136 pub source: String,
138 pub source_label: String,
140 pub file: String,
141 pub line: Option<u32>,
142 pub line_end: Option<u32>,
143 pub side: String,
145 pub body: String,
146 pub status: String,
147 pub author: String,
148 pub replies: Vec<ReplyInfo>,
149 pub outdated: bool,
151 pub context: Option<String>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
156pub struct ReplyInfo {
157 pub author: String,
158 pub body: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
162pub struct CommentsResponse {
163 pub comments: Vec<CommentInfo>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
167pub struct ReviewsResponse {
168 pub reviews: Vec<ReviewSummary>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
172pub struct ReplyResponse {
173 pub ok: bool,
174 pub status: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
178pub struct OkResponse {
179 pub ok: bool,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
183pub struct WaitForFeedbackResponse {
184 pub epoch: u64,
185 pub timed_out: bool,
186 pub comments: Vec<CommentInfo>,
187}
188
189#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
190pub struct GetDiffParams {
191 pub file: Option<String>,
193}
194
195#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
196pub struct GetCommentsParams {
197 pub status: Option<String>,
199}
200
201#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
202pub struct ReplyCommentParams {
203 pub id: String,
204 pub body: String,
205}
206
207#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
208pub struct ProposeResolveParams {
209 pub id: String,
210 pub note: Option<String>,
212}
213
214#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
215pub struct MarkViewedParams {
216 pub file: String,
218}
219
220#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
221pub struct WaitForFeedbackParams {
222 pub since_epoch: Option<u64>,
225 pub timeout_seconds: Option<u64>,
227}
228
229pub const fn comment_status_name(status: CommentStatus) -> &'static str {
230 match status {
231 CommentStatus::Open => "open",
232 CommentStatus::Replied => "replied",
233 CommentStatus::Resolved => "resolved",
234 }
235}
236
237pub const fn file_status_name(status: FileStatus) -> &'static str {
238 match status {
239 FileStatus::Added => "added",
240 FileStatus::Modified => "modified",
241 FileStatus::Deleted => "deleted",
242 FileStatus::Renamed => "renamed",
243 FileStatus::Untracked => "untracked",
244 }
245}
246
247pub fn render_unified(model: &DiffModel, file: Option<&str>) -> Result<String, String> {
249 use std::fmt::Write as _;
250
251 if let Some(path) = file
252 && !model.files.iter().any(|f| f.path == path)
253 {
254 return Err(format!("unknown file: {path}"));
255 }
256 let mut out = String::new();
257 for f in &model.files {
258 if file.is_some_and(|path| path != f.path) {
259 continue;
260 }
261 let old = match f.status {
262 FileStatus::Added | FileStatus::Untracked => "/dev/null".to_owned(),
263 _ => format!("a/{}", f.old_path.as_deref().unwrap_or(&f.path)),
264 };
265 let new = match f.status {
266 FileStatus::Deleted => "/dev/null".to_owned(),
267 _ => format!("b/{}", f.path),
268 };
269 let _ = writeln!(out, "--- {old}");
270 let _ = writeln!(out, "+++ {new}");
271 if f.binary {
272 out.push_str("Binary files differ\n");
273 continue;
274 }
275 for hunk in &f.hunks {
276 let _ = writeln!(out, "{}", hunk.header());
277 for line in &hunk.lines {
278 let _ = writeln!(out, "{}{}", line.kind.origin(), line.text);
279 }
280 }
281 }
282 Ok(out)
283}
284
285pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
288 let anchor = &comment.anchor;
289 let context = anchor
292 .line
293 .and_then(|line| feedback::context_snippet(model, &anchor.file, line, anchor.on_old_side))
294 .map(|snippet| {
295 snippet
296 .iter()
297 .map(|(origin, text)| format!("{origin}{text}"))
298 .collect::<Vec<_>>()
299 .join("\n")
300 });
301 let outdated = anchor.is_outdated(model);
302 CommentInfo {
303 id: comment.id.clone(),
304 source: source.key(),
305 source_label: source.label(),
306 file: anchor.file.clone(),
307 line: anchor.line,
308 line_end: anchor.line_end,
309 side: if anchor.on_old_side { "old" } else { "new" }.to_owned(),
310 body: comment.body.clone(),
311 status: comment_status_name(comment.status).to_owned(),
312 author: comment.author.clone(),
313 replies: comment
314 .replies
315 .iter()
316 .map(|r| ReplyInfo {
317 author: r.author.clone(),
318 body: r.body.clone(),
319 })
320 .collect(),
321 outdated,
322 context,
323 }
324}
325
326#[derive(Clone)]
329pub struct DifflerMcp {
330 tx: UnboundedSender<AppEvent>,
331 feedback_rx: watch::Receiver<u64>,
332 tool_router: ToolRouter<Self>,
333}
334
335fn mismatch() -> ErrorData {
336 ErrorData::internal_error("unexpected response from the diffler app", None)
337}
338
339fn parse_status(status: &str) -> Result<CommentStatus, ErrorData> {
340 match status {
341 "open" => Ok(CommentStatus::Open),
342 "replied" => Ok(CommentStatus::Replied),
343 "resolved" => Ok(CommentStatus::Resolved),
344 other => Err(ErrorData::invalid_params(
345 format!("unknown status {other:?} (expected open, replied, or resolved)"),
346 None,
347 )),
348 }
349}
350
351impl DifflerMcp {
352 pub fn new(tx: UnboundedSender<AppEvent>, feedback_rx: watch::Receiver<u64>) -> Self {
353 Self {
354 tx,
355 feedback_rx,
356 tool_router: Self::tool_router(),
357 }
358 }
359
360 async fn request(&self, kind: McpRequestKind) -> Result<McpResponse, ErrorData> {
361 self.request_with_timeout(kind, REQUEST_TIMEOUT).await
362 }
363
364 async fn request_with_timeout(
365 &self,
366 kind: McpRequestKind,
367 timeout: Duration,
368 ) -> Result<McpResponse, ErrorData> {
369 let (reply, response) = oneshot::channel();
370 self.tx
371 .send(AppEvent::Mcp(McpRequest { kind, reply }))
372 .map_err(|_| ErrorData::internal_error("the diffler TUI is not running", None))?;
373 match tokio::time::timeout(timeout, response).await {
374 Ok(Ok(McpResponse::Error(message))) => Err(ErrorData::invalid_params(message, None)),
375 Ok(Ok(response)) => Ok(response),
376 Ok(Err(_)) => Err(ErrorData::internal_error(
377 "the diffler TUI dropped the request",
378 None,
379 )),
380 Err(_) => Err(ErrorData::internal_error(
381 "diffler is busy (editor open or loop stalled) — retry",
382 None,
383 )),
384 }
385 }
386}
387
388#[tool_router]
389impl DifflerMcp {
390 #[tool(
391 description = "Current review state: repo, branch, changed files with viewed marks, comment counts, and the feedback epoch for wait_for_feedback."
392 )]
393 async fn review_status(&self) -> Result<Json<ReviewStatusResponse>, ErrorData> {
394 match self.request(McpRequestKind::ReviewStatus).await? {
395 McpResponse::Status(status) => Ok(Json(status)),
396 _ => Err(mismatch()),
397 }
398 }
399
400 #[tool(
401 description = "Unified diff text of the working tree under review, optionally restricted to one file."
402 )]
403 async fn get_diff(
404 &self,
405 Parameters(params): Parameters<GetDiffParams>,
406 ) -> Result<Json<DiffResponse>, ErrorData> {
407 let kind = McpRequestKind::GetDiff { file: params.file };
408 match self.request(kind).await? {
409 McpResponse::Diff(diff) => Ok(Json(DiffResponse { diff })),
410 _ => Err(mismatch()),
411 }
412 }
413
414 #[tool(
415 description = "Review comments across every review (working tree, commits, ranges), each tagged with its source and source_label; anchors, diff context, and threads included. Optionally filtered by status (open, replied, resolved)."
416 )]
417 async fn get_comments(
418 &self,
419 Parameters(params): Parameters<GetCommentsParams>,
420 ) -> Result<Json<CommentsResponse>, ErrorData> {
421 let status = params.status.as_deref().map(parse_status).transpose()?;
422 match self.request(McpRequestKind::GetComments { status }).await? {
423 McpResponse::Comments(comments) => Ok(Json(CommentsResponse { comments })),
424 _ => Err(mismatch()),
425 }
426 }
427
428 #[tool(
429 description = "List every review the human has — the working tree, individual commits, and commit ranges — each with its comment counts, so you can tell where feedback came from."
430 )]
431 async fn list_reviews(&self) -> Result<Json<ReviewsResponse>, ErrorData> {
432 match self.request(McpRequestKind::ListReviews).await? {
433 McpResponse::Reviews(reviews) => Ok(Json(ReviewsResponse { reviews })),
434 _ => Err(mismatch()),
435 }
436 }
437
438 #[tool(description = "Answer a review comment in place; the human sees the reply immediately.")]
439 async fn reply_comment(
440 &self,
441 Parameters(params): Parameters<ReplyCommentParams>,
442 ) -> Result<Json<ReplyResponse>, ErrorData> {
443 let kind = McpRequestKind::ReplyComment {
444 id: params.id,
445 body: params.body,
446 };
447 match self.request(kind).await? {
448 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
449 _ => Err(mismatch()),
450 }
451 }
452
453 #[tool(
454 description = "Propose a comment as resolved: marks it replied with an agent note. Only the human can resolve it, in the TUI."
455 )]
456 async fn propose_resolve(
457 &self,
458 Parameters(params): Parameters<ProposeResolveParams>,
459 ) -> Result<Json<ReplyResponse>, ErrorData> {
460 let kind = McpRequestKind::ProposeResolve {
461 id: params.id,
462 note: params.note,
463 };
464 match self.request(kind).await? {
465 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
466 _ => Err(mismatch()),
467 }
468 }
469
470 #[tool(
471 description = "Mark a file as viewed in the review the human is currently looking at (working tree, or the open commit/range diff)."
472 )]
473 async fn mark_viewed(
474 &self,
475 Parameters(params): Parameters<MarkViewedParams>,
476 ) -> Result<Json<OkResponse>, ErrorData> {
477 let kind = McpRequestKind::MarkViewed { file: params.file };
478 match self.request(kind).await? {
479 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
480 _ => Err(mismatch()),
481 }
482 }
483
484 #[tool(
485 description = "Long-poll until the human sends feedback (comments, replies, or the send key). Returns the new epoch and all open/replied comments, or timed_out."
486 )]
487 async fn wait_for_feedback(
488 &self,
489 Parameters(params): Parameters<WaitForFeedbackParams>,
490 ) -> Result<Json<WaitForFeedbackResponse>, ErrorData> {
491 let mut rx = self.feedback_rx.clone();
492 let since = params.since_epoch.unwrap_or_else(|| *rx.borrow());
493 let timeout = Duration::from_secs(
494 params
495 .timeout_seconds
496 .unwrap_or(DEFAULT_WAIT_SECONDS)
497 .min(MAX_WAIT_SECONDS),
498 );
499 let waited = tokio::time::timeout(timeout, rx.wait_for(|epoch| *epoch > since))
500 .await
501 .map(|result| result.map(|epoch| *epoch));
503 let epoch = match waited {
504 Ok(Ok(epoch)) => epoch,
505 Ok(Err(_)) => {
506 return Err(ErrorData::internal_error(
507 "the diffler TUI is not running",
508 None,
509 ));
510 }
511 Err(_) => {
512 let epoch = *rx.borrow();
513 return Ok(Json(WaitForFeedbackResponse {
514 epoch,
515 timed_out: true,
516 comments: Vec::new(),
517 }));
518 }
519 };
520 match self.request(McpRequestKind::Feedback).await? {
521 McpResponse::Comments(comments) => Ok(Json(WaitForFeedbackResponse {
522 epoch,
523 timed_out: false,
524 comments,
525 })),
526 _ => Err(mismatch()),
527 }
528 }
529}
530
531#[tool_handler(router = self.tool_router)]
532impl ServerHandler for DifflerMcp {
533 fn get_info(&self) -> ServerInfo {
534 ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
535 "diffler code review: get_comments reads the human's diff comments, \
536 reply_comment answers them in place, propose_resolve flags them as \
537 addressed, and wait_for_feedback long-polls until the human sends \
538 new feedback.",
539 )
540 }
541}
542
543pub struct McpHandle {
544 pub port: u16,
546 pub handle: JoinHandle<()>,
547}
548
549pub async fn spawn_mcp(
553 tx: UnboundedSender<AppEvent>,
554 feedback_rx: watch::Receiver<u64>,
555 port: u16,
556) -> std::io::Result<McpHandle> {
557 let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
558 Ok(listener) => listener,
559 Err(e) if e.kind() == ErrorKind::AddrInUse => {
560 tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?
561 }
562 Err(e) => return Err(e),
563 };
564 let port = listener.local_addr()?.port();
565 let service: StreamableHttpService<DifflerMcp, LocalSessionManager> =
566 StreamableHttpService::new(
567 move || Ok(DifflerMcp::new(tx.clone(), feedback_rx.clone())),
568 std::sync::Arc::default(),
569 StreamableHttpServerConfig::default(),
570 );
571 let router = axum::Router::new().nest_service("/mcp", service);
572 let handle = tokio::spawn(async move {
573 let _ = axum::serve(listener, router).await;
575 });
576 Ok(McpHandle { port, handle })
577}
578
579pub fn connect_hint(port: u16) -> String {
581 format!("mcp :{port} — claude mcp add --transport http diffler http://127.0.0.1:{port}/mcp")
582}
583
584const ENDPOINT_FILE: &str = "mcp.json";
586
587fn endpoint_path(repo_root: &Path) -> std::path::PathBuf {
588 repo_root.join(".diffler").join(ENDPOINT_FILE)
589}
590
591pub fn write_endpoint(repo_root: &Path, port: u16) -> std::io::Result<()> {
595 let dir = repo_root.join(".diffler");
596 std::fs::create_dir_all(&dir)?;
597 let gitignore = dir.join(".gitignore");
598 if !gitignore.exists() {
599 std::fs::write(&gitignore, "*\n")?;
600 }
601 let body = format!("{{\n \"port\": {port},\n \"url\": \"http://127.0.0.1:{port}/mcp\"\n}}\n");
602 std::fs::write(endpoint_path(repo_root), body)
603}
604
605pub fn clear_endpoint(repo_root: &Path) {
607 let _ = std::fs::remove_file(endpoint_path(repo_root));
608}
609
610#[cfg(test)]
611mod tests {
612 use diffler_core::model::{DiffLine, FileDiff, Hunk, HunkId, LineKind};
613 use diffler_core::session::{Anchor, Session};
614
615 use super::*;
616
617 fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
618 DiffLine::new(kind, old_no, new_no, text.to_owned())
619 }
620
621 fn sample_model() -> DiffModel {
622 DiffModel {
623 files: vec![
624 FileDiff {
625 path: "src/auth.py".into(),
626 old_path: None,
627 status: FileStatus::Modified,
628 binary: false,
629 old_text: None,
630 new_text: Some("one\nTWO\nthree\n".into()),
631 hunks: vec![Hunk {
632 id: HunkId("h1".into()),
633 old_start: 1,
634 old_lines: 3,
635 new_start: 1,
636 new_lines: 3,
637 context: String::new(),
638 lines: vec![
639 diff_line(LineKind::Context, Some(1), Some(1), "one"),
640 diff_line(LineKind::Deleted, Some(2), None, "two"),
641 diff_line(LineKind::Added, None, Some(2), "TWO"),
642 diff_line(LineKind::Context, Some(3), Some(3), "three"),
643 ],
644 }],
645 },
646 FileDiff {
647 path: "logo.png".into(),
648 old_path: None,
649 status: FileStatus::Added,
650 binary: true,
651 old_text: None,
652 new_text: None,
653 hunks: vec![],
654 },
655 ],
656 }
657 }
658
659 fn anchor(file: &str, line: Option<u32>) -> Anchor {
660 Anchor {
661 file: file.to_owned(),
662 line,
663 line_end: None,
664 on_old_side: false,
665 hunk: None,
666 line_text: None,
667 }
668 }
669
670 #[test]
671 fn render_unified_emits_headers_hunks_and_origins() {
672 let text = render_unified(&sample_model(), None).unwrap();
673 assert!(text.contains("--- a/src/auth.py\n+++ b/src/auth.py\n"));
674 assert!(text.contains("@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"));
675 assert!(text.contains("--- /dev/null\n+++ b/logo.png\nBinary files differ\n"));
676 }
677
678 #[test]
679 fn render_unified_filters_to_one_file() {
680 let text = render_unified(&sample_model(), Some("src/auth.py")).unwrap();
681 assert!(text.contains("src/auth.py"));
682 assert!(!text.contains("logo.png"));
683 }
684
685 #[test]
686 fn render_unified_unknown_file_is_an_error() {
687 let err = render_unified(&sample_model(), Some("nope.rs")).unwrap_err();
688 assert!(err.contains("nope.rs"));
689 }
690
691 #[test]
692 fn render_unified_marks_deleted_files_with_dev_null() {
693 let mut model = sample_model();
694 model.files[0].status = FileStatus::Deleted;
695 let text = render_unified(&model, Some("src/auth.py")).unwrap();
696 assert!(text.contains("--- a/src/auth.py\n+++ /dev/null\n"));
697 }
698
699 #[test]
700 fn render_unified_renamed_file_pairs_old_and_new_paths() {
701 let mut model = sample_model();
702 model.files[0].status = FileStatus::Renamed;
703 model.files[0].old_path = Some("src/auth_v1.py".to_owned());
704 let text = render_unified(&model, Some("src/auth.py")).unwrap();
705 assert!(
706 text.contains("--- a/src/auth_v1.py\n+++ b/src/auth.py\n"),
707 "rename header must pair both paths: {text}"
708 );
709 }
710
711 #[test]
712 fn render_unified_empty_model_is_an_empty_diff() {
713 let model = DiffModel { files: vec![] };
714 assert_eq!(render_unified(&model, None).unwrap(), "");
715 }
716
717 #[test]
718 fn comment_info_old_side_anchor_reports_side_and_context() {
719 let mut session = Session::default();
720 let mut a = anchor("src/auth.py", Some(2));
721 a.on_old_side = true;
722 a.line_text = Some("two".to_owned());
723 session.add_comment("human", a, "what was wrong with two?");
724 let info = comment_info(
725 &session.comments[0],
726 &sample_model(),
727 &ReviewSource::WorkingTree,
728 );
729 assert_eq!(info.side, "old");
730 assert_eq!(info.line, Some(2));
731 assert_eq!(info.line_end, None);
732 assert!(!info.outdated, "the deleted line is still in the diff");
733 assert_eq!(info.context.as_deref(), Some(" one\n-two\n+TWO"));
734 }
735
736 #[test]
737 fn comment_info_carries_context_and_thread() {
738 let mut session = Session::default();
739 let mut a = anchor("src/auth.py", Some(2));
740 a.line_text = Some("TWO".to_owned());
741 let id = session.add_comment("human", a, "why uppercase?").id.clone();
742 session.reply(&id, AGENT_AUTHOR, "legacy API");
743 let info = comment_info(
744 &session.comments[0],
745 &sample_model(),
746 &ReviewSource::WorkingTree,
747 );
748 assert_eq!(info.file, "src/auth.py");
749 assert_eq!(info.line, Some(2));
750 assert_eq!(info.side, "new");
751 assert_eq!(info.status, "replied");
752 assert_eq!(info.context.as_deref(), Some("-two\n+TWO\n three"));
753 assert!(!info.outdated);
754 assert_eq!(info.replies.len(), 1);
755 assert_eq!(info.replies[0].author, AGENT_AUTHOR);
756 }
757
758 #[test]
759 fn comment_info_flags_departed_lines_outdated() {
760 let mut session = Session::default();
761 session.add_comment("human", anchor("src/auth.py", Some(99)), "moved on");
762 let info = comment_info(
763 &session.comments[0],
764 &sample_model(),
765 &ReviewSource::WorkingTree,
766 );
767 assert!(info.outdated);
768 assert_eq!(info.context, None);
769 }
770
771 #[test]
772 fn comment_info_flags_drifted_line_text_outdated() {
773 let mut session = Session::default();
774 let mut a = anchor("src/auth.py", Some(2));
775 a.line_text = Some("old text".to_owned());
776 session.add_comment("human", a, "stale");
777 let info = comment_info(
778 &session.comments[0],
779 &sample_model(),
780 &ReviewSource::WorkingTree,
781 );
782 assert!(info.outdated);
783 assert!(info.context.is_some(), "context still renders");
784 }
785
786 #[test]
787 fn file_level_comment_outdated_only_when_file_left_the_diff() {
788 let mut session = Session::default();
789 session.add_comment("human", anchor("src/auth.py", None), "overall");
790 session.add_comment("human", anchor("gone.py", None), "gone");
791 let model = sample_model();
792 assert!(!comment_info(&session.comments[0], &model, &ReviewSource::WorkingTree).outdated);
793 assert!(comment_info(&session.comments[1], &model, &ReviewSource::WorkingTree).outdated);
794 }
795
796 #[test]
800 fn range_comment_not_outdated_when_end_line_matches() {
801 let mut session = Session::default();
802 let mut a = Anchor {
804 file: "src/auth.py".to_owned(),
805 line: Some(1),
806 line_end: Some(3),
807 on_old_side: false,
808 hunk: None,
809 line_text: Some("three".to_owned()),
810 };
811 assert_ne!(a.line_text.as_deref(), Some("one"));
813 session.add_comment("human", a.clone(), "range comment");
814 let info = comment_info(
815 &session.comments[0],
816 &sample_model(),
817 &ReviewSource::WorkingTree,
818 );
819 assert!(
820 !info.outdated,
821 "end line text matches snapshot — must NOT be outdated"
822 );
823 a.line_text = Some("changed".to_owned());
825 session.comments[0].anchor = a;
826 let info2 = comment_info(
827 &session.comments[0],
828 &sample_model(),
829 &ReviewSource::WorkingTree,
830 );
831 assert!(info2.outdated, "end line text drifted — must be outdated");
832 }
833
834 #[tokio::test]
837 async fn request_times_out_when_app_does_not_answer() {
838 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
839 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
840 let handler = DifflerMcp::new(tx, feedback_rx);
841 let err = handler
842 .request_with_timeout(McpRequestKind::ReviewStatus, Duration::from_millis(50))
843 .await
844 .unwrap_err();
845 assert!(
846 err.message.contains("busy"),
847 "error should mention busy: {err:?}"
848 );
849 }
850
851 #[tokio::test(start_paused = true)]
855 async fn wait_for_feedback_clamps_the_timeout_to_the_cap() {
856 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
857 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
858 let handler = DifflerMcp::new(tx, feedback_rx);
859 let started = tokio::time::Instant::now();
860 let Json(response) = handler
861 .wait_for_feedback(Parameters(WaitForFeedbackParams {
862 since_epoch: None,
863 timeout_seconds: Some(MAX_WAIT_SECONDS * 10),
864 }))
865 .await
866 .unwrap();
867 assert!(response.timed_out, "no feedback ever arrives");
868 assert_eq!(
869 started.elapsed(),
870 Duration::from_secs(MAX_WAIT_SECONDS),
871 "a timeout above the cap must clamp to {MAX_WAIT_SECONDS}s"
872 );
873 }
874
875 #[test]
876 fn endpoint_file_publishes_the_port_and_clears() {
877 let dir = tempfile::tempdir().expect("tempdir");
878 write_endpoint(dir.path(), 8417).expect("write");
879 let path = dir.path().join(".diffler/mcp.json");
880 let body = std::fs::read_to_string(&path).expect("read");
881 assert!(body.contains("\"port\": 8417"), "{body}");
882 assert!(body.contains("http://127.0.0.1:8417/mcp"), "{body}");
883 let gitignore =
885 std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("gitignore");
886 assert_eq!(gitignore, "*\n");
887 clear_endpoint(dir.path());
888 assert!(!path.exists(), "endpoint file removed on shutdown");
889 }
890
891 #[tokio::test]
893 async fn spawn_mcp_fallback_on_addr_in_use() {
894 let holder = tokio::net::TcpListener::bind(("127.0.0.1", 0))
896 .await
897 .unwrap();
898 let busy_port = holder.local_addr().unwrap().port();
899 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
900 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
901 let handle = spawn_mcp(tx, feedback_rx, busy_port).await.unwrap();
902 assert_ne!(
903 handle.port, busy_port,
904 "should have bound to a different ephemeral port"
905 );
906 handle.handle.abort();
907 }
908}