Skip to main content

diffler/
mcp.rs

1//! Embedded MCP server: agents read review comments, answer them in place,
2//! and long-poll for the human's feedback. Tool handlers never touch the
3//! review directly — every call is sent through the app event channel as an
4//! [`McpRequest`] and answered by `App::handle_mcp` on the main loop, so the
5//! app stays the single owner of all state (no locks).
6
7use 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
29/// Author label stamped on replies the agent writes through MCP.
30pub const AGENT_AUTHOR: &str = "agent";
31
32const DEFAULT_WAIT_SECONDS: u64 = 300;
33const MAX_WAIT_SECONDS: u64 = 540;
34/// How long a tool call waits for the app to respond before giving up.
35/// The editor suspension is the main source of delays; 30 s is generous.
36pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
37
38/// One agent tool call in flight: the app answers on `reply`.
39pub 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    /// Every persisted review (working tree, commits, ranges) with its comment
62    /// counts, so the agent knows what the human reviewed and where from.
63    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    /// Open + replied comments for `wait_for_feedback` after an epoch bump.
76    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    /// Domain refusal (unknown id/file): surfaces as a tool error.
90    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    /// Every persisted review and its comment counts; `files_changed` above is
104    /// the working-tree review, the default the human starts on.
105    pub reviews: Vec<ReviewSummary>,
106}
107
108/// One review's provenance and comment tally: what was reviewed (working tree,
109/// a commit, or a range) and how many comments sit at each status.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
111pub struct ReviewSummary {
112    /// Stable source key (e.g. "working", "commit-<oid>", "range-<a>-<b>").
113    pub source: String,
114    /// Human-facing description (e.g. "commit a1b2c3", "range a1b2c3..d4e5f6").
115    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    /// Stable key of the review this comment belongs to (see [`ReviewSummary`]).
137    pub source: String,
138    /// Human-facing description of that review (what the human was looking at).
139    pub source_label: String,
140    pub file: String,
141    pub line: Option<u32>,
142    pub line_end: Option<u32>,
143    /// Which side of the diff the line numbers count: "old" or "new".
144    pub side: String,
145    pub body: String,
146    pub status: String,
147    pub author: String,
148    pub replies: Vec<ReplyInfo>,
149    /// The anchored line changed or vanished since the comment was made.
150    pub outdated: bool,
151    /// Origin-prefixed diff snippet around the anchored line.
152    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    /// Restrict the diff to one file (repo-relative path).
192    pub file: Option<String>,
193}
194
195#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
196pub struct GetCommentsParams {
197    /// Filter by comment status: "open", "replied", or "resolved".
198    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    /// Short note on why the comment is addressed.
211    pub note: Option<String>,
212}
213
214#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
215pub struct MarkViewedParams {
216    /// Repo-relative path of a file in the review diff.
217    pub file: String,
218}
219
220#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
221pub struct WaitForFeedbackParams {
222    /// Return once the feedback epoch exceeds this value. Defaults to the
223    /// current epoch, i.e. wait for the next human send.
224    pub since_epoch: Option<u64>,
225    /// Long-poll timeout (default 300, max 540).
226    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
247/// Unified-style text rendering of a diff model for `get_diff`.
248pub 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
285/// Agent-facing view of one comment, with context and outdated detection
286/// judged against the current diff model, tagged with its review source.
287pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
288    let anchor = &comment.anchor;
289    // range comments anchor to their END line (`Anchor::is_outdated`), but
290    // the context snippet renders from the START line so it reads naturally
291    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/// MCP tool handler: forwards every call to the app over the event channel
327/// and holds a feedback-epoch receiver for the long poll.
328#[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            // copy the epoch out so the watch borrow ends before the match
502            .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    /// Actual bound port: the configured one, or an ephemeral fallback.
545    pub port: u16,
546    pub handle: JoinHandle<()>,
547}
548
549/// Serve the MCP tools over streamable HTTP at `127.0.0.1:{port}/mcp`.
550/// A taken port falls back to an ephemeral one instead of failing the TUI;
551/// the returned handle carries the port that actually bound.
552pub 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        // serve only ends on listener errors; the TUI aborts this task on quit
574        let _ = axum::serve(listener, router).await;
575    });
576    Ok(McpHandle { port, handle })
577}
578
579/// The `claude mcp add` hint shown in the status bar at startup.
580pub 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
584/// Repo-relative path of the endpoint discovery file an external proxy reads.
585const 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
591/// Publish the live MCP endpoint to `.diffler/mcp.json` so a stdio proxy (the
592/// `npx` bridge) can discover the actual port, which may differ from the
593/// configured one after an ephemeral fallback.
594pub 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
605/// Remove the endpoint file on shutdown so a stale port never lingers.
606pub 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    // A range comment where start != end is NOT outdated when the end
797    // line still matches.  Only the end-line text is checked for drift; the
798    // context snippet is still rooted at the start line.
799    #[test]
800    fn range_comment_not_outdated_when_end_line_matches() {
801        let mut session = Session::default();
802        // comment spans new lines 1-3; line_text snapshots line 3 ("three")
803        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        // sanity: start text differs from end text
812        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        // change the snapshot to something that no longer matches line 3
824        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    // request_with_timeout returns a "busy" error when the reply
835    // channel is never answered within the deadline.
836    #[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    // The long-poll cap must hold even when the caller asks for more.
852    // Under tokio's paused clock the runtime auto-advances to the next
853    // timer, so the elapsed virtual time is exactly the effective timeout.
854    #[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        // the .diffler dir self-gitignores like the session store
884        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    // spawn_mcp falls back to :0 only on AddrInUse, not on other errors.
892    #[tokio::test]
893    async fn spawn_mcp_fallback_on_addr_in_use() {
894        // grab a port and hold the listener so the configured port is busy
895        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}