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::net::SocketAddr;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
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, ServerConfig};
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
34/// Author label stamped on replies the agent writes through MCP.
35pub const AGENT_AUTHOR: &str = "agent";
36
37/// Claude Code cuts a request at 120 s, so `MAX_WAIT_SECONDS` plus one
38/// `REQUEST_TIMEOUT` must stay under it; the caller polls again to keep waiting.
39const DEFAULT_WAIT_SECONDS: u64 = 25;
40const MAX_WAIT_SECONDS: u64 = 55;
41/// The app stalls on a call mostly while `$EDITOR` holds the terminal.
42pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
43
44/// One agent tool call in flight: the app answers on `reply`. `project`
45/// names the tab a project-scoped call acts on, the one in front when unset.
46pub struct McpRequest {
47    pub kind: McpRequestKind,
48    pub project: Option<String>,
49    pub reply: oneshot::Sender<McpResponse>,
50}
51
52impl fmt::Debug for McpRequest {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.debug_struct("McpRequest")
55            .field("kind", &self.kind)
56            .field("project", &self.project)
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,
71    ReplyComment {
72        id: String,
73        body: String,
74    },
75    ProposeResolve {
76        id: String,
77        note: Option<String>,
78    },
79    MarkViewed {
80        file: String,
81    },
82    /// A comment in the review the human has open; `as_human` makes the human
83    /// its author.
84    AddComment {
85        file: String,
86        line: u32,
87        line_end: Option<u32>,
88        body: String,
89        as_human: bool,
90    },
91    /// Refused for a human's comment and for a walkthrough stop or note,
92    /// which `publish_walkthrough` owns.
93    DeleteComment {
94        id: String,
95    },
96    /// Keeps status, replies and anchor. Same refusals as `DeleteComment`.
97    EditComment {
98        id: String,
99        body: String,
100    },
101    /// Open + replied comments for `wait_for_feedback` after an epoch bump.
102    Feedback,
103    /// Revises the walkthrough `id` names, or publishes a new one when `id`
104    /// is `None`.
105    PublishWalkthrough {
106        id: Option<String>,
107        title: String,
108        stops: Vec<StopParams>,
109        skipped: Option<String>,
110        summary: Option<String>,
111    },
112    /// The walkthrough `id` names, or the newest one when `id` is `None`.
113    GetWalkthrough {
114        id: Option<String>,
115    },
116    /// The agent's own words for the status bar's activity indicator.
117    ReportActivity {
118        focus: String,
119        file: Option<String>,
120    },
121    OpenProject {
122        path: String,
123    },
124    /// Takes the human to `target`, with `note` in the status line.
125    Focus {
126        target: FocusTarget,
127        note: Option<String>,
128    },
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum FocusTarget {
133    /// A comment id or a walkthrough id.
134    Id(String),
135    Code {
136        file: String,
137        line: Option<u32>,
138        line_end: Option<u32>,
139        review: Option<String>,
140    },
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum McpResponse {
145    Status(ReviewStatusResponse),
146    Diff(String),
147    Comments(Vec<CommentInfo>),
148    Reviews(Vec<ReviewSummary>),
149    Replied {
150        status: String,
151    },
152    Ok,
153    Added {
154        id: String,
155    },
156    /// Domain refusal (unknown id/file): surfaces as a tool error.
157    Error(String),
158    WalkthroughPublished(WalkthroughPublished),
159    Walkthrough(Option<WalkthroughInfo>),
160    /// Answers [`McpRequestKind::Feedback`]: the open and replied comments.
161    Feedback {
162        comments: Vec<CommentInfo>,
163    },
164    /// Answers [`McpRequestKind::OpenProject`]: the tab now showing it.
165    ProjectOpened(ProjectInfo),
166    Focused(FocusResponse),
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
170#[serde(rename_all = "lowercase")]
171pub enum FocusView {
172    Diff,
173    File,
174    /// diffler is fetching what the review needs; the human's view moves
175    /// once it arrives.
176    Waiting,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
180pub struct FocusResponse {
181    pub project: String,
182    /// The review on screen, e.g. "working tree" or "PR #12"; absent when
183    /// the file view shows the file as it is on disk.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub review: Option<String>,
186    pub view: FocusView,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub file: Option<String>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    #[schemars(with = "Option<Count>")]
191    pub line: Option<u32>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
195pub struct ReviewStatusResponse {
196    pub repo: String,
197    pub branch: Option<String>,
198    pub oid7: String,
199    pub files_changed: Vec<FileEntry>,
200    #[schemars(with = "Count")]
201    pub open_comments: usize,
202    #[schemars(with = "Count")]
203    pub replied_comments: usize,
204    #[schemars(with = "Count")]
205    pub resolved_comments: usize,
206    #[schemars(with = "Count")]
207    pub feedback_epoch: u64,
208    /// Every persisted review and its comment counts; `files_changed` above is
209    /// the working-tree review, the default the human starts on.
210    pub reviews: Vec<ReviewSummary>,
211    /// Every walkthrough published in the repo, newest first; empty when none
212    /// has been published.
213    pub walkthroughs: Vec<WalkthroughSummary>,
214    /// Filenames under `.diffler/reviews/` that failed to parse and were
215    /// skipped: `reviews` and `walkthroughs` above may be missing an entry
216    /// because of one of these, rather than the repository having none.
217    /// Empty when every review file parsed.
218    pub corrupt_reviews: Vec<String>,
219    /// Every project open as a tab, with its comment counts. Comments and
220    /// feedback cover all of them; `project` targets a call at one.
221    pub projects: Vec<ProjectInfo>,
222}
223
224/// One project open as a tab in the human's diffler.
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
226pub struct ProjectInfo {
227    /// The name to pass as `project`: the repository's folder name.
228    pub name: String,
229    pub root: String,
230    /// The tab the human is looking at.
231    pub active: bool,
232    #[schemars(with = "Count")]
233    pub open_comments: usize,
234    #[schemars(with = "Count")]
235    pub replied_comments: usize,
236}
237
238/// What a fresh agent needs to know a walkthrough exists before reading
239/// anything else: `get_walkthrough` carries the rest.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
241pub struct WalkthroughSummary {
242    /// Pass this to `get_walkthrough` or `publish_walkthrough` to read or
243    /// revise this walkthrough.
244    pub id: String,
245    pub title: String,
246    #[schemars(with = "Count")]
247    pub stops: usize,
248    #[schemars(with = "Count")]
249    pub at: u64,
250}
251
252/// One review's provenance and comment tally: what was reviewed (working tree,
253/// a commit, or a range) and how many comments sit at each status.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
255pub struct ReviewSummary {
256    /// The project this review belongs to, when more than one is open.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub project: Option<String>,
259    /// Stable source key (e.g. `working`, `commit-<oid>`, `range-<a>-<b>`).
260    pub source: String,
261    /// Human-facing description (e.g. "commit a1b2c3", "range a1b2c3..d4e5f6").
262    pub label: String,
263    #[schemars(with = "Count")]
264    pub open_comments: usize,
265    #[schemars(with = "Count")]
266    pub replied_comments: usize,
267    #[schemars(with = "Count")]
268    pub resolved_comments: usize,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
272pub struct FileEntry {
273    pub path: String,
274    pub status: String,
275    pub viewed: bool,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
279pub struct DiffResponse {
280    pub diff: String,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
284pub struct CommentInfo {
285    pub id: String,
286    /// The project this comment belongs to, when more than one is open.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub project: Option<String>,
289    /// Stable key of the review this comment belongs to (see [`ReviewSummary`]).
290    pub source: String,
291    /// Human-facing description of that review (what the human was looking at).
292    pub source_label: String,
293    pub file: String,
294    #[schemars(with = "Option<Count>")]
295    pub line: Option<u32>,
296    #[schemars(with = "Option<Count>")]
297    pub line_end: Option<u32>,
298    /// Which side of the diff the line numbers count: "old" or "new".
299    pub side: String,
300    pub body: String,
301    pub status: String,
302    pub author: String,
303    pub replies: Vec<ReplyInfo>,
304    /// The anchored line changed or vanished since the comment was made.
305    pub outdated: bool,
306    /// Origin-prefixed diff snippet around the anchored line.
307    pub context: Option<String>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
311pub struct ReplyInfo {
312    pub author: String,
313    pub body: String,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
317pub struct CommentsResponse {
318    pub comments: Vec<CommentInfo>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
322pub struct ReviewsResponse {
323    pub reviews: Vec<ReviewSummary>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
327pub struct ReplyResponse {
328    pub ok: bool,
329    pub status: String,
330}
331
332/// One line of what `publish_walkthrough` checked or resolved. A refusal
333/// (`too_many_stops`, `empty_stops`, `body_too_long`, `total_too_long`,
334/// `anchor_unparsed`, `anchor_file_missing`, `nothing_to_anchor`,
335/// `note_outside_stop`, `duplicate_id`) means nothing was stored; the rest
336/// (`anchor_whole`, `figure_dropped`, `figure_simplified`) land alongside a
337/// stored walkthrough so the agent learns what to fix without a broken
338/// figure ever reaching the reader.
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
340pub struct ReceiptInfo {
341    #[schemars(with = "Option<Count>")]
342    pub stop: Option<usize>,
343    pub code: String,
344    pub detail: String,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
348pub struct WalkthroughPublished {
349    pub id: String,
350    #[schemars(with = "Count")]
351    pub stops: usize,
352    pub receipts: Vec<ReceiptInfo>,
353    /// The full commit this walkthrough is pinned to, `None` when the repo
354    /// has no commits yet. Revise it once this no longer matches the branch.
355    pub rev: Option<String>,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
359pub struct StopInfo {
360    /// The comment this stop is. Pass it back as a stop's `id` to revise the
361    /// stop and keep the thread hanging off it.
362    pub id: String,
363    pub title: String,
364    pub anchor: Option<String>,
365    pub body: String,
366    /// The extra comments this stop carries in its region. Pass a note's `id`
367    /// back to keep it; a note whose id you leave out is deleted.
368    pub notes: Vec<NoteInfo>,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
372pub struct NoteInfo {
373    pub id: String,
374    pub anchor: Option<String>,
375    pub body: String,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
379pub struct WalkthroughInfo {
380    pub id: String,
381    pub title: String,
382    pub author: String,
383    #[schemars(with = "Count")]
384    pub at: u64,
385    pub skipped: Option<String>,
386    /// The walkthrough's own overview, when it has one: pass it back
387    /// unchanged on a revision to keep it.
388    pub summary: Option<String>,
389    /// The full commit this walkthrough is pinned to, `None` for one
390    /// published before `rev` existed.
391    pub rev: Option<String>,
392    pub stops: Vec<StopInfo>,
393}
394
395/// An MCP tool's structured output must itself describe an object, so a
396/// possibly-absent walkthrough is a field here rather than the bare
397/// `Option<WalkthroughInfo>` a top-level return would need.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
399pub struct GetWalkthroughResponse {
400    pub walkthrough: Option<WalkthroughInfo>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
404pub struct OkResponse {
405    pub ok: bool,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
409pub struct WaitForFeedbackResponse {
410    #[schemars(with = "Count")]
411    pub epoch: u64,
412    pub timed_out: bool,
413    pub comments: Vec<CommentInfo>,
414}
415
416#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
417pub struct GetDiffParams {
418    /// Restrict the diff to one file (repo-relative path).
419    pub file: Option<String>,
420    /// The project to act on: its folder name from `review_status`, or its
421    /// path. Omit it for the project the human is looking at.
422    pub project: Option<String>,
423}
424
425#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
426pub struct GetCommentsParams {
427    /// Filter by comment status: "open", "replied", or "resolved".
428    pub status: Option<String>,
429}
430
431#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
432pub struct ReplyCommentParams {
433    pub id: String,
434    pub body: String,
435}
436
437#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
438pub struct ProposeResolveParams {
439    pub id: String,
440    /// Short note on why the comment is addressed.
441    pub note: Option<String>,
442}
443
444#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
445pub struct MarkViewedParams {
446    /// Repo-relative path of a file in the review diff.
447    pub file: String,
448    /// The project to act on: its folder name from `review_status`, or its
449    /// path. Omit it for the project the human is looking at.
450    pub project: Option<String>,
451}
452
453#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
454pub struct AddCommentParams {
455    /// Repo-relative path of a file in the review diff.
456    pub file: String,
457    /// Line number in the diff (new-side, unless the line was deleted).
458    #[schemars(with = "Count")]
459    pub line: u32,
460    /// Last line of an inclusive range starting at `line`; omit for one line.
461    #[schemars(with = "Option<Count>")]
462    pub line_end: Option<u32>,
463    pub body: String,
464    /// Author the comment as the human, so it goes out untouched with their
465    /// next submitted review. Off by default, which leaves the comment the
466    /// agent's own for the human to answer.
467    pub as_human: Option<bool>,
468    /// The project to act on: its folder name from `review_status`, or its
469    /// path. Omit it for the project the human is looking at.
470    pub project: Option<String>,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
474pub struct AddCommentResponse {
475    pub id: String,
476}
477
478#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
479pub struct DeleteCommentParams {
480    /// The comment to delete, from `get_comments` or `add_comment`.
481    pub id: String,
482}
483
484#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
485pub struct EditCommentParams {
486    /// The comment to rewrite, from `get_comments` or `add_comment`.
487    pub id: String,
488    pub body: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
492pub struct StopParams {
493    /// The comment id of the stop this replaces, from `get_walkthrough`. Pass
494    /// it to keep that stop's thread; omit it for a new stop.
495    pub id: Option<String>,
496    /// A label of two to five words naming what the stop is about, no verb;
497    /// the sidebar shows about thirty characters.
498    pub title: String,
499    /// `path#symbol` where a symbol exists, `path:start-end` where none does,
500    /// `path:line` for one line, a bare `path` for a whole file, or omit for
501    /// a stop with no anchor.
502    pub anchor: Option<String>,
503    /// Markdown: two or three short bullets on what we chose and why, in full
504    /// sentences, every identifier in backticks, plain verbs for code (raise,
505    /// return, check) and no metaphor. A `mermaid` or `callstack` fence draws
506    /// a diagram.
507    pub body: String,
508    /// Extra remarks on this same stop: a second point about another part of
509    /// its region, or a diagram beside the prose. Each becomes its own comment
510    /// in the stop's slide.
511    pub notes: Option<Vec<NoteParams>>,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, schemars::JsonSchema)]
515pub struct NoteParams {
516    /// The comment id of the note this replaces, from `get_walkthrough`. Pass
517    /// it to keep that note's thread; omit it for a new note.
518    pub id: Option<String>,
519    /// `path:line` or `path:start-end` in the stop's own file, or omit to
520    /// anchor the note at the start of the stop's region.
521    pub anchor: Option<String>,
522    pub body: String,
523}
524
525#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
526pub struct PublishWalkthroughParams {
527    /// The walkthrough to revise, from `get_walkthrough` or `review_status`.
528    /// Omit to publish a new one of its own.
529    pub id: Option<String>,
530    /// The walkthrough's name, as the status screen lists it.
531    pub title: String,
532    pub stops: Vec<StopParams>,
533    /// One line on what you left out and why.
534    pub skipped: Option<String>,
535    /// What the reader meets first: one short paragraph saying what the
536    /// change does, plus one diagram of the simplest shape that explains it,
537    /// five to eight nodes, participants or frames, naming real files or
538    /// functions. Pick the kind for what you are showing: a `mermaid`
539    /// `flowchart` for a branch, a state, or data moving through steps; a
540    /// `mermaid` `sequenceDiagram` for who calls whom over time, with
541    /// `link B: label @ path#symbol` tying participant `B` to its code; a
542    /// `callstack` fence for how a call path changed, one frame per line, two
543    /// spaces of indent per depth, `+ `/`- ` right before an added or removed
544    /// frame's label, and an optional ` @ path#symbol` after it. Never a list
545    /// of the stops or a repeat of their titles. Omit for a walkthrough with
546    /// no summary.
547    pub summary: Option<String>,
548    /// The project to act on: its folder name from `review_status`, or its
549    /// path. Omit it for the project the human is looking at.
550    pub project: Option<String>,
551}
552
553#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
554pub struct OpenProjectParams {
555    /// Path of a git repository, absolute or starting with `~`.
556    pub path: String,
557}
558
559#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
560pub struct FocusParams {
561    /// A comment id (from `get_comments`, `add_comment` or `get_walkthrough`) or a
562    /// walkthrough id. Takes the human to it in whichever review holds it.
563    pub id: Option<String>,
564    /// Repo-relative path to show, when you pass no `id`.
565    pub file: Option<String>,
566    /// New-side line to put the cursor on; omit for the top of the file.
567    #[schemars(with = "Option<Count>")]
568    pub line: Option<u32>,
569    /// Last line of an inclusive range starting at `line`, banded on screen.
570    #[schemars(with = "Option<Count>")]
571    pub line_end: Option<u32>,
572    /// The review to show `file` in: `working`, `commit:<rev>`,
573    /// `range:<oldest>..<newest>` (both commits included), `pr:<number>`,
574    /// `against:<rev>`, or a `source` from `list_reviews`. Omit it to use the
575    /// review on screen when it holds the file, else the working tree, else
576    /// the file as it is on disk.
577    pub review: Option<String>,
578    /// A few words the human reads as their view moves, e.g. "the retry loop
579    /// you asked about".
580    pub note: Option<String>,
581    /// The project to act on: its folder name from `review_status`, or its
582    /// path. Omit it for the project the human is looking at; an `id` finds
583    /// its own project.
584    pub project: Option<String>,
585}
586
587#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
588pub struct GetWalkthroughParams {
589    /// The walkthrough to fetch, from `review_status`. Omit for the newest.
590    pub id: Option<String>,
591}
592
593#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
594pub struct ReportActivityParams {
595    /// Short plain phrase for what you're doing right now, e.g. "writing the
596    /// walkthrough" or "fixing the failing test". Keep it to a few words;
597    /// the bar cuts a long one.
598    pub focus: String,
599    /// The file this is about, if any.
600    pub file: Option<String>,
601}
602
603#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
604pub struct WaitForFeedbackParams {
605    /// Return once the feedback epoch exceeds this value. Defaults to the
606    /// current epoch, i.e. wait for the next human send.
607    #[schemars(with = "Option<Count>")]
608    pub since_epoch: Option<u64>,
609    /// Long-poll timeout in seconds (default 25), capped at 55; call again
610    /// to keep waiting.
611    #[schemars(with = "Option<Count>")]
612    pub timeout_seconds: Option<u64>,
613}
614
615/// Unsigned ints derive `format: "uint*"`, which JSON Schema does not register
616/// and strict validators warn on. rmcp hardcodes its generator, so we opt each
617/// field out with `#[schemars(with = "Count")]`.
618struct Count;
619
620impl schemars::JsonSchema for Count {
621    fn inline_schema() -> bool {
622        true
623    }
624
625    fn schema_name() -> std::borrow::Cow<'static, str> {
626        "Count".into()
627    }
628
629    fn schema_id() -> std::borrow::Cow<'static, str> {
630        "diffler::mcp::Count".into()
631    }
632
633    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
634        schemars::json_schema!({ "type": "integer", "minimum": 0 })
635    }
636}
637
638pub const fn comment_status_name(status: CommentStatus) -> &'static str {
639    match status {
640        CommentStatus::Open => "open",
641        CommentStatus::Replied => "replied",
642        CommentStatus::Resolved => "resolved",
643    }
644}
645
646pub const fn file_status_name(status: FileStatus) -> &'static str {
647    match status {
648        FileStatus::Added => "added",
649        FileStatus::Modified => "modified",
650        FileStatus::Deleted => "deleted",
651        FileStatus::Renamed => "renamed",
652        FileStatus::Untracked => "untracked",
653        FileStatus::Unchanged => "unchanged",
654    }
655}
656
657pub fn render_unified(model: &DiffModel, file: Option<&str>) -> Result<String, String> {
658    use std::fmt::Write as _;
659
660    if let Some(path) = file
661        && !model.files.iter().any(|f| f.path == path)
662    {
663        return Err(format!("unknown file: {path}"));
664    }
665    let mut out = String::new();
666    for f in &model.files {
667        if file.is_some_and(|path| path != f.path) {
668            continue;
669        }
670        let old = match f.status {
671            FileStatus::Added | FileStatus::Untracked => "/dev/null".to_owned(),
672            _ => format!("a/{}", f.old_path.as_deref().unwrap_or(&f.path)),
673        };
674        let new = match f.status {
675            FileStatus::Deleted => "/dev/null".to_owned(),
676            _ => format!("b/{}", f.path),
677        };
678        let _ = writeln!(out, "--- {old}");
679        let _ = writeln!(out, "+++ {new}");
680        if f.binary {
681            out.push_str("Binary files differ\n");
682            continue;
683        }
684        for hunk in &f.hunks {
685            let _ = writeln!(out, "{}", hunk.header());
686            for line in &hunk.lines {
687                let _ = writeln!(out, "{}{}", line.kind.origin(), line.text);
688            }
689        }
690    }
691    Ok(out)
692}
693
694pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
695    let anchor = &comment.anchor;
696    // outdated detection reads a range's end line, but we start the snippet at
697    // its first line so it reads top-down
698    let context = anchor
699        .line
700        .and_then(|line| feedback::context_snippet(model, &anchor.file, line, anchor.on_old_side))
701        .map(|snippet| {
702            snippet
703                .iter()
704                .map(|(origin, text)| format!("{origin}{text}"))
705                .collect::<Vec<_>>()
706                .join("\n")
707        });
708    let outdated = anchor.is_outdated(model);
709    CommentInfo {
710        id: comment.id.clone(),
711        project: None,
712        source: source.key(),
713        source_label: source.label(),
714        file: anchor.file.clone(),
715        line: anchor.line,
716        line_end: anchor.line_end,
717        side: if anchor.on_old_side { "old" } else { "new" }.to_owned(),
718        body: comment.body.clone(),
719        status: comment_status_name(comment.status).to_owned(),
720        author: comment.author.clone(),
721        replies: comment
722            .replies
723            .iter()
724            .map(|r| ReplyInfo {
725                author: r.author.clone(),
726                body: r.body.clone(),
727            })
728            .collect(),
729        outdated,
730        context,
731    }
732}
733
734/// MCP tool handler: forwards every call to the app over the event channel
735/// and holds a feedback-epoch receiver for the long poll.
736#[derive(Clone)]
737pub struct DifflerMcp {
738    tx: UnboundedSender<AppEvent>,
739    feedback_rx: watch::Receiver<u64>,
740    tool_router: ToolRouter<Self>,
741    prompt_router: PromptRouter<Self>,
742}
743
744fn mismatch() -> ErrorData {
745    ErrorData::internal_error("unexpected response from the diffler app", None)
746}
747
748fn parse_status(status: &str) -> Result<CommentStatus, ErrorData> {
749    match status {
750        "open" => Ok(CommentStatus::Open),
751        "replied" => Ok(CommentStatus::Replied),
752        "resolved" => Ok(CommentStatus::Resolved),
753        other => Err(ErrorData::invalid_params(
754            format!("unknown status {other:?} (expected open, replied, or resolved)"),
755            None,
756        )),
757    }
758}
759
760impl DifflerMcp {
761    pub fn new(tx: UnboundedSender<AppEvent>, feedback_rx: watch::Receiver<u64>) -> Self {
762        Self {
763            tx,
764            feedback_rx,
765            tool_router: Self::tool_router(),
766            prompt_router: Self::prompt_router(),
767        }
768    }
769
770    async fn request(&self, kind: McpRequestKind) -> Result<McpResponse, ErrorData> {
771        self.request_with_timeout(kind, None, REQUEST_TIMEOUT).await
772    }
773
774    /// [`Self::request`] for a call that acts on one project's tab.
775    async fn request_in(
776        &self,
777        project: Option<String>,
778        kind: McpRequestKind,
779    ) -> Result<McpResponse, ErrorData> {
780        self.request_with_timeout(kind, project, REQUEST_TIMEOUT)
781            .await
782    }
783
784    async fn request_with_timeout(
785        &self,
786        kind: McpRequestKind,
787        project: Option<String>,
788        timeout: Duration,
789    ) -> Result<McpResponse, ErrorData> {
790        let (reply, response) = oneshot::channel();
791        self.tx
792            .send(AppEvent::Mcp(McpRequest {
793                kind,
794                project,
795                reply,
796            }))
797            .map_err(|_| ErrorData::internal_error("the diffler TUI is not running", None))?;
798        match tokio::time::timeout(timeout, response).await {
799            Ok(Ok(McpResponse::Error(message))) => Err(ErrorData::invalid_params(message, None)),
800            Ok(Ok(response)) => Ok(response),
801            Ok(Err(_)) => Err(ErrorData::internal_error(
802                "the diffler TUI dropped the request",
803                None,
804            )),
805            Err(_) => Err(ErrorData::internal_error(
806                "diffler is busy (editor open or loop stalled), retry",
807                None,
808            )),
809        }
810    }
811}
812
813#[tool_router]
814impl DifflerMcp {
815    #[tool(
816        description = "Current review state: repo, branch, changed files with viewed marks, comment counts, and the feedback epoch for wait_for_feedback."
817    )]
818    async fn review_status(&self) -> Result<Json<ReviewStatusResponse>, ErrorData> {
819        match self.request(McpRequestKind::ReviewStatus).await? {
820            McpResponse::Status(status) => Ok(Json(status)),
821            _ => Err(mismatch()),
822        }
823    }
824
825    #[tool(
826        description = "Unified diff text of the working tree under review, optionally restricted to one file."
827    )]
828    async fn get_diff(
829        &self,
830        Parameters(params): Parameters<GetDiffParams>,
831    ) -> Result<Json<DiffResponse>, ErrorData> {
832        let kind = McpRequestKind::GetDiff { file: params.file };
833        match self.request_in(params.project, kind).await? {
834            McpResponse::Diff(diff) => Ok(Json(DiffResponse { diff })),
835            _ => Err(mismatch()),
836        }
837    }
838
839    #[tool(
840        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)."
841    )]
842    async fn get_comments(
843        &self,
844        Parameters(params): Parameters<GetCommentsParams>,
845    ) -> Result<Json<CommentsResponse>, ErrorData> {
846        let status = params.status.as_deref().map(parse_status).transpose()?;
847        match self.request(McpRequestKind::GetComments { status }).await? {
848            McpResponse::Comments(comments) => Ok(Json(CommentsResponse { comments })),
849            _ => Err(mismatch()),
850        }
851    }
852
853    #[tool(
854        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."
855    )]
856    async fn list_reviews(&self) -> Result<Json<ReviewsResponse>, ErrorData> {
857        match self.request(McpRequestKind::ListReviews).await? {
858            McpResponse::Reviews(reviews) => Ok(Json(ReviewsResponse { reviews })),
859            _ => Err(mismatch()),
860        }
861    }
862
863    #[tool(description = "Answer a review comment in place; the human sees the reply immediately.")]
864    async fn reply_comment(
865        &self,
866        Parameters(params): Parameters<ReplyCommentParams>,
867    ) -> Result<Json<ReplyResponse>, ErrorData> {
868        let kind = McpRequestKind::ReplyComment {
869            id: params.id,
870            body: params.body,
871        };
872        match self.request(kind).await? {
873            McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
874            _ => Err(mismatch()),
875        }
876    }
877
878    #[tool(
879        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."
880    )]
881    async fn propose_resolve(
882        &self,
883        Parameters(params): Parameters<ProposeResolveParams>,
884    ) -> Result<Json<ReplyResponse>, ErrorData> {
885        let kind = McpRequestKind::ProposeResolve {
886            id: params.id,
887            note: params.note,
888        };
889        match self.request(kind).await? {
890            McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
891            _ => Err(mismatch()),
892        }
893    }
894
895    #[tool(
896        description = "Mark a file as viewed in the review the human is currently looking at (working tree, or the open commit/range diff)."
897    )]
898    async fn mark_viewed(
899        &self,
900        Parameters(params): Parameters<MarkViewedParams>,
901    ) -> Result<Json<OkResponse>, ErrorData> {
902        let kind = McpRequestKind::MarkViewed { file: params.file };
903        match self.request_in(params.project, kind).await? {
904            McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
905            _ => Err(mismatch()),
906        }
907    }
908
909    #[tool(
910        description = "Write a review comment on a line or an inclusive line range in the review the human has open: the working tree, or the commit, range or PR on screen. To comment on another review, `focus` it first. The comment is anchored like the human's own, so it shows as outdated once its line changes. It is the agent's comment, for the human to answer in its thread; `as_human` makes it the human's own, sent with their next submitted review. Returns its `id`."
911    )]
912    async fn add_comment(
913        &self,
914        Parameters(params): Parameters<AddCommentParams>,
915    ) -> Result<Json<AddCommentResponse>, ErrorData> {
916        let kind = McpRequestKind::AddComment {
917            file: params.file,
918            line: params.line,
919            line_end: params.line_end,
920            body: params.body,
921            as_human: params.as_human.unwrap_or(false),
922        };
923        match self.request_in(params.project, kind).await? {
924            McpResponse::Added { id } => Ok(Json(AddCommentResponse { id })),
925            _ => Err(mismatch()),
926        }
927    }
928
929    #[tool(
930        description = "Delete a comment, yours or the human's. Refused for one already posted to the forge, for one someone else has replied to (edit it instead), and for a walkthrough stop or note, which `publish_walkthrough` revises."
931    )]
932    async fn delete_comment(
933        &self,
934        Parameters(params): Parameters<DeleteCommentParams>,
935    ) -> Result<Json<OkResponse>, ErrorData> {
936        let kind = McpRequestKind::DeleteComment { id: params.id };
937        match self.request(kind).await? {
938            McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
939            _ => Err(mismatch()),
940        }
941    }
942
943    #[tool(
944        description = "Replace a comment's body, yours or the human's, keeping its author, status, replies and anchor. Refused for one already posted to the forge, and for a walkthrough stop or note, which `publish_walkthrough` revises."
945    )]
946    async fn edit_comment(
947        &self,
948        Parameters(params): Parameters<EditCommentParams>,
949    ) -> Result<Json<OkResponse>, ErrorData> {
950        let kind = McpRequestKind::EditComment {
951            id: params.id,
952            body: params.body,
953        };
954        match self.request(kind).await? {
955            McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
956            _ => Err(mismatch()),
957        }
958    }
959
960    #[tool(
961        description = "Publish a walkthrough: the order a reader should take through a change. Use one stop per real decision, as few as the change needs (five is common; more than ten means the change wants splitting). Each stop says why, never what the diff already shows, and becomes an agent comment the human answers in its thread. It describes the review open now, pinned to the commit checked out. Pass `id` to revise one in place, with each surviving stop's and note's `id` from `get_walkthrough`; any left out are deleted. Returns the walkthrough's `id` and receipts; a refusal names what to fix. Limits: 20 stops, 8 KB per body, 64 KB in all."
962    )]
963    async fn publish_walkthrough(
964        &self,
965        Parameters(params): Parameters<PublishWalkthroughParams>,
966    ) -> Result<Json<WalkthroughPublished>, ErrorData> {
967        let kind = McpRequestKind::PublishWalkthrough {
968            id: params.id,
969            title: params.title,
970            stops: params.stops,
971            skipped: params.skipped,
972            summary: params.summary,
973        };
974        match self.request_in(params.project, kind).await? {
975            McpResponse::WalkthroughPublished(published) => Ok(Json(published)),
976            _ => Err(mismatch()),
977        }
978    }
979
980    #[tool(
981        description = "The walkthrough `id` names (from review_status), or the newest one when `id` is omitted, or null when the review has none."
982    )]
983    async fn get_walkthrough(
984        &self,
985        Parameters(params): Parameters<GetWalkthroughParams>,
986    ) -> Result<Json<GetWalkthroughResponse>, ErrorData> {
987        match self
988            .request(McpRequestKind::GetWalkthrough { id: params.id })
989            .await?
990        {
991            McpResponse::Walkthrough(walkthrough) => {
992                Ok(Json(GetWalkthroughResponse { walkthrough }))
993            }
994            _ => Err(mismatch()),
995        }
996    }
997
998    #[tool(
999        description = "Show what you are doing in the human's status bar: a few words (\"writing the walkthrough\") and the file it is about, if any. Every other tool call already shows on its own, so call this before work no call covers, such as reading code or writing tests, or when a call's own label says too little."
1000    )]
1001    async fn report_activity(
1002        &self,
1003        Parameters(params): Parameters<ReportActivityParams>,
1004    ) -> Result<Json<OkResponse>, ErrorData> {
1005        let kind = McpRequestKind::ReportActivity {
1006            focus: params.focus,
1007            file: params.file,
1008        };
1009        match self.request(kind).await? {
1010            McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
1011            _ => Err(mismatch()),
1012        }
1013    }
1014
1015    #[tool(
1016        description = "Open a git repository as a project tab in the human's diffler, so they can review its changes beside the others. Call it for every repository you changed outside the one diffler started in. Comments and feedback then cover it too, and `project` targets a call at it."
1017    )]
1018    async fn open_project(
1019        &self,
1020        Parameters(params): Parameters<OpenProjectParams>,
1021    ) -> Result<Json<ProjectInfo>, ErrorData> {
1022        let kind = McpRequestKind::OpenProject { path: params.path };
1023        match self.request(kind).await? {
1024            McpResponse::ProjectOpened(project) => Ok(Json(project)),
1025            _ => Err(mismatch()),
1026        }
1027    }
1028
1029    #[tool(
1030        description = "Take the human's diffler to a place: a comment or walkthrough by `id`, or a `file` and line range in any review. It switches to the project's tab and opens that review with the cursor on the place. Use it when the human asks where something is, and after `publish_walkthrough` to open the walkthrough. To point at code with a remark, call `add_comment`, then focus the `id` it returns. Refused while the human is typing a comment or answering a dialog; a pull request that still has to load answers `waiting` and opens once it does."
1031    )]
1032    async fn focus(
1033        &self,
1034        Parameters(params): Parameters<FocusParams>,
1035    ) -> Result<Json<FocusResponse>, ErrorData> {
1036        let target = match (params.id, params.file) {
1037            (Some(id), None) => FocusTarget::Id(id),
1038            (None, Some(file)) => FocusTarget::Code {
1039                file,
1040                line: params.line,
1041                line_end: params.line_end,
1042                review: params.review,
1043            },
1044            _ => {
1045                return Err(ErrorData::invalid_params("pass either id or file", None));
1046            }
1047        };
1048        let kind = McpRequestKind::Focus {
1049            target,
1050            note: params.note,
1051        };
1052        match self.request_in(params.project, kind).await? {
1053            McpResponse::Focused(focused) => Ok(Json(focused)),
1054            _ => Err(mismatch()),
1055        }
1056    }
1057
1058    #[tool(
1059        description = "Wait until the human sends feedback: a comment, a reply, or the send key. Returns the new epoch and every open or replied comment, or `timed_out` while they are still reviewing; call again with the returned epoch to keep waiting. A reply on a walkthrough stop arrives as a reply on that stop's comment, so its id names the stop."
1060    )]
1061    async fn wait_for_feedback(
1062        &self,
1063        Parameters(params): Parameters<WaitForFeedbackParams>,
1064    ) -> Result<Json<WaitForFeedbackResponse>, ErrorData> {
1065        let mut rx = self.feedback_rx.clone();
1066        let since = params.since_epoch.unwrap_or_else(|| *rx.borrow());
1067        let timeout = Duration::from_secs(
1068            params
1069                .timeout_seconds
1070                .unwrap_or(DEFAULT_WAIT_SECONDS)
1071                .min(MAX_WAIT_SECONDS),
1072        );
1073        // we tell the app before blocking, since the poll sends no request
1074        // until the human answers
1075        let _ = self.tx.send(AppEvent::McpWaiting {
1076            until: Instant::now() + timeout,
1077        });
1078        let waited = tokio::time::timeout(timeout, rx.wait_for(|epoch| *epoch > since))
1079            .await
1080            // copy the epoch out so the watch borrow ends before the match
1081            .map(|result| result.map(|epoch| *epoch));
1082        let (epoch, timed_out) = match waited {
1083            Ok(Ok(epoch)) => (epoch, false),
1084            Ok(Err(_)) => {
1085                return Err(ErrorData::internal_error(
1086                    "the diffler TUI is not running",
1087                    None,
1088                ));
1089            }
1090            Err(_) => (*rx.borrow(), true),
1091        };
1092        if timed_out {
1093            return Ok(Json(WaitForFeedbackResponse {
1094                epoch,
1095                timed_out: true,
1096                comments: Vec::new(),
1097            }));
1098        }
1099        match self.request(McpRequestKind::Feedback).await? {
1100            McpResponse::Feedback { comments } => Ok(Json(WaitForFeedbackResponse {
1101                epoch,
1102                timed_out: false,
1103                comments,
1104            })),
1105            _ => Err(mismatch()),
1106        }
1107    }
1108}
1109
1110/// A skill file's prose with its YAML frontmatter dropped. We normalise CRLF
1111/// first, since on a Windows checkout `\n` matches no frontmatter and we would
1112/// ship the YAML header to the agent.
1113fn skill_body(doc: &str) -> String {
1114    let doc = doc.replace("\r\n", "\n");
1115    let body = doc
1116        .strip_prefix("---\n")
1117        .and_then(|rest| rest.split_once("\n---\n"))
1118        .map_or(doc.as_str(), |(_, body)| body);
1119    body.trim().to_owned()
1120}
1121
1122// a published crate carries only its own directory, so the prompts live here
1123// and `agent_command_sync` pins them to the skill files the plugin ships
1124const DF_SKILL: &str = include_str!("../prompts/df.md");
1125
1126const DFA_SKILL: &str = include_str!("../prompts/dfa.md");
1127
1128const DFR_SKILL: &str = include_str!("../prompts/dfr.md");
1129
1130#[prompt_router]
1131impl DifflerMcp {
1132    #[prompt(
1133        name = "review",
1134        description = "Check the diffler review: read the human's open comments, address them in code, reply, and wait for the next round."
1135    )]
1136    async fn review(&self) -> Vec<PromptMessage> {
1137        vec![PromptMessage::new_text(Role::User, skill_body(DF_SKILL))]
1138    }
1139
1140    #[prompt(
1141        name = "walkthrough",
1142        description = "Walk the human through a change: publish a walkthrough, one stop per real decision, and answer their comments."
1143    )]
1144    async fn walkthrough(&self) -> Vec<PromptMessage> {
1145        vec![PromptMessage::new_text(Role::User, skill_body(DFA_SKILL))]
1146    }
1147
1148    #[prompt(
1149        name = "critique",
1150        description = "Review a change in diffler: read the diff and leave comments on real problems, one per issue, and never submit."
1151    )]
1152    async fn critique(&self) -> Vec<PromptMessage> {
1153        vec![PromptMessage::new_text(Role::User, skill_body(DFR_SKILL))]
1154    }
1155}
1156
1157#[tool_handler(router = self.tool_router)]
1158#[prompt_handler(router = self.prompt_router)]
1159impl ServerHandler for DifflerMcp {
1160    fn get_info(&self) -> ServerConfig {
1161        ServerConfig::new(
1162            ServerCapabilities::builder()
1163                .enable_tools()
1164                .enable_prompts()
1165                .build(),
1166        )
1167        .with_instructions(
1168            "diffler code review: get_comments reads the human's diff comments, \
1169             reply_comment answers them in place, propose_resolve flags an \
1170             answered one as addressed without adding text, and \
1171             wait_for_feedback long-polls until the human sends \
1172             new feedback. open_project opens another repository you \
1173             changed as a tab in the same diffler; comments and feedback \
1174             cover every open project, each tagged with its project. focus \
1175             takes the human to a comment, a walkthrough, or a file and \
1176             line in any review. The review prompt packages that loop as a \
1177             command.",
1178        )
1179    }
1180}
1181
1182pub struct McpHandle {
1183    /// Actual bound port: the configured one, or an ephemeral fallback.
1184    pub port: u16,
1185    pub handle: JoinHandle<()>,
1186}
1187
1188// SO_REUSEADDR lets a restart reclaim the port while the old socket lingers in
1189// TIME_WAIT. Unix only: on Windows it instead allows hijacking a live port and
1190// errors WSAEACCES, defeating the busy-port fallback.
1191fn bind_reusable(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
1192    let socket = tokio::net::TcpSocket::new_v4()?;
1193    #[cfg(unix)]
1194    socket.set_reuseaddr(true)?;
1195    socket.bind(addr)?;
1196    socket.listen(1024)
1197}
1198
1199/// Serves `127.0.0.1:{port}/mcp`, or an ephemeral port when `port` is taken;
1200/// the handle carries the port that bound.
1201pub fn spawn_mcp(
1202    tx: UnboundedSender<AppEvent>,
1203    feedback_rx: watch::Receiver<u64>,
1204    port: u16,
1205) -> std::io::Result<McpHandle> {
1206    let listener = match bind_reusable(SocketAddr::from(([127, 0, 0, 1], port))) {
1207        Ok(listener) => listener,
1208        Err(e) if e.kind() == ErrorKind::AddrInUse => {
1209            bind_reusable(SocketAddr::from(([127, 0, 0, 1], 0)))?
1210        }
1211        Err(e) => return Err(e),
1212    };
1213    let port = listener.local_addr()?.port();
1214    let service: StreamableHttpService<DifflerMcp, LocalSessionManager> =
1215        StreamableHttpService::new(
1216            move || Ok(DifflerMcp::new(tx.clone(), feedback_rx.clone())),
1217            std::sync::Arc::default(),
1218            StreamableHttpServerConfig::default(),
1219        );
1220    let router = axum::Router::new().nest_service("/mcp", service);
1221    let handle = tokio::spawn(async move {
1222        // serve only ends on listener errors; the TUI aborts this task on quit
1223        let _ = axum::serve(listener, router).await;
1224    });
1225    Ok(McpHandle { port, handle })
1226}
1227
1228const ENDPOINT_FILE: &str = "mcp.json";
1229
1230fn endpoint_path(repo_root: &Path) -> PathBuf {
1231    repo_root.join(".diffler").join(ENDPOINT_FILE)
1232}
1233
1234#[derive(Serialize)]
1235struct EndpointFile {
1236    port: u16,
1237    url: String,
1238    pid: u32,
1239}
1240
1241/// The stdio proxy reads the bound port from `.diffler/mcp.json`, or from the
1242/// per-user registry when it starts outside any repo.
1243pub fn write_endpoint(repo_root: &Path, port: u16) -> std::io::Result<()> {
1244    let pid = std::process::id();
1245    let body = serde_json::to_string_pretty(&EndpointFile {
1246        port,
1247        url: format!("http://127.0.0.1:{port}/mcp"),
1248        pid,
1249    })
1250    .map_err(std::io::Error::other)?;
1251    diffler_core::store::write_file(repo_root, ENDPOINT_FILE, &body)?;
1252    write_registry_entry(repo_root, port, pid);
1253    Ok(())
1254}
1255
1256/// We remove the file only while it names our port, since a second instance
1257/// in the same repo overwrites it and still needs it after we exit.
1258pub fn clear_endpoint(repo_root: &Path, port: u16) {
1259    let path = endpoint_path(repo_root);
1260    if let Ok(body) = std::fs::read_to_string(&path) {
1261        let current_owner = serde_json::from_str::<serde_json::Value>(&body)
1262            .ok()
1263            .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
1264        if current_owner == Some(u64::from(port)) {
1265            let _ = std::fs::remove_file(&path);
1266        }
1267    }
1268    clear_registry_entry(repo_root, port);
1269}
1270
1271#[derive(Serialize)]
1272struct RegistryEntry<'a> {
1273    repo: &'a str,
1274    port: u16,
1275    pid: u32,
1276    url: String,
1277}
1278
1279/// `$XDG_STATE_HOME/diffler/instances`, defaulting to `~/.local/state`.
1280/// Tests set `DIFFLER_STATE_DIR` so they never touch the real home directory.
1281fn registry_dir() -> Option<PathBuf> {
1282    let base = if let Some(dir) = non_empty_env("DIFFLER_STATE_DIR") {
1283        PathBuf::from(dir)
1284    } else if let Some(dir) = non_empty_env("XDG_STATE_HOME") {
1285        PathBuf::from(dir)
1286    } else {
1287        PathBuf::from(non_empty_env("HOME")?)
1288            .join(".local")
1289            .join("state")
1290    };
1291    Some(base.join("diffler").join("instances"))
1292}
1293
1294fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
1295    std::env::var_os(key).filter(|v| !v.is_empty())
1296}
1297
1298/// Best-effort: a same-repo proxy still finds `.diffler/mcp.json`, so we
1299/// ignore write failures here.
1300fn write_registry_entry(repo_root: &Path, port: u16, pid: u32) {
1301    let Some(dir) = registry_dir() else {
1302        return;
1303    };
1304    if std::fs::create_dir_all(&dir).is_err() {
1305        return;
1306    }
1307    let repo = canonical_repo(repo_root);
1308    let Some(repo) = repo.to_str() else {
1309        return;
1310    };
1311    let Ok(body) = serde_json::to_string_pretty(&RegistryEntry {
1312        repo,
1313        port,
1314        pid,
1315        url: format!("http://127.0.0.1:{port}/mcp"),
1316    }) else {
1317        return;
1318    };
1319    let _ = std::fs::write(dir.join(registry_file(repo_root, port)), body);
1320}
1321
1322pub(crate) fn canonical_repo(repo_root: &Path) -> PathBuf {
1323    repo_root
1324        .canonicalize()
1325        .unwrap_or_else(|_| repo_root.to_path_buf())
1326}
1327
1328/// One entry per project, since one diffler serves all its tabs on one port.
1329fn registry_file(repo_root: &Path, port: u16) -> String {
1330    let mut hasher = std::hash::DefaultHasher::new();
1331    std::hash::Hash::hash(&canonical_repo(repo_root), &mut hasher);
1332    format!("{port}-{:016x}.json", std::hash::Hasher::finish(&hasher))
1333}
1334
1335/// Same owner check as `clear_endpoint`.
1336fn clear_registry_entry(repo_root: &Path, port: u16) {
1337    let Some(dir) = registry_dir() else {
1338        return;
1339    };
1340    let path = dir.join(registry_file(repo_root, port));
1341    let Ok(body) = std::fs::read_to_string(&path) else {
1342        return;
1343    };
1344    let owner_port = serde_json::from_str::<serde_json::Value>(&body)
1345        .ok()
1346        .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
1347    if owner_port == Some(u64::from(port)) {
1348        let _ = std::fs::remove_file(&path);
1349    }
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354    use diffler_core::model::{DiffLine, FileDiff, Hunk, HunkId, LineKind};
1355    use diffler_core::session::{Anchor, Session};
1356
1357    use super::*;
1358
1359    fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
1360        DiffLine::new(kind, old_no, new_no, text.to_owned())
1361    }
1362
1363    fn sample_model() -> DiffModel {
1364        DiffModel {
1365            files: vec![
1366                FileDiff {
1367                    path: "src/auth.py".into(),
1368                    old_path: None,
1369                    status: FileStatus::Modified,
1370                    binary: false,
1371                    old_text: None,
1372                    new_text: Some("one\nTWO\nthree\n".into()),
1373                    hunks: vec![Hunk {
1374                        id: HunkId("h1".into()),
1375                        old_start: 1,
1376                        old_lines: 3,
1377                        new_start: 1,
1378                        new_lines: 3,
1379                        context: String::new(),
1380                        lines: vec![
1381                            diff_line(LineKind::Context, Some(1), Some(1), "one"),
1382                            diff_line(LineKind::Deleted, Some(2), None, "two"),
1383                            diff_line(LineKind::Added, None, Some(2), "TWO"),
1384                            diff_line(LineKind::Context, Some(3), Some(3), "three"),
1385                        ],
1386                    }],
1387                    hashes: diffler_core::model::HashCache::default(),
1388                    blobs: diffler_core::model::BlobIds::default(),
1389                },
1390                FileDiff {
1391                    path: "logo.png".into(),
1392                    old_path: None,
1393                    status: FileStatus::Added,
1394                    binary: true,
1395                    old_text: None,
1396                    new_text: None,
1397                    hunks: vec![],
1398                    hashes: diffler_core::model::HashCache::default(),
1399                    blobs: diffler_core::model::BlobIds::default(),
1400                },
1401            ],
1402        }
1403    }
1404
1405    fn anchor(file: &str, line: Option<u32>) -> Anchor {
1406        Anchor {
1407            file: file.to_owned(),
1408            line,
1409            line_end: None,
1410            on_old_side: false,
1411            line_text: None,
1412        }
1413    }
1414
1415    #[test]
1416    fn render_unified_emits_headers_hunks_and_origins() {
1417        let text = render_unified(&sample_model(), None).unwrap();
1418        assert!(text.contains("--- a/src/auth.py\n+++ b/src/auth.py\n"));
1419        assert!(text.contains("@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"));
1420        assert!(text.contains("--- /dev/null\n+++ b/logo.png\nBinary files differ\n"));
1421    }
1422
1423    #[test]
1424    fn render_unified_filters_to_one_file() {
1425        let text = render_unified(&sample_model(), Some("src/auth.py")).unwrap();
1426        assert!(text.contains("src/auth.py"));
1427        assert!(!text.contains("logo.png"));
1428    }
1429
1430    #[test]
1431    fn render_unified_unknown_file_is_an_error() {
1432        let err = render_unified(&sample_model(), Some("nope.rs")).unwrap_err();
1433        assert!(err.contains("nope.rs"));
1434    }
1435
1436    #[test]
1437    fn render_unified_marks_deleted_files_with_dev_null() {
1438        let mut model = sample_model();
1439        model.files[0].status = FileStatus::Deleted;
1440        let text = render_unified(&model, Some("src/auth.py")).unwrap();
1441        assert!(text.contains("--- a/src/auth.py\n+++ /dev/null\n"));
1442    }
1443
1444    #[test]
1445    fn render_unified_renamed_file_pairs_old_and_new_paths() {
1446        let mut model = sample_model();
1447        model.files[0].status = FileStatus::Renamed;
1448        model.files[0].old_path = Some("src/auth_v1.py".to_owned());
1449        let text = render_unified(&model, Some("src/auth.py")).unwrap();
1450        assert!(
1451            text.contains("--- a/src/auth_v1.py\n+++ b/src/auth.py\n"),
1452            "rename header must pair both paths: {text}"
1453        );
1454    }
1455
1456    #[test]
1457    fn render_unified_empty_model_is_an_empty_diff() {
1458        let model = DiffModel { files: vec![] };
1459        assert_eq!(render_unified(&model, None).unwrap(), "");
1460    }
1461
1462    #[test]
1463    fn comment_info_old_side_anchor_reports_side_and_context() {
1464        let mut session = Session::default();
1465        let mut a = anchor("src/auth.py", Some(2));
1466        a.on_old_side = true;
1467        a.line_text = Some("two".to_owned());
1468        session.add_comment(a, "human", "what was wrong with two?");
1469        let info = comment_info(
1470            &session.comments[0],
1471            &sample_model(),
1472            &ReviewSource::WorkingTree,
1473        );
1474        assert_eq!(info.side, "old");
1475        assert_eq!(info.line, Some(2));
1476        assert_eq!(info.line_end, None);
1477        assert!(!info.outdated, "the deleted line is still in the diff");
1478        assert_eq!(info.context.as_deref(), Some(" one\n-two\n+TWO"));
1479    }
1480
1481    #[test]
1482    fn comment_info_carries_context_and_thread() {
1483        let mut session = Session::default();
1484        let mut a = anchor("src/auth.py", Some(2));
1485        a.line_text = Some("TWO".to_owned());
1486        let id = session.add_comment(a, "human", "why uppercase?").id.clone();
1487        session.reply(&id, AGENT_AUTHOR, "legacy API");
1488        let info = comment_info(
1489            &session.comments[0],
1490            &sample_model(),
1491            &ReviewSource::WorkingTree,
1492        );
1493        assert_eq!(info.file, "src/auth.py");
1494        assert_eq!(info.line, Some(2));
1495        assert_eq!(info.side, "new");
1496        assert_eq!(info.status, "replied");
1497        assert_eq!(info.context.as_deref(), Some("-two\n+TWO\n three"));
1498        assert!(!info.outdated);
1499        assert_eq!(info.replies.len(), 1);
1500        assert_eq!(info.replies[0].author, AGENT_AUTHOR);
1501    }
1502
1503    #[test]
1504    fn comment_info_flags_departed_lines_outdated() {
1505        let mut session = Session::default();
1506        session.add_comment(anchor("src/auth.py", Some(99)), "human", "moved on");
1507        let info = comment_info(
1508            &session.comments[0],
1509            &sample_model(),
1510            &ReviewSource::WorkingTree,
1511        );
1512        assert!(info.outdated);
1513        assert_eq!(info.context, None);
1514    }
1515
1516    #[test]
1517    fn comment_info_flags_drifted_line_text_outdated() {
1518        let mut session = Session::default();
1519        let mut a = anchor("src/auth.py", Some(2));
1520        a.line_text = Some("old text".to_owned());
1521        session.add_comment(a, "human", "stale");
1522        let info = comment_info(
1523            &session.comments[0],
1524            &sample_model(),
1525            &ReviewSource::WorkingTree,
1526        );
1527        assert!(info.outdated);
1528        assert!(info.context.is_some(), "context still renders");
1529    }
1530
1531    #[test]
1532    fn file_level_comment_outdated_only_when_file_left_the_diff() {
1533        let mut session = Session::default();
1534        session.add_comment(anchor("src/auth.py", None), "human", "overall");
1535        session.add_comment(anchor("gone.py", None), "human", "gone");
1536        let model = sample_model();
1537        assert!(!comment_info(&session.comments[0], &model, &ReviewSource::WorkingTree).outdated);
1538        assert!(comment_info(&session.comments[1], &model, &ReviewSource::WorkingTree).outdated);
1539    }
1540
1541    #[test]
1542    fn range_comment_not_outdated_when_end_line_matches() {
1543        let mut session = Session::default();
1544        // comment spans new lines 1-3; line_text snapshots line 3 ("three")
1545        let mut a = Anchor {
1546            file: "src/auth.py".to_owned(),
1547            line: Some(1),
1548            line_end: Some(3),
1549            on_old_side: false,
1550            line_text: Some("three".to_owned()),
1551        };
1552        assert_ne!(a.line_text.as_deref(), Some("one"));
1553        session.add_comment(a.clone(), "human", "range comment");
1554        let info = comment_info(
1555            &session.comments[0],
1556            &sample_model(),
1557            &ReviewSource::WorkingTree,
1558        );
1559        assert!(
1560            !info.outdated,
1561            "end line text matches snapshot: must NOT be outdated"
1562        );
1563        a.line_text = Some("changed".to_owned());
1564        session.comments[0].anchor = a;
1565        let info2 = comment_info(
1566            &session.comments[0],
1567            &sample_model(),
1568            &ReviewSource::WorkingTree,
1569        );
1570        assert!(info2.outdated, "end line text drifted: must be outdated");
1571    }
1572
1573    #[tokio::test]
1574    async fn request_times_out_when_app_does_not_answer() {
1575        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1576        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1577        let handler = DifflerMcp::new(tx, feedback_rx);
1578        let err = handler
1579            .request_with_timeout(
1580                McpRequestKind::ReviewStatus,
1581                None,
1582                Duration::from_millis(50),
1583            )
1584            .await
1585            .unwrap_err();
1586        assert!(
1587            err.message.contains("busy"),
1588            "error should mention busy: {err:?}"
1589        );
1590    }
1591
1592    // a paused clock jumps to the next timer, so elapsed time is the effective timeout
1593    #[tokio::test(start_paused = true)]
1594    async fn wait_for_feedback_clamps_the_timeout_to_the_cap() {
1595        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1596        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1597        let handler = DifflerMcp::new(tx, feedback_rx);
1598        let started = tokio::time::Instant::now();
1599        let Json(response) = handler
1600            .wait_for_feedback(Parameters(WaitForFeedbackParams {
1601                since_epoch: None,
1602                timeout_seconds: Some(MAX_WAIT_SECONDS * 10),
1603            }))
1604            .await
1605            .unwrap();
1606        assert!(response.timed_out, "no feedback ever arrives");
1607        assert_eq!(
1608            started.elapsed(),
1609            Duration::from_secs(MAX_WAIT_SECONDS),
1610            "a timeout above the cap must clamp to {MAX_WAIT_SECONDS}s"
1611        );
1612    }
1613
1614    #[tokio::test(start_paused = true)]
1615    async fn wait_for_feedback_defaults_inside_the_client_request_timeout() {
1616        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1617        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1618        let handler = DifflerMcp::new(tx, feedback_rx);
1619        let started = tokio::time::Instant::now();
1620        let Json(response) = handler
1621            .wait_for_feedback(Parameters(WaitForFeedbackParams {
1622                since_epoch: None,
1623                timeout_seconds: None,
1624            }))
1625            .await
1626            .unwrap();
1627        assert!(response.timed_out, "no feedback ever arrives");
1628        assert!(
1629            started.elapsed() + REQUEST_TIMEOUT < Duration::from_secs(120),
1630            "the wait plus the reply it still owes must beat the client's 120 s cut"
1631        );
1632    }
1633
1634    #[test]
1635    fn a_capped_wait_and_its_round_trip_fit_the_client_ceiling() {
1636        assert!(MAX_WAIT_SECONDS + REQUEST_TIMEOUT.as_secs() < 120);
1637    }
1638
1639    #[tokio::test]
1640    async fn wait_for_feedback_answers_with_the_reviews_comments() {
1641        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1642        let (feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1643        feedback_tx.send_modify(|epoch| *epoch += 1);
1644        let handler = DifflerMcp::new(tx, feedback_rx);
1645        let requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1646        let counted = requests.clone();
1647        tokio::spawn(async move {
1648            while let Some(event) = rx.recv().await {
1649                let AppEvent::Mcp(request) = event else {
1650                    continue;
1651                };
1652                counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1653                let response = match request.kind {
1654                    McpRequestKind::Feedback => McpResponse::Feedback {
1655                        comments: Vec::new(),
1656                    },
1657                    _ => McpResponse::Ok,
1658                };
1659                let _ = request.reply.send(response);
1660            }
1661        });
1662        let Json(response) = handler
1663            .wait_for_feedback(Parameters(WaitForFeedbackParams {
1664                since_epoch: Some(0),
1665                timeout_seconds: None,
1666            }))
1667            .await
1668            .unwrap();
1669        assert!(!response.timed_out);
1670        assert_eq!(
1671            requests.load(std::sync::atomic::Ordering::SeqCst),
1672            1,
1673            "the success path makes exactly one app request after the wait"
1674        );
1675    }
1676
1677    // `DIFFLER_STATE_DIR` is process-global, so registry tests serialize on this lock.
1678    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1679
1680    #[allow(unsafe_code)]
1681    fn with_state_dir<T>(dir: &Path, f: impl FnOnce() -> T) -> T {
1682        let _guard = ENV_LOCK.lock().unwrap();
1683        // SAFETY: ENV_LOCK serializes every test that reads or writes this var.
1684        unsafe { std::env::set_var("DIFFLER_STATE_DIR", dir) };
1685        let result = f();
1686        unsafe { std::env::remove_var("DIFFLER_STATE_DIR") };
1687        result
1688    }
1689
1690    #[test]
1691    fn endpoint_file_publishes_the_port_and_clears() {
1692        let dir = tempfile::tempdir().expect("tempdir");
1693        let state = tempfile::tempdir().expect("tempdir");
1694        with_state_dir(state.path(), || {
1695            write_endpoint(dir.path(), 8417).expect("write");
1696            let path = dir.path().join(".diffler/mcp.json");
1697            let body = std::fs::read_to_string(&path).expect("read");
1698            assert!(body.contains("\"port\": 8417"), "{body}");
1699            assert!(body.contains("http://127.0.0.1:8417/mcp"), "{body}");
1700            assert!(body.contains("\"pid\""), "{body}");
1701            // the .diffler dir self-gitignores like the session store
1702            let gitignore =
1703                std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("gitignore");
1704            assert_eq!(gitignore, "*\n");
1705            clear_endpoint(dir.path(), 8417);
1706            assert!(!path.exists(), "endpoint file removed on shutdown");
1707        });
1708    }
1709
1710    #[test]
1711    fn clear_endpoint_leaves_a_newer_owner_alone() {
1712        let dir = tempfile::tempdir().expect("tempdir");
1713        let state = tempfile::tempdir().expect("tempdir");
1714        with_state_dir(state.path(), || {
1715            write_endpoint(dir.path(), 1111).expect("write first instance");
1716            // a second diffler instance in the same repo overwrites the file
1717            write_endpoint(dir.path(), 2222).expect("write second instance");
1718
1719            // the first instance shuts down and clears its own (stale) port
1720            clear_endpoint(dir.path(), 1111);
1721
1722            let path = dir.path().join(".diffler/mcp.json");
1723            let body = std::fs::read_to_string(&path)
1724                .expect("file survives: it names the still-running instance");
1725            assert!(body.contains("\"port\": 2222"), "{body}");
1726        });
1727    }
1728
1729    #[test]
1730    fn write_endpoint_registers_the_instance_for_cross_repo_discovery() {
1731        let dir = tempfile::tempdir().expect("tempdir");
1732        let state = tempfile::tempdir().expect("tempdir");
1733        with_state_dir(state.path(), || {
1734            write_endpoint(dir.path(), 8417).expect("write");
1735            let entry_path = state
1736                .path()
1737                .join("diffler/instances")
1738                .join(registry_file(dir.path(), 8417));
1739            let body = std::fs::read_to_string(&entry_path).expect("registry entry written");
1740            // we parse the entry because JSON escapes a Windows path's separators
1741            let entry: serde_json::Value =
1742                serde_json::from_str(&body).expect("registry entry is json");
1743            let repo = dir.path().canonicalize().expect("canonicalize");
1744            assert_eq!(entry["repo"].as_str(), repo.to_str(), "{body}");
1745            assert_eq!(entry["port"].as_u64(), Some(8417), "{body}");
1746            assert_eq!(
1747                entry["pid"].as_u64(),
1748                Some(u64::from(std::process::id())),
1749                "{body}"
1750            );
1751            assert_eq!(
1752                entry["url"].as_str(),
1753                Some("http://127.0.0.1:8417/mcp"),
1754                "{body}"
1755            );
1756        });
1757    }
1758
1759    #[test]
1760    fn clear_endpoint_removes_only_its_own_registry_entry() {
1761        let dir = tempfile::tempdir().expect("tempdir");
1762        let state = tempfile::tempdir().expect("tempdir");
1763        with_state_dir(state.path(), || {
1764            write_endpoint(dir.path(), 1111).expect("write first instance");
1765            write_endpoint(dir.path(), 2222).expect("write second instance");
1766            let instances = state.path().join("diffler/instances");
1767
1768            clear_endpoint(dir.path(), 1111);
1769
1770            assert!(
1771                !instances.join(registry_file(dir.path(), 1111)).exists(),
1772                "the cleared instance's own registry entry is gone"
1773            );
1774            assert!(
1775                instances.join(registry_file(dir.path(), 2222)).exists(),
1776                "a different instance's registry entry survives"
1777            );
1778        });
1779    }
1780
1781    #[test]
1782    fn one_diffler_registers_every_project_it_serves() {
1783        let (first, second) = (
1784            tempfile::tempdir().expect("tempdir"),
1785            tempfile::tempdir().expect("tempdir"),
1786        );
1787        let state = tempfile::tempdir().expect("tempdir");
1788        with_state_dir(state.path(), || {
1789            write_endpoint(first.path(), 3333).expect("first project");
1790            write_endpoint(second.path(), 3333).expect("second project");
1791            let instances = state.path().join("diffler/instances");
1792            assert_eq!(std::fs::read_dir(&instances).expect("registry").count(), 2);
1793            clear_endpoint(second.path(), 3333);
1794            assert!(instances.join(registry_file(first.path(), 3333)).exists());
1795            assert!(!instances.join(registry_file(second.path(), 3333)).exists());
1796        });
1797    }
1798
1799    #[test]
1800    fn tool_schemas_use_no_unregistered_format() {
1801        for tool in DifflerMcp::tool_router().list_all() {
1802            for (part, schema) in [
1803                ("input", Some(&*tool.input_schema)),
1804                ("output", tool.output_schema.as_deref()),
1805            ] {
1806                let Some(schema) = schema else { continue };
1807                let text = serde_json::to_string(schema).expect("schema serializes");
1808                assert!(
1809                    !text.contains("\"format\""),
1810                    "{} {part} schema carries a format keyword: {text}",
1811                    tool.name
1812                );
1813            }
1814        }
1815    }
1816
1817    #[tokio::test]
1818    async fn spawn_mcp_fallback_on_addr_in_use() {
1819        let holder = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1820            .await
1821            .unwrap();
1822        let busy_port = holder.local_addr().unwrap().port();
1823        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1824        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1825        let handle = spawn_mcp(tx, feedback_rx, busy_port).unwrap();
1826        assert_ne!(
1827            handle.port, busy_port,
1828            "should have bound to a different ephemeral port"
1829        );
1830        handle.handle.abort();
1831    }
1832}
1833
1834#[cfg(test)]
1835mod agent_command_sync {
1836    fn body(doc: &str) -> String {
1837        let doc = doc.replace("\r\n", "\n");
1838        let after_frontmatter = doc
1839            .strip_prefix("---\n")
1840            .and_then(|rest| rest.split_once("\n---\n"))
1841            .map_or(doc.as_str(), |(_, body)| body);
1842        after_frontmatter.trim().to_owned()
1843    }
1844
1845    #[test]
1846    fn a_skill_loses_its_frontmatter_whatever_its_line_endings() {
1847        assert_eq!(
1848            super::skill_body("---\r\nname: df\r\n---\r\n\r\nthe body\r\n"),
1849            "the body"
1850        );
1851        assert_eq!(
1852            super::skill_body("---\nname: df\n---\n\nthe body\n"),
1853            "the body"
1854        );
1855    }
1856
1857    /// Every agent command ships as a Claude Code skill, an `OpenCode` command
1858    /// and a crate prompt, with only the frontmatter allowed to differ.
1859    #[test]
1860    fn both_agent_command_files_teach_the_same_loop() {
1861        const DF_SKILL: &str = include_str!(concat!(
1862            env!("CARGO_MANIFEST_DIR"),
1863            "/../../skills/df/SKILL.md"
1864        ));
1865        const DF_OPENCODE: &str = include_str!(concat!(
1866            env!("CARGO_MANIFEST_DIR"),
1867            "/../../.opencode/commands/df.md"
1868        ));
1869        const DFA_SKILL: &str = include_str!(concat!(
1870            env!("CARGO_MANIFEST_DIR"),
1871            "/../../skills/dfa/SKILL.md"
1872        ));
1873        const DFA_OPENCODE: &str = include_str!(concat!(
1874            env!("CARGO_MANIFEST_DIR"),
1875            "/../../.opencode/commands/dfa.md"
1876        ));
1877        const DFR_SKILL: &str = include_str!(concat!(
1878            env!("CARGO_MANIFEST_DIR"),
1879            "/../../skills/dfr/SKILL.md"
1880        ));
1881        const DFR_OPENCODE: &str = include_str!(concat!(
1882            env!("CARGO_MANIFEST_DIR"),
1883            "/../../.opencode/commands/dfr.md"
1884        ));
1885        for (name, skill, opencode, prompt) in [
1886            ("df", DF_SKILL, DF_OPENCODE, super::DF_SKILL),
1887            ("dfa", DFA_SKILL, DFA_OPENCODE, super::DFA_SKILL),
1888            ("dfr", DFR_SKILL, DFR_OPENCODE, super::DFR_SKILL),
1889        ] {
1890            assert_eq!(
1891                body(skill),
1892                body(opencode),
1893                "skills/{name}/SKILL.md and .opencode/commands/{name}.md have drifted; \
1894                 edit both or neither"
1895            );
1896            assert_eq!(
1897                body(skill),
1898                body(prompt),
1899                "skills/{name}/SKILL.md and crates/diffler/prompts/{name}.md have drifted; \
1900                 the crate ships its own copy because a published crate carries only \
1901                 its own directory"
1902            );
1903        }
1904    }
1905
1906    fn write_for_the_card(doc: &str) -> &str {
1907        const HEADING: &str = "## Write for the card";
1908        let Some(start) = doc.find(HEADING) else {
1909            return "";
1910        };
1911        let after = &doc[start..];
1912        let end = after[HEADING.len()..]
1913            .find("\n## ")
1914            .map_or(after.len(), |offset| offset + HEADING.len());
1915        after[..end].trim()
1916    }
1917
1918    /// All three skills write a card in the same pane, so they share its writing rules.
1919    #[test]
1920    fn every_skill_writes_the_card_the_same_way() {
1921        const DF_SKILL: &str = include_str!(concat!(
1922            env!("CARGO_MANIFEST_DIR"),
1923            "/../../skills/df/SKILL.md"
1924        ));
1925        const DFA_SKILL: &str = include_str!(concat!(
1926            env!("CARGO_MANIFEST_DIR"),
1927            "/../../skills/dfa/SKILL.md"
1928        ));
1929        const DFR_SKILL: &str = include_str!(concat!(
1930            env!("CARGO_MANIFEST_DIR"),
1931            "/../../skills/dfr/SKILL.md"
1932        ));
1933        let df = write_for_the_card(DF_SKILL);
1934        let dfa = write_for_the_card(DFA_SKILL);
1935        let dfr = write_for_the_card(DFR_SKILL);
1936        assert!(
1937            !df.is_empty(),
1938            "df must have a '## Write for the card' section"
1939        );
1940        assert_eq!(
1941            df, dfa,
1942            "the '## Write for the card' section must be identical in df and dfa"
1943        );
1944        assert_eq!(
1945            df, dfr,
1946            "the '## Write for the card' section must be identical in df and dfr"
1947        );
1948    }
1949
1950    /// Each MCP prompt must be its skill file's steps, verbatim.
1951    #[tokio::test]
1952    async fn the_prompts_are_their_skills_bodies() {
1953        const DF_SKILL: &str = include_str!(concat!(
1954            env!("CARGO_MANIFEST_DIR"),
1955            "/../../skills/df/SKILL.md"
1956        ));
1957        const DFA_SKILL: &str = include_str!(concat!(
1958            env!("CARGO_MANIFEST_DIR"),
1959            "/../../skills/dfa/SKILL.md"
1960        ));
1961        const DFR_SKILL: &str = include_str!(concat!(
1962            env!("CARGO_MANIFEST_DIR"),
1963            "/../../skills/dfr/SKILL.md"
1964        ));
1965        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1966        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1967        let handler = super::DifflerMcp::new(tx, feedback_rx);
1968        for (messages, skill) in [
1969            (handler.review().await, DF_SKILL),
1970            (handler.walkthrough().await, DFA_SKILL),
1971            (handler.critique().await, DFR_SKILL),
1972        ] {
1973            let text = &messages[0]
1974                .content
1975                .as_text()
1976                .expect("prompt carries text")
1977                .text;
1978            assert_eq!(*text, body(skill));
1979        }
1980    }
1981}