1use std::process::Command;
8
9use super::adapters::{self, BoneInfo, Claim, ReviewDetail, ReviewDetailResponse, Workspace};
10
11#[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 pub fn collect(project: &str, agent: &str) -> Result<Self, ContextError> {
33 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 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 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 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 #[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 pub fn workspace_for_bone(&self, bone_id: &str) -> Option<&str> {
102 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 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 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 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 Ok(Vec::new())
171 }
172 }
173 }
174
175 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 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 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 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 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 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#[derive(Debug, Clone)]
310pub enum ContextError {
311 SubprocessFailed(String),
313 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 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 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 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); }
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 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}