Skip to main content

forge_workspace/mcp/documents/
facade.rs

1//! The narrow surface the document tool calls: one verb, the workspace's
2//! own read-and-raise.
3//!
4//! Mirrors [`crate::mcp::browser::facade`]: the `Tool` impl holds an
5//! `Arc<dyn DocumentFacade>` so its tests drive a `MockDocumentFacade`
6//! instead of a live workspace.
7
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use forge_primitives::SessionSlot;
12use forge_primitives::document::DocumentHandOffEnding;
13
14use crate::workspace::Workspace;
15
16/// The hand-off, as one tool call sees it.
17#[async_trait]
18pub trait DocumentFacade: Send + Sync {
19    /// Read the document out of `seat`'s own tree or its project's
20    /// `document_roots`, ask the clients drawing that seat to open it, and
21    /// **park until the reader answers** - answering with the resolved
22    /// path and the ending the park released on.
23    ///
24    /// A refused read raises nothing and parks on nothing; a forge with no
25    /// attached client is the read's sibling failure, its own sentence.
26    async fn hand_off(
27        &self,
28        seat: &SessionSlot,
29        path: &str,
30        reviewable: bool,
31    ) -> Result<(String, DocumentHandOffEnding), String>;
32}
33
34/// Production impl: the workspace's own read and parked ask.
35pub struct ProdDocumentFacade {
36    workspace: std::sync::Weak<Workspace>,
37}
38
39impl ProdDocumentFacade {
40    pub fn from_workspace(workspace: &Arc<Workspace>) -> Arc<dyn DocumentFacade> {
41        Arc::new(Self { workspace: Arc::downgrade(workspace) })
42    }
43}
44
45#[async_trait]
46impl DocumentFacade for ProdDocumentFacade {
47    async fn hand_off(
48        &self,
49        seat: &SessionSlot,
50        path: &str,
51        reviewable: bool,
52    ) -> Result<(String, DocumentHandOffEnding), String> {
53        let Some(workspace) = self.workspace.upgrade() else {
54            return Err("the workspace went away before the document was handed".to_owned());
55        };
56        workspace.hand_off_document(seat, path, reviewable).await
57    }
58}
59
60/// Mock for the tool's unit tests: captures what a call carried and answers
61/// with a preloaded outcome.
62#[cfg(test)]
63pub struct MockDocumentFacade {
64    /// Captured `(path, reviewable)` calls.
65    pub calls: parking_lot::Mutex<Vec<(String, bool)>>,
66    /// The seat each call was made for, so a caller sending the wrong one is
67    /// a failed assertion rather than a silent mismatch.
68    pub seats: parking_lot::Mutex<Vec<SessionSlot>>,
69    /// The outcome every call answers with.
70    pub answer: parking_lot::Mutex<Result<(String, DocumentHandOffEnding), String>>,
71}
72
73#[cfg(test)]
74impl MockDocumentFacade {
75    pub fn new() -> Self {
76        Self {
77            calls: parking_lot::Mutex::new(Vec::new()),
78            seats: parking_lot::Mutex::new(Vec::new()),
79            answer: parking_lot::Mutex::new(Ok((
80                "/proj/docs/readme.md".to_owned(),
81                DocumentHandOffEnding::Approve { review_id: None, note: None },
82            ))),
83        }
84    }
85
86    pub fn into_arc(self) -> Arc<dyn DocumentFacade> {
87        Arc::new(self)
88    }
89}
90
91#[cfg(test)]
92#[async_trait]
93impl DocumentFacade for MockDocumentFacade {
94    async fn hand_off(
95        &self,
96        seat: &SessionSlot,
97        path: &str,
98        reviewable: bool,
99    ) -> Result<(String, DocumentHandOffEnding), String> {
100        self.seats.lock().push(seat.clone());
101        self.calls.lock().push((path.to_owned(), reviewable));
102        self.answer.lock().clone()
103    }
104}