1use serde::Deserialize;
8
9#[derive(Debug, Clone, Deserialize)]
13pub struct ClaimsResponse {
14 #[serde(default)]
15 pub claims: Vec<Claim>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct Claim {
20 #[serde(default)]
21 pub agent: String,
22 #[serde(default)]
23 pub patterns: Vec<String>,
24 #[serde(default)]
25 pub active: bool,
26 #[serde(default)]
27 pub memo: Option<String>,
28 #[serde(default)]
29 pub expires_at: Option<String>,
30}
31
32impl Claim {
33 pub fn bone_ids(&self) -> Vec<&str> {
35 self.patterns
36 .iter()
37 .filter_map(|p| {
38 p.strip_prefix("bone://")
39 .and_then(|rest| rest.split('/').nth(1))
40 })
41 .collect()
42 }
43
44 pub fn workspace_names(&self) -> Vec<&str> {
46 self.patterns
47 .iter()
48 .filter_map(|p| {
49 p.strip_prefix("workspace://")
50 .and_then(|rest| rest.split('/').nth(1))
51 })
52 .collect()
53 }
54}
55
56#[derive(Debug, Clone, Deserialize)]
60pub struct WorkspacesResponse {
61 #[serde(default)]
62 pub workspaces: Vec<Workspace>,
63 #[serde(default)]
64 pub advice: Vec<WorkspaceAdvice>,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68pub struct Workspace {
69 pub name: String,
70 #[serde(default)]
71 pub is_default: bool,
72 #[serde(default)]
73 pub is_current: bool,
74 #[serde(default)]
75 pub change_id: Option<String>,
76 #[serde(default)]
77 pub commit_id: Option<String>,
78 #[serde(default)]
79 pub description: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize)]
83pub struct WorkspaceAdvice {
84 #[serde(default)]
85 pub level: String,
86 #[serde(default)]
87 pub message: String,
88 #[serde(default)]
89 pub details: Option<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
98pub struct BoneInfo {
99 pub id: String,
100 #[serde(default)]
101 pub title: String,
102 #[serde(default)]
103 pub state: String,
104 #[serde(default)]
105 pub assignees: Vec<String>,
106 #[serde(default)]
107 pub labels: Vec<String>,
108 #[serde(rename = "kind", default)]
109 pub kind: Option<String>,
110 #[serde(default)]
111 pub urgency: Option<String>,
112}
113
114pub fn parse_bone_show(json: &str) -> Result<BoneInfo, AdapterError> {
116 serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
118 tool: "bn show",
119 detail: e.to_string(),
120 })
121}
122
123#[derive(Debug, Clone, Deserialize)]
127pub struct ReviewsListResponse {
128 #[serde(default)]
129 pub reviews: Vec<ReviewSummary>,
130}
131
132#[derive(Debug, Clone, Deserialize)]
133pub struct ReviewSummary {
134 pub review_id: String,
135 #[serde(default)]
136 pub title: Option<String>,
137 #[serde(default)]
138 pub status: String,
139 #[serde(default)]
140 pub change_id: Option<String>,
141 #[serde(default)]
142 pub author: Option<String>,
143}
144
145#[derive(Debug, Clone, Deserialize)]
147pub struct ReviewDetailResponse {
148 pub review: ReviewDetail,
149 #[serde(default)]
150 pub threads: Vec<ReviewThread>,
151}
152
153#[derive(Debug, Clone, Deserialize)]
154pub struct ReviewDetail {
155 pub review_id: String,
156 #[serde(default)]
157 pub title: Option<String>,
158 #[serde(default)]
159 pub status: String,
160 #[serde(default)]
161 pub change_id: Option<String>,
162 #[serde(default)]
163 pub votes: Vec<ReviewVote>,
164 #[serde(default)]
165 pub open_thread_count: usize,
166}
167
168#[derive(Debug, Clone, Deserialize)]
169pub struct ReviewVote {
170 pub reviewer: String,
171 pub vote: String,
172 #[serde(default)]
173 pub voted_at: Option<String>,
174}
175
176impl ReviewVote {
177 pub fn is_lgtm(&self) -> bool {
178 self.vote == "lgtm"
179 }
180
181 pub fn is_block(&self) -> bool {
182 self.vote == "block"
183 }
184}
185
186#[derive(Debug, Clone, Deserialize)]
187pub struct ReviewThread {
188 pub thread_id: String,
189 #[serde(default)]
190 pub file: Option<String>,
191 #[serde(default)]
192 pub line: Option<u32>,
193 #[serde(default)]
194 pub resolved: bool,
195 #[serde(default)]
196 pub comments: Vec<ReviewComment>,
197}
198
199#[derive(Debug, Clone, Deserialize)]
200pub struct ReviewComment {
201 #[serde(default)]
202 pub author: String,
203 #[serde(default)]
204 pub body: String,
205 #[serde(default)]
206 pub created_at: Option<String>,
207}
208
209#[derive(Debug, Clone)]
212pub enum AdapterError {
213 ParseFailed { tool: &'static str, detail: String },
214 NotFound { tool: &'static str, detail: String },
215}
216
217impl std::fmt::Display for AdapterError {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 AdapterError::ParseFailed { tool, detail } => {
221 write!(f, "failed to parse {tool} output: {detail}")
222 }
223 AdapterError::NotFound { tool, detail } => {
224 write!(f, "{tool}: {detail}")
225 }
226 }
227 }
228}
229
230impl std::error::Error for AdapterError {}
231
232pub fn parse_claims(json: &str) -> Result<ClaimsResponse, AdapterError> {
236 serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
237 tool: "bus claims list",
238 detail: e.to_string(),
239 })
240}
241
242pub fn parse_workspaces(json: &str) -> Result<WorkspacesResponse, AdapterError> {
244 serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
245 tool: "maw ws list",
246 detail: e.to_string(),
247 })
248}
249
250pub fn parse_reviews_list(json: &str) -> Result<ReviewsListResponse, AdapterError> {
252 serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
253 tool: "crit reviews list",
254 detail: e.to_string(),
255 })
256}
257
258pub fn parse_review_detail(json: &str) -> Result<ReviewDetailResponse, AdapterError> {
260 serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
261 tool: "crit review",
262 detail: e.to_string(),
263 })
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
273 fn parse_claims_basic() {
274 let json = r#"{"claims": [
275 {"agent": "myapp-dev", "patterns": ["bone://myapp/bd-abc"], "active": true, "memo": "bd-abc"},
276 {"agent": "myapp-dev", "patterns": ["workspace://myapp/frost-castle"], "active": true}
277 ]}"#;
278 let resp = parse_claims(json).unwrap();
279 assert_eq!(resp.claims.len(), 2);
280 assert_eq!(resp.claims[0].agent, "myapp-dev");
281 assert_eq!(resp.claims[0].bone_ids(), vec!["bd-abc"]);
282 assert_eq!(resp.claims[1].workspace_names(), vec!["frost-castle"]);
283 }
284
285 #[test]
286 fn parse_claims_empty() {
287 let json = r#"{"claims": []}"#;
288 let resp = parse_claims(json).unwrap();
289 assert!(resp.claims.is_empty());
290 }
291
292 #[test]
293 fn parse_claims_missing_optional_fields() {
294 let json = r#"{"claims": [{"agent": "dev", "patterns": ["bone://p/bd-x"]}]}"#;
295 let resp = parse_claims(json).unwrap();
296 assert!(!resp.claims[0].active); assert!(resp.claims[0].memo.is_none());
298 assert!(resp.claims[0].expires_at.is_none());
299 }
300
301 #[test]
302 fn parse_claims_extra_fields_tolerated() {
303 let json = r#"{"claims": [{"agent": "dev", "patterns": [], "some_new_field": 42}]}"#;
304 let resp = parse_claims(json).unwrap();
305 assert_eq!(resp.claims.len(), 1);
306 }
307
308 #[test]
309 fn parse_claims_invalid_json() {
310 let result = parse_claims("not json");
311 assert!(result.is_err());
312 let err = result.unwrap_err();
313 assert!(err.to_string().contains("bus claims list"));
314 }
315
316 #[test]
319 fn parse_workspaces_basic() {
320 let json = r#"{"workspaces": [
321 {"name": "default", "is_default": true, "is_current": false, "change_id": "abc123"},
322 {"name": "frost-castle", "is_default": false, "is_current": true, "change_id": "def456"}
323 ], "advice": []}"#;
324 let resp = parse_workspaces(json).unwrap();
325 assert_eq!(resp.workspaces.len(), 2);
326 assert!(resp.workspaces[0].is_default);
327 assert_eq!(resp.workspaces[1].name, "frost-castle");
328 }
329
330 #[test]
331 fn parse_workspaces_with_advice() {
332 let json = r#"{"workspaces": [], "advice": [
333 {"level": "warn", "message": "stale workspace detected", "details": "frost-castle"}
334 ]}"#;
335 let resp = parse_workspaces(json).unwrap();
336 assert_eq!(resp.advice.len(), 1);
337 assert!(resp.advice[0].message.contains("stale"));
338 }
339
340 #[test]
341 fn parse_workspaces_missing_advice() {
342 let json = r#"{"workspaces": [{"name": "default", "is_default": true}]}"#;
343 let resp = parse_workspaces(json).unwrap();
344 assert!(resp.advice.is_empty());
345 }
346
347 #[test]
350 fn parse_bone_show_basic() {
351 let json = r#"{"id": "bd-abc", "title": "Fix login", "state": "doing", "assignees": ["myapp-dev"], "labels": ["bug"]}"#;
352 let bone = parse_bone_show(json).unwrap();
353 assert_eq!(bone.id, "bd-abc");
354 assert_eq!(bone.title, "Fix login");
355 assert_eq!(bone.state, "doing");
356 assert_eq!(bone.assignees, vec!["myapp-dev"]);
357 assert_eq!(bone.labels, vec!["bug"]);
358 }
359
360 #[test]
361 fn parse_bone_show_minimal() {
362 let json = r#"{"id": "bd-abc"}"#;
363 let bone = parse_bone_show(json).unwrap();
364 assert_eq!(bone.id, "bd-abc");
365 assert_eq!(bone.title, "");
366 assert_eq!(bone.state, "");
367 assert!(bone.assignees.is_empty());
368 assert!(bone.labels.is_empty());
369 }
370
371 #[test]
372 fn parse_bone_show_invalid_json() {
373 let result = parse_bone_show("not json");
374 assert!(result.is_err());
375 assert!(result.unwrap_err().to_string().contains("bn show"));
376 }
377
378 #[test]
379 fn parse_bone_show_extra_fields() {
380 let json = r#"{"id": "bd-x", "title": "t", "state": "open", "some_future_field": true}"#;
381 let bone = parse_bone_show(json).unwrap();
382 assert_eq!(bone.id, "bd-x");
383 }
384
385 #[test]
388 fn parse_reviews_list_basic() {
389 let json = r#"{"reviews": [
390 {"review_id": "cr-abc", "title": "feat: login", "status": "open", "change_id": "xyz"}
391 ]}"#;
392 let resp = parse_reviews_list(json).unwrap();
393 assert_eq!(resp.reviews.len(), 1);
394 assert_eq!(resp.reviews[0].review_id, "cr-abc");
395 }
396
397 #[test]
398 fn parse_reviews_list_empty() {
399 let json = r#"{"reviews": []}"#;
400 let resp = parse_reviews_list(json).unwrap();
401 assert!(resp.reviews.is_empty());
402 }
403
404 #[test]
405 fn parse_review_detail_with_votes() {
406 let json = r#"{
407 "review": {
408 "review_id": "cr-abc",
409 "status": "reviewed",
410 "votes": [
411 {"reviewer": "myapp-security", "vote": "lgtm", "voted_at": "2026-02-16T10:00:00Z"},
412 {"reviewer": "myapp-perf", "vote": "block", "voted_at": "2026-02-16T11:00:00Z"}
413 ],
414 "open_thread_count": 2
415 },
416 "threads": [
417 {"thread_id": "th-1", "file": "src/main.rs", "line": 42, "resolved": false, "comments": [
418 {"author": "myapp-security", "body": "Missing validation", "created_at": "2026-02-16T10:00:00Z"}
419 ]}
420 ]
421 }"#;
422 let resp = parse_review_detail(json).unwrap();
423 assert_eq!(resp.review.review_id, "cr-abc");
424 assert_eq!(resp.review.votes.len(), 2);
425 assert!(resp.review.votes[0].is_lgtm());
426 assert!(resp.review.votes[1].is_block());
427 assert_eq!(resp.review.open_thread_count, 2);
428 assert_eq!(resp.threads.len(), 1);
429 assert_eq!(resp.threads[0].comments.len(), 1);
430 }
431
432 #[test]
433 fn parse_review_detail_minimal() {
434 let json = r#"{"review": {"review_id": "cr-x", "status": "open"}, "threads": []}"#;
435 let resp = parse_review_detail(json).unwrap();
436 assert_eq!(resp.review.review_id, "cr-x");
437 assert!(resp.review.votes.is_empty());
438 assert_eq!(resp.review.open_thread_count, 0);
439 }
440
441 #[test]
442 fn parse_review_detail_extra_fields() {
443 let json = r#"{"review": {"review_id": "cr-x", "status": "open", "new_field": "val"}, "threads": []}"#;
444 let resp = parse_review_detail(json).unwrap();
445 assert_eq!(resp.review.review_id, "cr-x");
446 }
447
448 #[test]
451 fn claim_bone_id_extraction() {
452 let claim = Claim {
453 agent: "dev".into(),
454 patterns: vec![
455 "bone://myapp/bd-abc".into(),
456 "workspace://myapp/ws".into(),
457 "agent://myapp-dev".into(),
458 ],
459 active: true,
460 memo: None,
461 expires_at: None,
462 };
463 assert_eq!(claim.bone_ids(), vec!["bd-abc"]);
464 assert_eq!(claim.workspace_names(), vec!["ws"]);
465 }
466
467 #[test]
468 fn claim_no_matching_patterns() {
469 let claim = Claim {
470 agent: "dev".into(),
471 patterns: vec!["agent://myapp-dev".into()],
472 active: true,
473 memo: None,
474 expires_at: None,
475 };
476 assert!(claim.bone_ids().is_empty());
477 assert!(claim.workspace_names().is_empty());
478 }
479}