Skip to main content

heddle_client/grpc_hosted/
tree_edit.rs

1//! Hosted client wrappers for the read-side tree-edit RPCs (heddle#409).
2//!
3//! These expose `TreeEditService`'s `StatusForThread`, `DiffForThread`, and
4//! `LogForThread` over the hosted gRPC surface so a filesystem-less client can
5//! inspect a hosted thread's tree state without a local `.heddle/` or worktree.
6//! The contract is committed-tree-by-default; dirty overlays only appear when
7//! the caller names a `compare_tree`.
8
9use grpc::heddle::v1::{
10    DiffForThreadRequest, DiffForThreadResponse, LogForThreadRequest, LogForThreadResponse,
11    StatusForThreadRequest, StatusForThreadResponse, Treeish,
12};
13use tonic::Request;
14use wire::ProtocolError;
15
16use super::{HostedGrpcClient, helpers::status_to_protocol_error};
17
18impl HostedGrpcClient {
19    /// Inspect a hosted thread's committed status, optionally comparing against
20    /// a caller-supplied overlay tree (`compare_tree`). With no overlay this
21    /// reports committed-thread facts only and makes no claim about a client
22    /// filesystem.
23    pub async fn status_for_thread(
24        &mut self,
25        repo_path: &str,
26        thread: &str,
27        compare_tree: Option<Treeish>,
28    ) -> Result<StatusForThreadResponse, ProtocolError> {
29        let mut request = Request::new(StatusForThreadRequest {
30            repo_path: repo_path.to_string(),
31            thread: thread.to_string(),
32            compare_tree,
33        });
34        self.apply_auth(&mut request)?;
35        self.tree_edit
36            .status_for_thread(request)
37            .await
38            .map_err(status_to_protocol_error)
39            .map(|response| response.into_inner())
40    }
41
42    /// Diff a hosted thread between two explicitly-named treeish points. Both
43    /// sides are required — a diff never implies an invisible worktree.
44    pub async fn diff_for_thread(
45        &mut self,
46        repo_path: &str,
47        thread: &str,
48        from: Treeish,
49        to: Treeish,
50        include_semantic: bool,
51    ) -> Result<DiffForThreadResponse, ProtocolError> {
52        let mut request = Request::new(DiffForThreadRequest {
53            repo_path: repo_path.to_string(),
54            thread: thread.to_string(),
55            from: Some(from),
56            to: Some(to),
57            include_semantic,
58        });
59        self.apply_auth(&mut request)?;
60        self.tree_edit
61            .diff_for_thread(request)
62            .await
63            .map_err(status_to_protocol_error)
64            .map(|response| response.into_inner())
65    }
66
67    /// Walk a hosted thread's first-parent history, reusing the
68    /// `ContentService` `StateSummary` shape. `since_state` is an exclusive
69    /// lower bound; `paths` and `agent_model_substring` are optional filters.
70    pub async fn log_for_thread(
71        &mut self,
72        repo_path: &str,
73        thread: &str,
74        limit: u32,
75        since_state: Option<&str>,
76        paths: Vec<String>,
77        agent_model_substring: Option<&str>,
78    ) -> Result<LogForThreadResponse, ProtocolError> {
79        let mut request = Request::new(LogForThreadRequest {
80            repo_path: repo_path.to_string(),
81            thread: thread.to_string(),
82            limit,
83            since_state: since_state.map(str::to_string),
84            paths,
85            agent_model_substring: agent_model_substring.map(str::to_string),
86        });
87        self.apply_auth(&mut request)?;
88        self.tree_edit
89            .log_for_thread(request)
90            .await
91            .map_err(status_to_protocol_error)
92            .map(|response| response.into_inner())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use cli_shared::ClientConfig;
99    use grpc::heddle::v1::{
100        CompareSummary, DiffForThreadRequest, DiffForThreadResponse, FileDiff, LogForThreadRequest,
101        LogForThreadResponse, StateSummary, StatusForThreadRequest, StatusForThreadResponse,
102        ThreadPathSet, Treeish,
103        tree_edit_service_server::{TreeEditService, TreeEditServiceServer},
104        treeish,
105    };
106    use tonic::{Request, Response, Status, transport::Server};
107
108    use super::HostedGrpcClient;
109
110    /// A mock hosted `TreeEditService` that echoes the request inputs back into
111    /// the response shapes. It stands in for the weft-side handler so the
112    /// heddle client can be exercised end-to-end over a real gRPC channel.
113    #[derive(Default)]
114    struct EchoTreeEditService;
115
116    #[tonic::async_trait]
117    impl TreeEditService for EchoTreeEditService {
118        async fn status_for_thread(
119            &self,
120            request: Request<StatusForThreadRequest>,
121        ) -> Result<Response<StatusForThreadResponse>, Status> {
122            let req = request.into_inner();
123            let compared_to_supplied_tree = req.compare_tree.is_some();
124            Ok(Response::new(StatusForThreadResponse {
125                thread: req.thread,
126                head_state: "hd-head".into(),
127                base_state: "hd-base".into(),
128                target_thread: "main".into(),
129                coordination_status: "ahead".into(),
130                changes: Some(ThreadPathSet {
131                    modified: vec!["src/lib.rs".into()],
132                    added: vec![],
133                    deleted: vec![],
134                }),
135                compared_to_supplied_tree,
136            }))
137        }
138
139        async fn diff_for_thread(
140            &self,
141            request: Request<DiffForThreadRequest>,
142        ) -> Result<Response<DiffForThreadResponse>, Status> {
143            let req = request.into_inner();
144            // Reject a diff with a missing side — the contract requires both.
145            if req.from.is_none() || req.to.is_none() {
146                return Err(Status::invalid_argument("from and to are both required"));
147            }
148            Ok(Response::new(DiffForThreadResponse {
149                from_state: "hd-from".into(),
150                to_state: "hd-to".into(),
151                files: vec![FileDiff {
152                    path: "src/lib.rs".into(),
153                    kind: "modified".into(),
154                    hunks: vec![],
155                    classification: "Logic".into(),
156                    importance: "High".into(),
157                }],
158                summary: Some(CompareSummary {
159                    added: 0,
160                    modified: 1,
161                    deleted: 0,
162                    renamed: 0,
163                    total: 1,
164                }),
165            }))
166        }
167
168        async fn log_for_thread(
169            &self,
170            request: Request<LogForThreadRequest>,
171        ) -> Result<Response<LogForThreadResponse>, Status> {
172            let req = request.into_inner();
173            // Echo the requested limit as the number of returned states so the
174            // client test can assert the request was wired through.
175            let states = (0..req.limit)
176                .map(|_| StateSummary::default())
177                .collect::<Vec<_>>();
178            Ok(Response::new(LogForThreadResponse { states }))
179        }
180    }
181
182    async fn connect_echo_service() -> Option<(HostedGrpcClient, tokio::task::JoinHandle<()>)> {
183        let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
184            Ok(listener) => listener,
185            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
186                eprintln!("skipping tree-edit client test: TCP bind denied: {err}");
187                return None;
188            }
189            Err(err) => panic!("bind test server: {err}"),
190        };
191        let addr = listener.local_addr().expect("local addr");
192        let incoming = futures::stream::unfold(listener, |listener| async {
193            match listener.accept().await {
194                Ok((stream, _addr)) => Some((Ok::<_, std::io::Error>(stream), listener)),
195                Err(err) => Some((Err(err), listener)),
196            }
197        });
198
199        let handle = tokio::spawn(async move {
200            Server::builder()
201                .add_service(TreeEditServiceServer::new(EchoTreeEditService))
202                .serve_with_incoming(incoming)
203                .await
204                .expect("serve tree-edit test service");
205        });
206
207        let client = HostedGrpcClient::connect(addr, &ClientConfig::default())
208            .await
209            .expect("connect client");
210        Some((client, handle))
211    }
212
213    #[tokio::test]
214    async fn status_for_thread_without_overlay_reports_committed_only() {
215        let Some((mut client, server)) = connect_echo_service().await else {
216            return;
217        };
218        let resp = client
219            .status_for_thread("owner/repo", "feat/x", None)
220            .await
221            .expect("status_for_thread");
222        server.abort();
223
224        assert_eq!(resp.thread, "feat/x");
225        assert_eq!(resp.head_state, "hd-head");
226        assert!(
227            !resp.compared_to_supplied_tree,
228            "no compare_tree supplied = committed-only status"
229        );
230    }
231
232    #[tokio::test]
233    async fn status_for_thread_with_overlay_sets_compared_flag() {
234        let Some((mut client, server)) = connect_echo_service().await else {
235            return;
236        };
237        let overlay = Treeish {
238            value: Some(treeish::Value::CaptureId("cap-123".into())),
239        };
240        let resp = client
241            .status_for_thread("owner/repo", "feat/x", Some(overlay))
242            .await
243            .expect("status_for_thread");
244        server.abort();
245
246        assert!(
247            resp.compared_to_supplied_tree,
248            "compare_tree supplied = overlay comparison"
249        );
250    }
251
252    #[tokio::test]
253    async fn diff_for_thread_round_trips_file_diffs() {
254        let Some((mut client, server)) = connect_echo_service().await else {
255            return;
256        };
257        let from = Treeish {
258            value: Some(treeish::Value::StateId("hd-from".into())),
259        };
260        let to = Treeish {
261            value: Some(treeish::Value::Ref("feat/x".into())),
262        };
263        let resp = client
264            .diff_for_thread("owner/repo", "feat/x", from, to, true)
265            .await
266            .expect("diff_for_thread");
267        server.abort();
268
269        assert_eq!(resp.files.len(), 1);
270        assert_eq!(resp.files[0].path, "src/lib.rs");
271        assert_eq!(resp.summary.expect("summary").modified, 1);
272    }
273
274    #[tokio::test]
275    async fn log_for_thread_returns_requested_number_of_states() {
276        let Some((mut client, server)) = connect_echo_service().await else {
277            return;
278        };
279        let resp = client
280            .log_for_thread("owner/repo", "feat/x", 3, Some("hd-since"), vec![], None)
281            .await
282            .expect("log_for_thread");
283        server.abort();
284
285        assert_eq!(resp.states.len(), 3);
286    }
287}