Skip to main content

edict/commands/protocol/
adapters.rs

1//! JSON adapters for companion tool output.
2//!
3//! Tolerant parsing for bus claims, maw workspaces, bn show, and crit review.
4//! Each adapter handles optional/new fields gracefully and produces clear
5//! parse errors. ProtocolContext consumes these instead of ad-hoc parsing.
6
7use serde::Deserialize;
8
9// --- Bus Claims ---
10
11/// Parsed output from `bus claims list --format json`.
12#[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    /// Extract bone IDs from `bone://project/bd-xxx` patterns.
34    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    /// Extract workspace names from `workspace://project/ws-name` patterns.
45    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// --- Maw Workspaces ---
57
58/// Parsed output from `maw ws list --format json`.
59#[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// --- Bones (bn show) ---
93
94/// Parsed output from `bn show <id> --format json`.
95///
96/// bn show returns a single JSON object.
97#[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
114/// Parse `bn show --format json` output. Returns the bone info.
115pub fn parse_bone_show(json: &str) -> Result<BoneInfo, AdapterError> {
116    // bn show returns a single object
117    serde_json::from_str(json).map_err(|e| AdapterError::ParseFailed {
118        tool: "bn show",
119        detail: e.to_string(),
120    })
121}
122
123// --- Botcrit Reviews ---
124
125/// Parsed output from `crit reviews list --format json`.
126#[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/// Parsed output from `crit review <id> --format json`.
146#[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// --- Adapter Errors ---
210
211#[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
232// --- Convenience parsers ---
233
234/// Parse `bus claims list --format json`.
235pub 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
242/// Parse `maw ws list --format json`.
243pub 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
250/// Parse `crit reviews list --format json`.
251pub 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
258/// Parse `crit review <id> --format json`.
259pub 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    // --- Claims parsing ---
271
272    #[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); // defaults to false
297        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    // --- Workspace parsing ---
317
318    #[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    // --- Bone parsing ---
348
349    #[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    // --- Review parsing ---
386
387    #[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    // --- Claim helper tests ---
449
450    #[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}