heddle_client/grpc_hosted/
tree_edit.rs1use 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 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_signed_auth(&mut request, "/heddle.v1.TreeEditService/StatusForThread")?;
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 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_signed_auth(&mut request, "/heddle.v1.TreeEditService/DiffForThread")?;
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 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_signed_auth(&mut request, "/heddle.v1.TreeEditService/LogForThread")?;
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 #[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 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 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}