Skip to main content

edict/commands/protocol/
context.rs

1//! ProtocolContext: cross-tool shared state collector.
2//!
3//! Gathers bus claims, maw workspaces, and bone/review status in a single
4//! structure to avoid duplicating subprocess calls across protocol commands.
5//! Lazy evaluation: state is fetched on-demand via subprocess calls, not upfront.
6
7use std::process::Command;
8
9use super::adapters::{self, BoneInfo, Claim, ReviewDetail, ReviewDetailResponse, Workspace};
10
11/// Cross-tool state collector for protocol commands.
12///
13/// Provides cached access to bus claims and maw workspaces (fetched on construction),
14/// plus lazy on-demand methods for bone/review status.
15#[derive(Debug, Clone)]
16pub struct ProtocolContext {
17    #[allow(dead_code)]
18    project: String,
19    agent: String,
20    claims: Vec<Claim>,
21    workspaces: Vec<Workspace>,
22}
23
24impl ProtocolContext {
25    /// Collect shared state from bus and maw.
26    ///
27    /// Calls:
28    /// - `bus claims list --format json --agent <agent>`
29    /// - `maw ws list --format json`
30    ///
31    /// Returns error if either subprocess fails or output is unparseable.
32    pub fn collect(project: &str, agent: &str) -> Result<Self, ContextError> {
33        // Fetch bus claims
34        let claims_output = Self::run_subprocess(&[
35            "bus", "claims", "list", "--agent", agent, "--format", "json",
36        ])?;
37        let claims_resp = adapters::parse_claims(&claims_output)
38            .map_err(|e| ContextError::ParseFailed(format!("claims: {e}")))?;
39
40        // Fetch maw workspaces
41        let workspaces_output = Self::run_subprocess(&["maw", "ws", "list", "--format", "json"])?;
42        let workspaces_resp = adapters::parse_workspaces(&workspaces_output)
43            .map_err(|e| ContextError::ParseFailed(format!("workspaces: {e}")))?;
44
45        Ok(ProtocolContext {
46            project: project.to_string(),
47            agent: agent.to_string(),
48            claims: claims_resp.claims,
49            workspaces: workspaces_resp.workspaces,
50        })
51    }
52
53    /// Get all held bone claims as (bone_id, pattern) tuples.
54    pub fn held_bone_claims(&self) -> Vec<(&str, &str)> {
55        let mut result = Vec::new();
56        for claim in &self.claims {
57            if claim.agent == self.agent {
58                for pattern in &claim.patterns {
59                    if let Some(bone_id) = pattern
60                        .strip_prefix("bone://")
61                        .and_then(|rest| rest.split('/').nth(1))
62                    {
63                        result.push((bone_id, pattern.as_str()));
64                    }
65                }
66            }
67        }
68        result
69    }
70
71    /// Get all held workspace claims as (workspace_name, pattern) tuples.
72    pub fn held_workspace_claims(&self) -> Vec<(&str, &str)> {
73        let mut result = Vec::new();
74        for claim in &self.claims {
75            if claim.agent == self.agent {
76                for pattern in &claim.patterns {
77                    if let Some(ws_name) = pattern
78                        .strip_prefix("workspace://")
79                        .and_then(|rest| rest.split('/').nth(1))
80                    {
81                        result.push((ws_name, pattern.as_str()));
82                    }
83                }
84            }
85        }
86        result
87    }
88
89    /// Find a workspace by name.
90    #[allow(dead_code)]
91    pub fn find_workspace(&self, name: &str) -> Option<&Workspace> {
92        self.workspaces.iter().find(|ws| ws.name == name)
93    }
94
95    /// Correlate a bone claim with its workspace claim.
96    ///
97    /// Tries memo-based correlation first (most precise), then falls back to
98    /// finding any non-default workspace claim from this agent. The fallback
99    /// is needed because `bus claims list --format json` currently omits the
100    /// memo field, making memo-based lookup fail.
101    pub fn workspace_for_bone(&self, bone_id: &str) -> Option<&str> {
102        // First pass: memo-based correlation (precise, works when bus includes memo)
103        for claim in &self.claims {
104            if claim.agent == self.agent {
105                if let Some(memo) = &claim.memo {
106                    if memo == bone_id {
107                        for pattern in &claim.patterns {
108                            if let Some(ws_name) = pattern
109                                .strip_prefix("workspace://")
110                                .and_then(|rest| rest.split('/').nth(1))
111                            {
112                                return Some(ws_name);
113                            }
114                        }
115                    }
116                }
117            }
118        }
119
120        // Fallback: find any non-default workspace claim from this agent.
121        // Workers hold one bone + one workspace, so this is unambiguous.
122        for claim in &self.claims {
123            if claim.agent == self.agent {
124                for pattern in &claim.patterns {
125                    if let Some(ws_name) = pattern
126                        .strip_prefix("workspace://")
127                        .and_then(|rest| rest.split('/').nth(1))
128                    {
129                        if ws_name != "default" {
130                            return Some(ws_name);
131                        }
132                    }
133                }
134            }
135        }
136        None
137    }
138
139    /// Fetch bone status by calling `maw exec default -- bn show <id> --format json`.
140    pub fn bone_status(&self, bone_id: &str) -> Result<BoneInfo, ContextError> {
141        Self::validate_bone_id(bone_id)?;
142        let output = Self::run_subprocess(&[
143            "maw", "exec", "default", "--", "bn", "show", bone_id, "--format", "json",
144        ])?;
145        let bone = adapters::parse_bone_show(&output)
146            .map_err(|e| ContextError::ParseFailed(format!("bone {bone_id}: {e}")))?;
147        Ok(bone)
148    }
149
150    /// List reviews in a workspace by calling `maw exec <ws> -- crit reviews list --format json`.
151    ///
152    /// Returns empty list if no reviews exist or crit is not configured.
153    pub fn reviews_in_workspace(
154        &self,
155        workspace: &str,
156    ) -> Result<Vec<adapters::ReviewSummary>, ContextError> {
157        Self::validate_workspace_name(workspace)?;
158        let output = Self::run_subprocess(&[
159            "maw", "exec", workspace, "--", "crit", "reviews", "list", "--format", "json",
160        ]);
161        match output {
162            Ok(json) => {
163                let resp = adapters::parse_reviews_list(&json).map_err(|e| {
164                    ContextError::ParseFailed(format!("reviews list in {workspace}: {e}"))
165                })?;
166                Ok(resp.reviews)
167            }
168            Err(_) => {
169                // crit may not be configured or workspace may not have reviews
170                Ok(Vec::new())
171            }
172        }
173    }
174
175    /// Fetch review status by calling `maw exec <ws> -- crit review <id> --format json`.
176    pub fn review_status(
177        &self,
178        review_id: &str,
179        workspace: &str,
180    ) -> Result<ReviewDetail, ContextError> {
181        Self::validate_review_id(review_id)?;
182        Self::validate_workspace_name(workspace)?;
183        let output = Self::run_subprocess(&[
184            "maw", "exec", workspace, "--", "crit", "review", review_id, "--format", "json",
185        ])?;
186        let review_resp: ReviewDetailResponse = serde_json::from_str(&output)
187            .map_err(|e| ContextError::ParseFailed(format!("review {review_id}: {e}")))?;
188        Ok(review_resp.review)
189    }
190
191    /// Check for claim conflicts by querying all claims.
192    ///
193    /// Returns the conflicting claim if another agent holds the bone.
194    pub fn check_bone_claim_conflict(&self, bone_id: &str) -> Result<Option<String>, ContextError> {
195        let output = Self::run_subprocess(&["bus", "claims", "list", "--format", "json"])?;
196        let claims_resp = adapters::parse_claims(&output)
197            .map_err(|e| ContextError::ParseFailed(format!("all claims: {e}")))?;
198
199        for claim in &claims_resp.claims {
200            if claim.agent != self.agent {
201                for pattern in &claim.patterns {
202                    if let Some(id) = pattern
203                        .strip_prefix("bone://")
204                        .and_then(|rest| rest.split('/').nth(1))
205                    {
206                        if id == bone_id {
207                            return Ok(Some(claim.agent.clone()));
208                        }
209                    }
210                }
211            }
212        }
213        Ok(None)
214    }
215
216    /// Validate that a bone ID is safe for subprocess use.
217    ///
218    /// Bone ID prefixes vary by project (e.g., `bd-`, `bn-`, `bm-`).
219    /// We validate the format (short alphanumeric with hyphens) without
220    /// hardcoding a specific prefix.
221    fn validate_bone_id(id: &str) -> Result<(), ContextError> {
222        if !id.is_empty()
223            && id.len() <= 20
224            && id.contains('-')
225            && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
226        {
227            Ok(())
228        } else {
229            Err(ContextError::ParseFailed(format!("invalid bone ID: {id}")))
230        }
231    }
232
233    /// Validate that a workspace name is safe (alphanumeric + hyphens only).
234    fn validate_workspace_name(name: &str) -> Result<(), ContextError> {
235        if !name.is_empty()
236            && name.len() <= 64
237            && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
238        {
239            Ok(())
240        } else {
241            Err(ContextError::ParseFailed(format!(
242                "invalid workspace name: {name}"
243            )))
244        }
245    }
246
247    /// Validate that a review ID matches the expected pattern (cr-xxxx).
248    fn validate_review_id(id: &str) -> Result<(), ContextError> {
249        if id.starts_with("cr-")
250            && id.len() <= 20
251            && id[3..].chars().all(|c| c.is_ascii_alphanumeric())
252        {
253            Ok(())
254        } else {
255            Err(ContextError::ParseFailed(format!(
256                "invalid review ID: {id}"
257            )))
258        }
259    }
260
261    /// Run a subprocess and capture stdout.
262    fn run_subprocess(args: &[&str]) -> Result<String, ContextError> {
263        let mut cmd = Command::new(args[0]);
264        for arg in &args[1..] {
265            cmd.arg(arg);
266        }
267
268        let output = cmd
269            .output()
270            .map_err(|e| ContextError::SubprocessFailed(format!("{}: {e}", args[0])))?;
271
272        if !output.status.success() {
273            let stderr = String::from_utf8_lossy(&output.stderr);
274            return Err(ContextError::SubprocessFailed(format!(
275                "{} exited with status {}: {}",
276                args[0],
277                output.status.code().unwrap_or(-1),
278                stderr.trim()
279            )));
280        }
281
282        Ok(String::from_utf8(output.stdout).map_err(|e| {
283            ContextError::SubprocessFailed(format!("invalid UTF-8 from {}: {e}", args[0]))
284        })?)
285    }
286
287    #[allow(dead_code)]
288    pub fn project(&self) -> &str {
289        &self.project
290    }
291
292    #[allow(dead_code)]
293    pub fn agent(&self) -> &str {
294        &self.agent
295    }
296
297    #[allow(dead_code)]
298    pub fn claims(&self) -> &[Claim] {
299        &self.claims
300    }
301
302    #[allow(dead_code)]
303    pub fn workspaces(&self) -> &[Workspace] {
304        &self.workspaces
305    }
306}
307
308/// Errors during context collection and state queries.
309#[derive(Debug, Clone)]
310pub enum ContextError {
311    /// Subprocess execution failed (command not found, permission denied, etc.)
312    SubprocessFailed(String),
313    /// Output parsing failed (invalid JSON, missing fields, etc.)
314    ParseFailed(String),
315}
316
317impl std::fmt::Display for ContextError {
318    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319        match self {
320            ContextError::SubprocessFailed(msg) => write!(f, "subprocess failed: {msg}"),
321            ContextError::ParseFailed(msg) => write!(f, "parse failed: {msg}"),
322        }
323    }
324}
325
326impl std::error::Error for ContextError {}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // Mock responses for testing without subprocess calls.
333    // Bus creates separate claims per stake call (no memo in JSON output).
334    const CLAIMS_JSON: &str = r#"{"claims": [
335        {"agent": "crimson-storm", "patterns": ["bone://edict/bd-3cqv"], "active": true},
336        {"agent": "crimson-storm", "patterns": ["workspace://edict/frost-forest"], "active": true},
337        {"agent": "green-vertex", "patterns": ["bone://edict/bd-3t1d"], "active": true}
338    ]}"#;
339
340    const WORKSPACES_JSON: &str = r#"{"workspaces": [
341        {"name": "default", "is_default": true, "is_current": false, "change_id": "abc123"},
342        {"name": "frost-forest", "is_default": false, "is_current": true, "change_id": "def456"}
343    ], "advice": []}"#;
344
345    #[test]
346    fn test_held_bone_claims() {
347        let claims_resp = adapters::parse_claims(CLAIMS_JSON).unwrap();
348        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
349
350        let ctx = ProtocolContext {
351            project: "edict".to_string(),
352            agent: "crimson-storm".to_string(),
353            claims: claims_resp.claims,
354            workspaces: workspaces_resp.workspaces,
355        };
356
357        let bead_claims = ctx.held_bone_claims();
358        assert_eq!(bead_claims.len(), 1);
359        assert_eq!(bead_claims[0].0, "bd-3cqv");
360    }
361
362    #[test]
363    fn test_held_workspace_claims() {
364        let claims_resp = adapters::parse_claims(CLAIMS_JSON).unwrap();
365        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
366
367        let ctx = ProtocolContext {
368            project: "edict".to_string(),
369            agent: "crimson-storm".to_string(),
370            claims: claims_resp.claims,
371            workspaces: workspaces_resp.workspaces,
372        };
373
374        let ws_claims = ctx.held_workspace_claims();
375        assert_eq!(ws_claims.len(), 1);
376        assert_eq!(ws_claims[0].0, "frost-forest");
377    }
378
379    #[test]
380    fn test_find_workspace() {
381        let claims_resp = adapters::parse_claims(CLAIMS_JSON).unwrap();
382        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
383
384        let ctx = ProtocolContext {
385            project: "edict".to_string(),
386            agent: "crimson-storm".to_string(),
387            claims: claims_resp.claims,
388            workspaces: workspaces_resp.workspaces,
389        };
390
391        let ws = ctx.find_workspace("frost-forest");
392        assert!(ws.is_some());
393        assert_eq!(ws.unwrap().name, "frost-forest");
394        assert!(!ws.unwrap().is_default);
395    }
396
397    #[test]
398    fn test_workspace_for_bone() {
399        let claims_resp = adapters::parse_claims(CLAIMS_JSON).unwrap();
400        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
401
402        let ctx = ProtocolContext {
403            project: "edict".to_string(),
404            agent: "crimson-storm".to_string(),
405            claims: claims_resp.claims,
406            workspaces: workspaces_resp.workspaces,
407        };
408
409        let ws = ctx.workspace_for_bone("bd-3cqv");
410        assert_eq!(ws, Some("frost-forest"));
411    }
412
413    #[test]
414    fn test_workspace_for_bone_fallback_no_memo() {
415        // When bus omits memo from JSON, fallback finds workspace by agent match
416        let json = r#"{"claims": [
417            {"agent": "dev-agent", "patterns": ["bone://proj/bd-abc"], "active": true},
418            {"agent": "dev-agent", "patterns": ["workspace://proj/ember-tower"], "active": true}
419        ]}"#;
420        let claims_resp = adapters::parse_claims(json).unwrap();
421        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
422
423        let ctx = ProtocolContext {
424            project: "proj".to_string(),
425            agent: "dev-agent".to_string(),
426            claims: claims_resp.claims,
427            workspaces: workspaces_resp.workspaces,
428        };
429
430        let ws = ctx.workspace_for_bone("bd-abc");
431        assert_eq!(ws, Some("ember-tower"));
432    }
433
434    #[test]
435    fn test_workspace_for_bone_skips_default() {
436        // Fallback must not return "default" workspace
437        let json = r#"{"claims": [
438            {"agent": "dev-agent", "patterns": ["bone://proj/bd-abc"], "active": true},
439            {"agent": "dev-agent", "patterns": ["workspace://proj/default"], "active": true}
440        ]}"#;
441        let claims_resp = adapters::parse_claims(json).unwrap();
442        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
443
444        let ctx = ProtocolContext {
445            project: "proj".to_string(),
446            agent: "dev-agent".to_string(),
447            claims: claims_resp.claims,
448            workspaces: workspaces_resp.workspaces,
449        };
450
451        let ws = ctx.workspace_for_bone("bd-abc");
452        assert_eq!(ws, None); // default is filtered out
453    }
454
455    #[test]
456    fn test_held_bone_claims_other_agent() {
457        let claims_resp = adapters::parse_claims(CLAIMS_JSON).unwrap();
458        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
459
460        // Using green-vertex context
461        let ctx = ProtocolContext {
462            project: "edict".to_string(),
463            agent: "green-vertex".to_string(),
464            claims: claims_resp.claims,
465            workspaces: workspaces_resp.workspaces,
466        };
467
468        let bead_claims = ctx.held_bone_claims();
469        assert_eq!(bead_claims.len(), 1);
470        assert_eq!(bead_claims[0].0, "bd-3t1d");
471    }
472
473    #[test]
474    fn test_empty_claims() {
475        let empty = r#"{"claims": []}"#;
476        let claims_resp = adapters::parse_claims(empty).unwrap();
477        let workspaces_resp = adapters::parse_workspaces(WORKSPACES_JSON).unwrap();
478
479        let ctx = ProtocolContext {
480            project: "edict".to_string(),
481            agent: "crimson-storm".to_string(),
482            claims: claims_resp.claims,
483            workspaces: workspaces_resp.workspaces,
484        };
485
486        assert!(ctx.held_bone_claims().is_empty());
487        assert!(ctx.held_workspace_claims().is_empty());
488    }
489}