1use std::fmt;
8use std::io::ErrorKind;
9use std::net::SocketAddr;
10use std::path::Path;
11use std::time::Duration;
12
13use diffler_core::feedback;
14use diffler_core::model::{DiffModel, FileStatus};
15use diffler_core::session::{Comment, CommentStatus};
16use diffler_core::source::ReviewSource;
17use rmcp::handler::server::router::prompt::PromptRouter;
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{PromptMessage, Role, ServerCapabilities, ServerInfo};
21use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
22use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
23use rmcp::{
24 ErrorData, Json, ServerHandler, prompt, prompt_handler, prompt_router, schemars, tool,
25 tool_handler, tool_router,
26};
27use serde::Serialize;
28use tokio::sync::mpsc::UnboundedSender;
29use tokio::sync::{oneshot, watch};
30use tokio::task::JoinHandle;
31
32use crate::event::AppEvent;
33
34pub const AGENT_AUTHOR: &str = "agent";
36
37const DEFAULT_WAIT_SECONDS: u64 = 25;
42const MAX_WAIT_SECONDS: u64 = 540;
43pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
46
47pub struct McpRequest {
49 pub kind: McpRequestKind,
50 pub reply: oneshot::Sender<McpResponse>,
51}
52
53impl fmt::Debug for McpRequest {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("McpRequest")
56 .field("kind", &self.kind)
57 .finish_non_exhaustive()
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum McpRequestKind {
63 ReviewStatus,
64 GetDiff {
65 file: Option<String>,
66 },
67 GetComments {
68 status: Option<CommentStatus>,
69 },
70 ListReviews,
73 ReplyComment {
74 id: String,
75 body: String,
76 },
77 ProposeResolve {
78 id: String,
79 note: Option<String>,
80 },
81 MarkViewed {
82 file: String,
83 },
84 Feedback,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum McpResponse {
90 Status(ReviewStatusResponse),
91 Diff(String),
92 Comments(Vec<CommentInfo>),
93 Reviews(Vec<ReviewSummary>),
94 Replied {
95 status: String,
96 },
97 Ok,
98 Error(String),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
103pub struct ReviewStatusResponse {
104 pub repo: String,
105 pub branch: Option<String>,
106 pub oid7: String,
107 pub files_changed: Vec<FileEntry>,
108 #[schemars(with = "Count")]
109 pub open_comments: usize,
110 #[schemars(with = "Count")]
111 pub replied_comments: usize,
112 #[schemars(with = "Count")]
113 pub resolved_comments: usize,
114 #[schemars(with = "Count")]
115 pub feedback_epoch: u64,
116 pub reviews: Vec<ReviewSummary>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
124pub struct ReviewSummary {
125 pub source: String,
127 pub label: String,
129 #[schemars(with = "Count")]
130 pub open_comments: usize,
131 #[schemars(with = "Count")]
132 pub replied_comments: usize,
133 #[schemars(with = "Count")]
134 pub resolved_comments: usize,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
138pub struct FileEntry {
139 pub path: String,
140 pub status: String,
141 pub viewed: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
145pub struct DiffResponse {
146 pub diff: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
150pub struct CommentInfo {
151 pub id: String,
152 pub source: String,
154 pub source_label: String,
156 pub file: String,
157 #[schemars(with = "Option<Count>")]
158 pub line: Option<u32>,
159 #[schemars(with = "Option<Count>")]
160 pub line_end: Option<u32>,
161 pub side: String,
163 pub body: String,
164 pub status: String,
165 pub author: String,
166 pub replies: Vec<ReplyInfo>,
167 pub outdated: bool,
169 pub context: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
174pub struct ReplyInfo {
175 pub author: String,
176 pub body: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
180pub struct CommentsResponse {
181 pub comments: Vec<CommentInfo>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
185pub struct ReviewsResponse {
186 pub reviews: Vec<ReviewSummary>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
190pub struct ReplyResponse {
191 pub ok: bool,
192 pub status: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
196pub struct OkResponse {
197 pub ok: bool,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
201pub struct WaitForFeedbackResponse {
202 #[schemars(with = "Count")]
203 pub epoch: u64,
204 pub timed_out: bool,
205 pub comments: Vec<CommentInfo>,
206}
207
208#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
209pub struct GetDiffParams {
210 pub file: Option<String>,
212}
213
214#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
215pub struct GetCommentsParams {
216 pub status: Option<String>,
218}
219
220#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
221pub struct ReplyCommentParams {
222 pub id: String,
223 pub body: String,
224}
225
226#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
227pub struct ProposeResolveParams {
228 pub id: String,
229 pub note: Option<String>,
231}
232
233#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
234pub struct MarkViewedParams {
235 pub file: String,
237}
238
239#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
240pub struct WaitForFeedbackParams {
241 #[schemars(with = "Option<Count>")]
244 pub since_epoch: Option<u64>,
245 #[schemars(with = "Option<Count>")]
248 pub timeout_seconds: Option<u64>,
249}
250
251struct Count;
257
258impl schemars::JsonSchema for Count {
259 fn inline_schema() -> bool {
260 true
261 }
262
263 fn schema_name() -> std::borrow::Cow<'static, str> {
264 "Count".into()
265 }
266
267 fn schema_id() -> std::borrow::Cow<'static, str> {
268 "diffler::mcp::Count".into()
269 }
270
271 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
272 schemars::json_schema!({ "type": "integer", "minimum": 0 })
273 }
274}
275
276pub const fn comment_status_name(status: CommentStatus) -> &'static str {
277 match status {
278 CommentStatus::Open => "open",
279 CommentStatus::Replied => "replied",
280 CommentStatus::Resolved => "resolved",
281 }
282}
283
284pub const fn file_status_name(status: FileStatus) -> &'static str {
285 match status {
286 FileStatus::Added => "added",
287 FileStatus::Modified => "modified",
288 FileStatus::Deleted => "deleted",
289 FileStatus::Renamed => "renamed",
290 FileStatus::Untracked => "untracked",
291 }
292}
293
294pub fn render_unified(model: &DiffModel, file: Option<&str>) -> Result<String, String> {
296 use std::fmt::Write as _;
297
298 if let Some(path) = file
299 && !model.files.iter().any(|f| f.path == path)
300 {
301 return Err(format!("unknown file: {path}"));
302 }
303 let mut out = String::new();
304 for f in &model.files {
305 if file.is_some_and(|path| path != f.path) {
306 continue;
307 }
308 let old = match f.status {
309 FileStatus::Added | FileStatus::Untracked => "/dev/null".to_owned(),
310 _ => format!("a/{}", f.old_path.as_deref().unwrap_or(&f.path)),
311 };
312 let new = match f.status {
313 FileStatus::Deleted => "/dev/null".to_owned(),
314 _ => format!("b/{}", f.path),
315 };
316 let _ = writeln!(out, "--- {old}");
317 let _ = writeln!(out, "+++ {new}");
318 if f.binary {
319 out.push_str("Binary files differ\n");
320 continue;
321 }
322 for hunk in &f.hunks {
323 let _ = writeln!(out, "{}", hunk.header());
324 for line in &hunk.lines {
325 let _ = writeln!(out, "{}{}", line.kind.origin(), line.text);
326 }
327 }
328 }
329 Ok(out)
330}
331
332pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
335 let anchor = &comment.anchor;
336 let context = anchor
339 .line
340 .and_then(|line| feedback::context_snippet(model, &anchor.file, line, anchor.on_old_side))
341 .map(|snippet| {
342 snippet
343 .iter()
344 .map(|(origin, text)| format!("{origin}{text}"))
345 .collect::<Vec<_>>()
346 .join("\n")
347 });
348 let outdated = anchor.is_outdated(model);
349 CommentInfo {
350 id: comment.id.clone(),
351 source: source.key(),
352 source_label: source.label(),
353 file: anchor.file.clone(),
354 line: anchor.line,
355 line_end: anchor.line_end,
356 side: if anchor.on_old_side { "old" } else { "new" }.to_owned(),
357 body: comment.body.clone(),
358 status: comment_status_name(comment.status).to_owned(),
359 author: comment.author.clone(),
360 replies: comment
361 .replies
362 .iter()
363 .map(|r| ReplyInfo {
364 author: r.author.clone(),
365 body: r.body.clone(),
366 })
367 .collect(),
368 outdated,
369 context,
370 }
371}
372
373#[derive(Clone)]
376pub struct DifflerMcp {
377 tx: UnboundedSender<AppEvent>,
378 feedback_rx: watch::Receiver<u64>,
379 tool_router: ToolRouter<Self>,
380 prompt_router: PromptRouter<Self>,
381}
382
383fn mismatch() -> ErrorData {
384 ErrorData::internal_error("unexpected response from the diffler app", None)
385}
386
387fn parse_status(status: &str) -> Result<CommentStatus, ErrorData> {
388 match status {
389 "open" => Ok(CommentStatus::Open),
390 "replied" => Ok(CommentStatus::Replied),
391 "resolved" => Ok(CommentStatus::Resolved),
392 other => Err(ErrorData::invalid_params(
393 format!("unknown status {other:?} (expected open, replied, or resolved)"),
394 None,
395 )),
396 }
397}
398
399impl DifflerMcp {
400 pub fn new(tx: UnboundedSender<AppEvent>, feedback_rx: watch::Receiver<u64>) -> Self {
401 Self {
402 tx,
403 feedback_rx,
404 tool_router: Self::tool_router(),
405 prompt_router: Self::prompt_router(),
406 }
407 }
408
409 async fn request(&self, kind: McpRequestKind) -> Result<McpResponse, ErrorData> {
410 self.request_with_timeout(kind, REQUEST_TIMEOUT).await
411 }
412
413 async fn request_with_timeout(
414 &self,
415 kind: McpRequestKind,
416 timeout: Duration,
417 ) -> Result<McpResponse, ErrorData> {
418 let (reply, response) = oneshot::channel();
419 self.tx
420 .send(AppEvent::Mcp(McpRequest { kind, reply }))
421 .map_err(|_| ErrorData::internal_error("the diffler TUI is not running", None))?;
422 match tokio::time::timeout(timeout, response).await {
423 Ok(Ok(McpResponse::Error(message))) => Err(ErrorData::invalid_params(message, None)),
424 Ok(Ok(response)) => Ok(response),
425 Ok(Err(_)) => Err(ErrorData::internal_error(
426 "the diffler TUI dropped the request",
427 None,
428 )),
429 Err(_) => Err(ErrorData::internal_error(
430 "diffler is busy (editor open or loop stalled), retry",
431 None,
432 )),
433 }
434 }
435}
436
437#[tool_router]
438impl DifflerMcp {
439 #[tool(
440 description = "Current review state: repo, branch, changed files with viewed marks, comment counts, and the feedback epoch for wait_for_feedback."
441 )]
442 async fn review_status(&self) -> Result<Json<ReviewStatusResponse>, ErrorData> {
443 match self.request(McpRequestKind::ReviewStatus).await? {
444 McpResponse::Status(status) => Ok(Json(status)),
445 _ => Err(mismatch()),
446 }
447 }
448
449 #[tool(
450 description = "Unified diff text of the working tree under review, optionally restricted to one file."
451 )]
452 async fn get_diff(
453 &self,
454 Parameters(params): Parameters<GetDiffParams>,
455 ) -> Result<Json<DiffResponse>, ErrorData> {
456 let kind = McpRequestKind::GetDiff { file: params.file };
457 match self.request(kind).await? {
458 McpResponse::Diff(diff) => Ok(Json(DiffResponse { diff })),
459 _ => Err(mismatch()),
460 }
461 }
462
463 #[tool(
464 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)."
465 )]
466 async fn get_comments(
467 &self,
468 Parameters(params): Parameters<GetCommentsParams>,
469 ) -> Result<Json<CommentsResponse>, ErrorData> {
470 let status = params.status.as_deref().map(parse_status).transpose()?;
471 match self.request(McpRequestKind::GetComments { status }).await? {
472 McpResponse::Comments(comments) => Ok(Json(CommentsResponse { comments })),
473 _ => Err(mismatch()),
474 }
475 }
476
477 #[tool(
478 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."
479 )]
480 async fn list_reviews(&self) -> Result<Json<ReviewsResponse>, ErrorData> {
481 match self.request(McpRequestKind::ListReviews).await? {
482 McpResponse::Reviews(reviews) => Ok(Json(ReviewsResponse { reviews })),
483 _ => Err(mismatch()),
484 }
485 }
486
487 #[tool(description = "Answer a review comment in place; the human sees the reply immediately.")]
488 async fn reply_comment(
489 &self,
490 Parameters(params): Parameters<ReplyCommentParams>,
491 ) -> Result<Json<ReplyResponse>, ErrorData> {
492 let kind = McpRequestKind::ReplyComment {
493 id: params.id,
494 body: params.body,
495 };
496 match self.request(kind).await? {
497 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
498 _ => Err(mismatch()),
499 }
500 }
501
502 #[tool(
503 description = "Flag a comment as addressed: marks it replied. Writes nothing into the thread, so call it after reply_comment rather than repeating the answer. The optional note is used only when you did not reply. Only the human can resolve it, in the TUI."
504 )]
505 async fn propose_resolve(
506 &self,
507 Parameters(params): Parameters<ProposeResolveParams>,
508 ) -> Result<Json<ReplyResponse>, ErrorData> {
509 let kind = McpRequestKind::ProposeResolve {
510 id: params.id,
511 note: params.note,
512 };
513 match self.request(kind).await? {
514 McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
515 _ => Err(mismatch()),
516 }
517 }
518
519 #[tool(
520 description = "Mark a file as viewed in the review the human is currently looking at (working tree, or the open commit/range diff)."
521 )]
522 async fn mark_viewed(
523 &self,
524 Parameters(params): Parameters<MarkViewedParams>,
525 ) -> Result<Json<OkResponse>, ErrorData> {
526 let kind = McpRequestKind::MarkViewed { file: params.file };
527 match self.request(kind).await? {
528 McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
529 _ => Err(mismatch()),
530 }
531 }
532
533 #[tool(
534 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. A timed_out result means the human is still reviewing: call again with the epoch it returned to keep waiting."
535 )]
536 async fn wait_for_feedback(
537 &self,
538 Parameters(params): Parameters<WaitForFeedbackParams>,
539 ) -> Result<Json<WaitForFeedbackResponse>, ErrorData> {
540 let mut rx = self.feedback_rx.clone();
541 let since = params.since_epoch.unwrap_or_else(|| *rx.borrow());
542 let timeout = Duration::from_secs(
543 params
544 .timeout_seconds
545 .unwrap_or(DEFAULT_WAIT_SECONDS)
546 .min(MAX_WAIT_SECONDS),
547 );
548 let waited = tokio::time::timeout(timeout, rx.wait_for(|epoch| *epoch > since))
549 .await
550 .map(|result| result.map(|epoch| *epoch));
552 let epoch = match waited {
553 Ok(Ok(epoch)) => epoch,
554 Ok(Err(_)) => {
555 return Err(ErrorData::internal_error(
556 "the diffler TUI is not running",
557 None,
558 ));
559 }
560 Err(_) => {
561 let epoch = *rx.borrow();
562 return Ok(Json(WaitForFeedbackResponse {
563 epoch,
564 timed_out: true,
565 comments: Vec::new(),
566 }));
567 }
568 };
569 match self.request(McpRequestKind::Feedback).await? {
570 McpResponse::Comments(comments) => Ok(Json(WaitForFeedbackResponse {
571 epoch,
572 timed_out: false,
573 comments,
574 })),
575 _ => Err(mismatch()),
576 }
577 }
578}
579
580#[prompt_router]
584impl DifflerMcp {
585 #[prompt(
586 name = "review",
587 description = "Check the diffler review: read the human's open comments, address them in code, reply, and wait for the next round."
588 )]
589 async fn review(&self) -> Vec<PromptMessage> {
590 vec![PromptMessage::new_text(
591 Role::User,
592 "Check the diffler review and respond to the human's feedback:\n\
593 1. Call review_status for the active review and its changed files.\n\
594 2. Call get_comments with status \"open\" and read each comment in place.\n\
595 3. Address every comment in the code it anchors to.\n\
596 4. Answer each with reply_comment (what you changed and why), then \
597 propose_resolve to flag it addressed. That flag adds no text: do \
598 not summarise the answer you just gave. Only the human resolves \
599 for real, in the TUI.\n\
600 5. Call wait_for_feedback with the latest epoch and start over when it \
601 returns: the human just sent new feedback. It answers within a minute; \
602 a timed_out result means they are still reviewing, so call it again. \
603 If the call itself fails, check review_status: diffler is closed only \
604 when that fails too, otherwise keep waiting.",
605 )]
606 }
607}
608
609#[tool_handler(router = self.tool_router)]
610#[prompt_handler(router = self.prompt_router)]
611impl ServerHandler for DifflerMcp {
612 fn get_info(&self) -> ServerInfo {
613 ServerInfo::new(
614 ServerCapabilities::builder()
615 .enable_tools()
616 .enable_prompts()
617 .build(),
618 )
619 .with_instructions(
620 "diffler code review: get_comments reads the human's diff comments, \
621 reply_comment answers them in place, propose_resolve flags an \
622 answered one as addressed without adding text, and \
623 wait_for_feedback long-polls until the human sends \
624 new feedback. The review prompt packages that loop as a command.",
625 )
626 }
627}
628
629pub struct McpHandle {
630 pub port: u16,
632 pub handle: JoinHandle<()>,
633}
634
635fn bind_reusable(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
639 let socket = tokio::net::TcpSocket::new_v4()?;
640 #[cfg(unix)]
641 socket.set_reuseaddr(true)?;
642 socket.bind(addr)?;
643 socket.listen(1024)
644}
645
646pub fn spawn_mcp(
650 tx: UnboundedSender<AppEvent>,
651 feedback_rx: watch::Receiver<u64>,
652 port: u16,
653) -> std::io::Result<McpHandle> {
654 let listener = match bind_reusable(SocketAddr::from(([127, 0, 0, 1], port))) {
655 Ok(listener) => listener,
656 Err(e) if e.kind() == ErrorKind::AddrInUse => {
657 bind_reusable(SocketAddr::from(([127, 0, 0, 1], 0)))?
658 }
659 Err(e) => return Err(e),
660 };
661 let port = listener.local_addr()?.port();
662 let service: StreamableHttpService<DifflerMcp, LocalSessionManager> =
663 StreamableHttpService::new(
664 move || Ok(DifflerMcp::new(tx.clone(), feedback_rx.clone())),
665 std::sync::Arc::default(),
666 StreamableHttpServerConfig::default(),
667 );
668 let router = axum::Router::new().nest_service("/mcp", service);
669 let handle = tokio::spawn(async move {
670 let _ = axum::serve(listener, router).await;
672 });
673 Ok(McpHandle { port, handle })
674}
675
676const ENDPOINT_FILE: &str = "mcp.json";
678
679fn endpoint_path(repo_root: &Path) -> std::path::PathBuf {
680 repo_root.join(".diffler").join(ENDPOINT_FILE)
681}
682
683pub fn write_endpoint(repo_root: &Path, port: u16) -> std::io::Result<()> {
687 let dir = repo_root.join(".diffler");
688 std::fs::create_dir_all(&dir)?;
689 let gitignore = dir.join(".gitignore");
690 if !gitignore.exists() {
691 std::fs::write(&gitignore, "*\n")?;
692 }
693 let body = format!("{{\n \"port\": {port},\n \"url\": \"http://127.0.0.1:{port}/mcp\"\n}}\n");
694 std::fs::write(endpoint_path(repo_root), body)
695}
696
697pub fn clear_endpoint(repo_root: &Path, port: u16) {
702 let path = endpoint_path(repo_root);
703 let Ok(body) = std::fs::read_to_string(&path) else {
704 return;
705 };
706 let current_owner = serde_json::from_str::<serde_json::Value>(&body)
707 .ok()
708 .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
709 if current_owner == Some(u64::from(port)) {
710 let _ = std::fs::remove_file(&path);
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use diffler_core::model::{DiffLine, FileDiff, Hunk, HunkId, LineKind};
717 use diffler_core::session::{Anchor, Session};
718
719 use super::*;
720
721 fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
722 DiffLine::new(kind, old_no, new_no, text.to_owned())
723 }
724
725 fn sample_model() -> DiffModel {
726 DiffModel {
727 files: vec![
728 FileDiff {
729 path: "src/auth.py".into(),
730 old_path: None,
731 status: FileStatus::Modified,
732 binary: false,
733 old_text: None,
734 new_text: Some("one\nTWO\nthree\n".into()),
735 hunks: vec![Hunk {
736 id: HunkId("h1".into()),
737 old_start: 1,
738 old_lines: 3,
739 new_start: 1,
740 new_lines: 3,
741 context: String::new(),
742 lines: vec![
743 diff_line(LineKind::Context, Some(1), Some(1), "one"),
744 diff_line(LineKind::Deleted, Some(2), None, "two"),
745 diff_line(LineKind::Added, None, Some(2), "TWO"),
746 diff_line(LineKind::Context, Some(3), Some(3), "three"),
747 ],
748 }],
749 hashes: diffler_core::model::HashCache::default(),
750 },
751 FileDiff {
752 path: "logo.png".into(),
753 old_path: None,
754 status: FileStatus::Added,
755 binary: true,
756 old_text: None,
757 new_text: None,
758 hunks: vec![],
759 hashes: diffler_core::model::HashCache::default(),
760 },
761 ],
762 }
763 }
764
765 fn anchor(file: &str, line: Option<u32>) -> Anchor {
766 Anchor {
767 file: file.to_owned(),
768 line,
769 line_end: None,
770 on_old_side: false,
771 line_text: None,
772 }
773 }
774
775 #[test]
776 fn render_unified_emits_headers_hunks_and_origins() {
777 let text = render_unified(&sample_model(), None).unwrap();
778 assert!(text.contains("--- a/src/auth.py\n+++ b/src/auth.py\n"));
779 assert!(text.contains("@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"));
780 assert!(text.contains("--- /dev/null\n+++ b/logo.png\nBinary files differ\n"));
781 }
782
783 #[test]
784 fn render_unified_filters_to_one_file() {
785 let text = render_unified(&sample_model(), Some("src/auth.py")).unwrap();
786 assert!(text.contains("src/auth.py"));
787 assert!(!text.contains("logo.png"));
788 }
789
790 #[test]
791 fn render_unified_unknown_file_is_an_error() {
792 let err = render_unified(&sample_model(), Some("nope.rs")).unwrap_err();
793 assert!(err.contains("nope.rs"));
794 }
795
796 #[test]
797 fn render_unified_marks_deleted_files_with_dev_null() {
798 let mut model = sample_model();
799 model.files[0].status = FileStatus::Deleted;
800 let text = render_unified(&model, Some("src/auth.py")).unwrap();
801 assert!(text.contains("--- a/src/auth.py\n+++ /dev/null\n"));
802 }
803
804 #[test]
805 fn render_unified_renamed_file_pairs_old_and_new_paths() {
806 let mut model = sample_model();
807 model.files[0].status = FileStatus::Renamed;
808 model.files[0].old_path = Some("src/auth_v1.py".to_owned());
809 let text = render_unified(&model, Some("src/auth.py")).unwrap();
810 assert!(
811 text.contains("--- a/src/auth_v1.py\n+++ b/src/auth.py\n"),
812 "rename header must pair both paths: {text}"
813 );
814 }
815
816 #[test]
817 fn render_unified_empty_model_is_an_empty_diff() {
818 let model = DiffModel { files: vec![] };
819 assert_eq!(render_unified(&model, None).unwrap(), "");
820 }
821
822 #[test]
823 fn comment_info_old_side_anchor_reports_side_and_context() {
824 let mut session = Session::default();
825 let mut a = anchor("src/auth.py", Some(2));
826 a.on_old_side = true;
827 a.line_text = Some("two".to_owned());
828 session.add_comment(a, "human", "what was wrong with two?");
829 let info = comment_info(
830 &session.comments[0],
831 &sample_model(),
832 &ReviewSource::WorkingTree,
833 );
834 assert_eq!(info.side, "old");
835 assert_eq!(info.line, Some(2));
836 assert_eq!(info.line_end, None);
837 assert!(!info.outdated, "the deleted line is still in the diff");
838 assert_eq!(info.context.as_deref(), Some(" one\n-two\n+TWO"));
839 }
840
841 #[test]
842 fn comment_info_carries_context_and_thread() {
843 let mut session = Session::default();
844 let mut a = anchor("src/auth.py", Some(2));
845 a.line_text = Some("TWO".to_owned());
846 let id = session.add_comment(a, "human", "why uppercase?").id.clone();
847 session.reply(&id, AGENT_AUTHOR, "legacy API");
848 let info = comment_info(
849 &session.comments[0],
850 &sample_model(),
851 &ReviewSource::WorkingTree,
852 );
853 assert_eq!(info.file, "src/auth.py");
854 assert_eq!(info.line, Some(2));
855 assert_eq!(info.side, "new");
856 assert_eq!(info.status, "replied");
857 assert_eq!(info.context.as_deref(), Some("-two\n+TWO\n three"));
858 assert!(!info.outdated);
859 assert_eq!(info.replies.len(), 1);
860 assert_eq!(info.replies[0].author, AGENT_AUTHOR);
861 }
862
863 #[test]
864 fn comment_info_flags_departed_lines_outdated() {
865 let mut session = Session::default();
866 session.add_comment(anchor("src/auth.py", Some(99)), "human", "moved on");
867 let info = comment_info(
868 &session.comments[0],
869 &sample_model(),
870 &ReviewSource::WorkingTree,
871 );
872 assert!(info.outdated);
873 assert_eq!(info.context, None);
874 }
875
876 #[test]
877 fn comment_info_flags_drifted_line_text_outdated() {
878 let mut session = Session::default();
879 let mut a = anchor("src/auth.py", Some(2));
880 a.line_text = Some("old text".to_owned());
881 session.add_comment(a, "human", "stale");
882 let info = comment_info(
883 &session.comments[0],
884 &sample_model(),
885 &ReviewSource::WorkingTree,
886 );
887 assert!(info.outdated);
888 assert!(info.context.is_some(), "context still renders");
889 }
890
891 #[test]
892 fn file_level_comment_outdated_only_when_file_left_the_diff() {
893 let mut session = Session::default();
894 session.add_comment(anchor("src/auth.py", None), "human", "overall");
895 session.add_comment(anchor("gone.py", None), "human", "gone");
896 let model = sample_model();
897 assert!(!comment_info(&session.comments[0], &model, &ReviewSource::WorkingTree).outdated);
898 assert!(comment_info(&session.comments[1], &model, &ReviewSource::WorkingTree).outdated);
899 }
900
901 #[test]
905 fn range_comment_not_outdated_when_end_line_matches() {
906 let mut session = Session::default();
907 let mut a = Anchor {
909 file: "src/auth.py".to_owned(),
910 line: Some(1),
911 line_end: Some(3),
912 on_old_side: false,
913 line_text: Some("three".to_owned()),
914 };
915 assert_ne!(a.line_text.as_deref(), Some("one"));
917 session.add_comment(a.clone(), "human", "range comment");
918 let info = comment_info(
919 &session.comments[0],
920 &sample_model(),
921 &ReviewSource::WorkingTree,
922 );
923 assert!(
924 !info.outdated,
925 "end line text matches snapshot: must NOT be outdated"
926 );
927 a.line_text = Some("changed".to_owned());
929 session.comments[0].anchor = a;
930 let info2 = comment_info(
931 &session.comments[0],
932 &sample_model(),
933 &ReviewSource::WorkingTree,
934 );
935 assert!(info2.outdated, "end line text drifted: must be outdated");
936 }
937
938 #[tokio::test]
941 async fn request_times_out_when_app_does_not_answer() {
942 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
943 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
944 let handler = DifflerMcp::new(tx, feedback_rx);
945 let err = handler
946 .request_with_timeout(McpRequestKind::ReviewStatus, Duration::from_millis(50))
947 .await
948 .unwrap_err();
949 assert!(
950 err.message.contains("busy"),
951 "error should mention busy: {err:?}"
952 );
953 }
954
955 #[tokio::test(start_paused = true)]
959 async fn wait_for_feedback_clamps_the_timeout_to_the_cap() {
960 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
961 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
962 let handler = DifflerMcp::new(tx, feedback_rx);
963 let started = tokio::time::Instant::now();
964 let Json(response) = handler
965 .wait_for_feedback(Parameters(WaitForFeedbackParams {
966 since_epoch: None,
967 timeout_seconds: Some(MAX_WAIT_SECONDS * 10),
968 }))
969 .await
970 .unwrap();
971 assert!(response.timed_out, "no feedback ever arrives");
972 assert_eq!(
973 started.elapsed(),
974 Duration::from_secs(MAX_WAIT_SECONDS),
975 "a timeout above the cap must clamp to {MAX_WAIT_SECONDS}s"
976 );
977 }
978
979 #[tokio::test(start_paused = true)]
980 async fn wait_for_feedback_defaults_inside_the_client_request_timeout() {
981 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
982 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
983 let handler = DifflerMcp::new(tx, feedback_rx);
984 let started = tokio::time::Instant::now();
985 let Json(response) = handler
986 .wait_for_feedback(Parameters(WaitForFeedbackParams {
987 since_epoch: None,
988 timeout_seconds: None,
989 }))
990 .await
991 .unwrap();
992 assert!(response.timed_out, "no feedback ever arrives");
993 assert!(
994 started.elapsed() + REQUEST_TIMEOUT < Duration::from_secs(60),
995 "the wait plus the reply it still owes must beat a 60 s client cut"
996 );
997 }
998
999 #[test]
1000 fn endpoint_file_publishes_the_port_and_clears() {
1001 let dir = tempfile::tempdir().expect("tempdir");
1002 write_endpoint(dir.path(), 8417).expect("write");
1003 let path = dir.path().join(".diffler/mcp.json");
1004 let body = std::fs::read_to_string(&path).expect("read");
1005 assert!(body.contains("\"port\": 8417"), "{body}");
1006 assert!(body.contains("http://127.0.0.1:8417/mcp"), "{body}");
1007 let gitignore =
1009 std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("gitignore");
1010 assert_eq!(gitignore, "*\n");
1011 clear_endpoint(dir.path(), 8417);
1012 assert!(!path.exists(), "endpoint file removed on shutdown");
1013 }
1014
1015 #[test]
1016 fn clear_endpoint_leaves_a_newer_owner_alone() {
1017 let dir = tempfile::tempdir().expect("tempdir");
1018 write_endpoint(dir.path(), 1111).expect("write first instance");
1019 write_endpoint(dir.path(), 2222).expect("write second instance");
1021
1022 clear_endpoint(dir.path(), 1111);
1024
1025 let path = dir.path().join(".diffler/mcp.json");
1026 let body = std::fs::read_to_string(&path)
1027 .expect("file survives: it names the still-running instance");
1028 assert!(body.contains("\"port\": 2222"), "{body}");
1029 }
1030
1031 #[test]
1034 fn tool_schemas_use_no_unregistered_format() {
1035 for tool in DifflerMcp::tool_router().list_all() {
1036 for (part, schema) in [
1037 ("input", Some(&*tool.input_schema)),
1038 ("output", tool.output_schema.as_deref()),
1039 ] {
1040 let Some(schema) = schema else { continue };
1041 let text = serde_json::to_string(schema).expect("schema serializes");
1042 assert!(
1043 !text.contains("\"format\""),
1044 "{} {part} schema carries a format keyword: {text}",
1045 tool.name
1046 );
1047 }
1048 }
1049 }
1050
1051 #[tokio::test]
1053 async fn spawn_mcp_fallback_on_addr_in_use() {
1054 let holder = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1056 .await
1057 .unwrap();
1058 let busy_port = holder.local_addr().unwrap().port();
1059 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1060 let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1061 let handle = spawn_mcp(tx, feedback_rx, busy_port).unwrap();
1062 assert_ne!(
1063 handle.port, busy_port,
1064 "should have bound to a different ephemeral port"
1065 );
1066 handle.handle.abort();
1067 }
1068}
1069
1070#[cfg(test)]
1071mod agent_command_sync {
1072 fn body(doc: &str) -> String {
1082 let doc = doc.replace("\r\n", "\n");
1083 let after_frontmatter = doc
1084 .strip_prefix("---\n")
1085 .and_then(|rest| rest.split_once("\n---\n"))
1086 .map_or(doc.as_str(), |(_, body)| body);
1087 after_frontmatter.trim().to_owned()
1088 }
1089
1090 #[test]
1091 fn both_agent_command_files_teach_the_same_review_loop() {
1092 const SKILL: &str = include_str!(concat!(
1093 env!("CARGO_MANIFEST_DIR"),
1094 "/../../skills/diffler/SKILL.md"
1095 ));
1096 const OPENCODE: &str = include_str!(concat!(
1097 env!("CARGO_MANIFEST_DIR"),
1098 "/../../.opencode/commands/diffler.md"
1099 ));
1100 assert_eq!(
1101 body(SKILL),
1102 body(OPENCODE),
1103 "skills/diffler/SKILL.md and .opencode/commands/diffler.md have drifted; \
1104 edit both or neither"
1105 );
1106 }
1107}