1use crate::commands::doctor::OutputFormat;
6use crate::commands::protocol::executor::ExecutionReport;
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ProtocolGuidance {
26 pub schema: &'static str,
28 pub command: &'static str,
30 pub status: ProtocolStatus,
32 pub snapshot_at: String,
34 pub valid_for_sec: u32,
36 pub revalidate_cmd: Option<String>,
38 pub bone: Option<BoneRef>,
40 pub workspace: Option<String>,
42 pub review: Option<ReviewRef>,
44 pub steps: Vec<String>,
46 pub diagnostics: Vec<String>,
48 pub advice: Option<String>,
50 #[serde(default)]
52 pub executed: bool,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub execution_report: Option<ExecutionReport>,
56}
57
58impl ProtocolGuidance {
59 pub fn new(command: &'static str) -> Self {
62 Self {
63 schema: "protocol-guidance.v1",
64 command,
65 status: ProtocolStatus::Ready,
66 snapshot_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
67 valid_for_sec: 300, revalidate_cmd: None,
69 bone: None,
70 workspace: None,
71 review: None,
72 steps: Vec::new(),
73 diagnostics: Vec::new(),
74 advice: None,
75 executed: false,
76 execution_report: None,
77 }
78 }
79
80 pub fn set_freshness(&mut self, valid_for_sec: u32, revalidate_cmd: Option<String>) {
83 self.valid_for_sec = valid_for_sec;
84 self.revalidate_cmd = revalidate_cmd;
85 }
86
87 pub fn step(&mut self, cmd: String) {
89 self.steps.push(cmd);
90 }
91
92 pub fn steps(&mut self, cmds: Vec<String>) {
94 self.steps.extend(cmds);
95 }
96
97 pub fn diagnostic(&mut self, msg: String) {
99 self.diagnostics.push(msg);
100 }
101
102 pub fn blocked(&mut self, reason: String) {
104 self.status = ProtocolStatus::Blocked;
105 self.diagnostic(reason);
106 }
107
108 pub fn advise(&mut self, msg: String) {
110 self.advice = Some(msg);
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "PascalCase")]
117pub enum ProtocolStatus {
118 Ready, Blocked, Resumable, NeedsReview, HasResources, Clean, HasWork, Fresh, }
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BoneRef {
131 pub id: String,
132 pub title: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ReviewRef {
138 pub review_id: String,
139 pub status: String,
140}
141
142use super::shell::{
145 ValidationError, validate_bone_id, validate_review_id, validate_workspace_name,
146};
147
148pub fn validate_guidance(guidance: &ProtocolGuidance) -> Result<(), ValidationError> {
150 if let Some(ref bone) = guidance.bone {
151 validate_bone_id(&bone.id)?;
152 }
153 if let Some(ref ws) = guidance.workspace {
154 validate_workspace_name(ws)?;
155 }
156 if let Some(ref review) = guidance.review {
157 validate_review_id(&review.review_id)?;
158 }
159 Ok(())
160}
161
162pub fn render_text(guidance: &ProtocolGuidance) -> String {
180 let mut out = String::new();
181
182 writeln!(&mut out, "Command: {}", guidance.command).unwrap();
184 writeln!(&mut out, "Status: {}", format_status(guidance.status)).unwrap();
185 writeln!(
186 &mut out,
187 "Snapshot: {} (valid for {}s)",
188 guidance.snapshot_at, guidance.valid_for_sec
189 )
190 .unwrap();
191
192 if let Some(ref bone) = guidance.bone {
193 writeln!(&mut out, "Bone: {} ({})", bone.id, bone.title).unwrap();
194 }
195 if let Some(ref ws) = guidance.workspace {
196 writeln!(&mut out, "Workspace: {}", ws).unwrap();
197 }
198 if let Some(ref review) = guidance.review {
199 writeln!(&mut out, "Review: {} ({})", review.review_id, review.status).unwrap();
200 }
201
202 if let Some(ref cmd) = guidance.revalidate_cmd {
203 writeln!(&mut out, "Revalidate: {}", cmd).unwrap();
204 }
205
206 if !guidance.diagnostics.is_empty() {
207 writeln!(&mut out).unwrap();
208 writeln!(&mut out, "Diagnostics:").unwrap();
209 for (i, diag) in guidance.diagnostics.iter().enumerate() {
210 writeln!(&mut out, " {}. {}", i + 1, diag).unwrap();
211 }
212 }
213
214 if guidance.executed {
216 if let Some(ref report) = guidance.execution_report {
217 writeln!(&mut out).unwrap();
218 writeln!(&mut out, "Execution:").unwrap();
219 let exec_output = super::executor::render_report(report, OutputFormat::Text);
220 for line in exec_output.lines() {
221 writeln!(&mut out, " {}", line).unwrap();
222 }
223 }
224 } else if !guidance.steps.is_empty() {
225 writeln!(&mut out).unwrap();
227 writeln!(&mut out, "Steps:").unwrap();
228 for (i, step) in guidance.steps.iter().enumerate() {
229 writeln!(&mut out, " {}. {}", i + 1, step).unwrap();
230 }
231 }
232
233 if let Some(ref advice) = guidance.advice {
234 writeln!(&mut out).unwrap();
235 writeln!(&mut out, "Advice: {}", advice).unwrap();
236 }
237
238 out
239}
240
241pub fn render_json(guidance: &ProtocolGuidance) -> Result<String, serde_json::Error> {
243 let json = serde_json::to_string_pretty(guidance)?;
244 Ok(json)
245}
246
247pub fn render_pretty(guidance: &ProtocolGuidance) -> String {
251 let mut out = String::new();
252
253 let reset = "\x1b[0m";
255 let bold = "\x1b[1m";
256 let green = "\x1b[32m";
257 let yellow = "\x1b[33m";
258 let red = "\x1b[31m";
259
260 let status_color = match guidance.status {
262 ProtocolStatus::Ready | ProtocolStatus::Clean | ProtocolStatus::Fresh => green,
263 ProtocolStatus::Blocked | ProtocolStatus::HasWork => red,
264 _ => yellow,
265 };
266
267 writeln!(&mut out, "{}Command:{} {}", bold, reset, guidance.command).unwrap();
269 writeln!(
270 &mut out,
271 "{}Status:{} {}{}{}\n",
272 bold,
273 reset,
274 status_color,
275 format_status(guidance.status),
276 reset
277 )
278 .unwrap();
279
280 writeln!(
281 &mut out,
282 "{}Snapshot:{} {} (valid for {}s)",
283 bold, reset, guidance.snapshot_at, guidance.valid_for_sec
284 )
285 .unwrap();
286
287 if let Some(ref bone) = guidance.bone {
288 writeln!(
289 &mut out,
290 "{}Bone:{} {} ({})",
291 bold, reset, bone.id, bone.title
292 )
293 .unwrap();
294 }
295 if let Some(ref ws) = guidance.workspace {
296 writeln!(&mut out, "{}Workspace:{} {}", bold, reset, ws).unwrap();
297 }
298 if let Some(ref review) = guidance.review {
299 writeln!(
300 &mut out,
301 "{}Review:{} {} ({})",
302 bold, reset, review.review_id, review.status
303 )
304 .unwrap();
305 }
306
307 if let Some(ref cmd) = guidance.revalidate_cmd {
308 writeln!(&mut out, "{}Revalidate:{} {}", bold, reset, cmd).unwrap();
309 }
310
311 if !guidance.diagnostics.is_empty() {
312 writeln!(&mut out, "\n{}Diagnostics:{}", bold, reset).unwrap();
313 for diag in &guidance.diagnostics {
314 writeln!(&mut out, " {}{}{}", red, diag, reset).unwrap();
315 }
316 }
317
318 if guidance.executed {
320 if let Some(ref report) = guidance.execution_report {
321 writeln!(&mut out, "\n{}Execution:{}", bold, reset).unwrap();
322 let exec_output = super::executor::render_report(report, OutputFormat::Pretty);
323 for line in exec_output.lines() {
324 writeln!(&mut out, " {}", line).unwrap();
325 }
326 }
327 } else if !guidance.steps.is_empty() {
328 writeln!(&mut out, "\n{}Steps:{}", bold, reset).unwrap();
330 for (i, step) in guidance.steps.iter().enumerate() {
331 writeln!(&mut out, " {}. {}", i + 1, step).unwrap();
332 }
333 }
334
335 if let Some(ref advice) = guidance.advice {
336 writeln!(&mut out, "\n{}Advice:{} {}", bold, reset, advice).unwrap();
337 }
338
339 out
340}
341
342fn format_status(status: ProtocolStatus) -> &'static str {
344 match status {
345 ProtocolStatus::Ready => "Ready",
346 ProtocolStatus::Blocked => "Blocked",
347 ProtocolStatus::Resumable => "Resumable",
348 ProtocolStatus::NeedsReview => "Needs Review",
349 ProtocolStatus::HasResources => "Has Resources",
350 ProtocolStatus::Clean => "Clean",
351 ProtocolStatus::HasWork => "Has Work",
352 ProtocolStatus::Fresh => "Fresh",
353 }
354}
355
356pub fn render(guidance: &ProtocolGuidance, format: OutputFormat) -> Result<String, String> {
358 validate_guidance(guidance).map_err(|e| e.to_string())?;
360
361 Ok(match format {
362 OutputFormat::Json => render_json(guidance).map_err(|e| e.to_string())?,
363 OutputFormat::Pretty => render_pretty(guidance),
364 OutputFormat::Text => render_text(guidance),
365 })
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::commands::protocol::executor::StepResult;
372
373 #[test]
376 fn guidance_new_start() {
377 let g = ProtocolGuidance::new("start");
378 assert_eq!(g.command, "start");
379 assert_eq!(g.status, ProtocolStatus::Ready);
380 assert_eq!(g.steps.len(), 0);
381 }
382
383 #[test]
384 fn guidance_add_step() {
385 let mut g = ProtocolGuidance::new("start");
386 g.step("echo hello".to_string());
387 assert_eq!(g.steps.len(), 1);
388 assert_eq!(g.steps[0], "echo hello");
389 }
390
391 #[test]
392 fn guidance_add_multiple_steps() {
393 let mut g = ProtocolGuidance::new("finish");
394 g.steps(vec![
395 "cmd1".to_string(),
396 "cmd2".to_string(),
397 "cmd3".to_string(),
398 ]);
399 assert_eq!(g.steps.len(), 3);
400 }
401
402 #[test]
403 fn guidance_blocked() {
404 let mut g = ProtocolGuidance::new("start");
405 g.blocked("workspace not found".to_string());
406 assert_eq!(g.status, ProtocolStatus::Blocked);
407 assert_eq!(g.diagnostics.len(), 1);
408 assert!(g.diagnostics[0].contains("workspace"));
409 }
410
411 #[test]
412 fn guidance_with_bone_and_workspace() {
413 let mut g = ProtocolGuidance::new("start");
414 g.bone = Some(BoneRef {
415 id: "bd-3t1d".to_string(),
416 title: "protocol: shell-safe command renderer".to_string(),
417 });
418 g.workspace = Some("brave-tiger".to_string());
419 assert!(g.bone.is_some());
420 assert!(g.workspace.is_some());
421 }
422
423 #[test]
424 fn guidance_with_review() {
425 let mut g = ProtocolGuidance::new("review");
426 g.review = Some(ReviewRef {
427 review_id: "cr-abc1".to_string(),
428 status: "open".to_string(),
429 });
430 assert!(g.review.is_some());
431 }
432
433 #[test]
434 fn guidance_with_advice() {
435 let mut g = ProtocolGuidance::new("start");
436 g.advise("Create workspace and stake claims first.".to_string());
437 assert!(g.advice.is_some());
438 assert!(g.advice.as_ref().unwrap().contains("workspace"));
439 }
440
441 #[test]
444 fn validate_guidance_valid() {
445 let mut g = ProtocolGuidance::new("start");
446 g.bone = Some(BoneRef {
447 id: "bd-3t1d".to_string(),
448 title: "test".to_string(),
449 });
450 g.workspace = Some("brave-tiger".to_string());
451 assert!(validate_guidance(&g).is_ok());
452 }
453
454 #[test]
455 fn validate_guidance_invalid_bead_id() {
456 let mut g = ProtocolGuidance::new("start");
457 g.bone = Some(BoneRef {
458 id: "has spaces; rm -rf".to_string(),
459 title: "test".to_string(),
460 });
461 assert!(validate_guidance(&g).is_err());
462 }
463
464 #[test]
465 fn validate_guidance_invalid_workspace_name() {
466 let mut g = ProtocolGuidance::new("start");
467 g.workspace = Some("invalid name".to_string());
468 assert!(validate_guidance(&g).is_err());
469 }
470
471 #[test]
474 fn render_text_minimal() {
475 let g = ProtocolGuidance::new("start");
476 let text = render_text(&g);
477 assert!(text.contains("Command: start"));
478 assert!(text.contains("Status: Ready"));
479 }
480
481 #[test]
482 fn render_text_with_bead_and_steps() {
483 let mut g = ProtocolGuidance::new("start");
484 g.bone = Some(BoneRef {
485 id: "bd-abc".to_string(),
486 title: "Test feature".to_string(),
487 });
488 g.step("echo step 1".to_string());
489 g.step("echo step 2".to_string());
490
491 let text = render_text(&g);
492 assert!(text.contains("Bone: bd-abc (Test feature)"));
493 assert!(text.contains("Steps:"));
494 assert!(text.contains("echo step 1"));
495 assert!(text.contains("echo step 2"));
496 }
497
498 #[test]
499 fn render_text_with_diagnostics() {
500 let mut g = ProtocolGuidance::new("finish");
501 g.blocked("review not approved".to_string());
502 g.diagnostic("waiting for LGTM".to_string());
503
504 let text = render_text(&g);
505 assert!(text.contains("Status: Blocked"));
506 assert!(text.contains("Diagnostics:"));
507 assert!(text.contains("review not approved"));
508 assert!(text.contains("waiting for LGTM"));
509 }
510
511 #[test]
512 fn render_text_with_advice() {
513 let mut g = ProtocolGuidance::new("cleanup");
514 g.advise("Run the cleanup steps to release held resources.".to_string());
515
516 let text = render_text(&g);
517 assert!(text.contains("Advice:"));
518 assert!(text.contains("cleanup steps"));
519 }
520
521 #[test]
522 fn render_json_valid() {
523 let mut g = ProtocolGuidance::new("start");
524 g.bone = Some(BoneRef {
525 id: "bd-xyz".to_string(),
526 title: "Feature".to_string(),
527 });
528 g.step("echo test".to_string());
529
530 let json = render_json(&g).unwrap();
531 assert!(json.contains("schema"));
532 assert!(json.contains("protocol-guidance.v1"));
533 assert!(json.contains("\"command\": \"start\"") || json.contains("\"command\":\"start\""));
534 assert!(json.contains("bd-xyz"));
535 assert!(json.contains("steps"));
536 assert!(json.contains("echo test"));
537 }
538
539 #[test]
540 fn render_pretty_has_colors() {
541 let g = ProtocolGuidance::new("start");
542 let pretty = render_pretty(&g);
543 assert!(pretty.contains("\x1b["));
545 }
546
547 #[test]
548 fn render_pretty_ready_status_is_green() {
549 let g = ProtocolGuidance::new("start");
550 let pretty = render_pretty(&g);
551 assert!(pretty.contains("\x1b[32m")); }
554
555 #[test]
556 fn render_pretty_blocked_status_is_red() {
557 let mut g = ProtocolGuidance::new("start");
558 g.blocked("error".to_string());
559 let pretty = render_pretty(&g);
560 assert!(pretty.contains("\x1b[31m")); }
563
564 #[test]
567 fn golden_start_workflow() {
568 let mut g = ProtocolGuidance::new("start");
570 g.bone = Some(BoneRef {
571 id: "bd-3t1d".to_string(),
572 title: "protocol: shell-safe command renderer".to_string(),
573 });
574 g.workspace = Some("brave-tiger".to_string());
575 g.steps(vec![
576 "maw exec default -- bn do bd-3t1d".to_string(),
577 "bus claims stake --agent crimson-storm 'bone://edict/bd-3t1d' -m 'bd-3t1d'"
578 .to_string(),
579 "maw ws create --random".to_string(),
580 "bus claims stake --agent crimson-storm 'workspace://edict/brave-tiger' -m 'bd-3t1d'"
581 .to_string(),
582 ]);
583 g.advise("Workspace created. Implement render.rs with ProtocolGuidance, ProtocolStatus, and rendering functions.".to_string());
584
585 let text = render_text(&g);
586 assert!(text.contains("Command: start"));
587 assert!(text.contains("brave-tiger"));
588 assert!(text.contains("3. maw ws create --random"));
589 assert!(text.contains("Advice:"));
590 }
591
592 #[test]
593 fn golden_blocked_workflow() {
594 let mut g = ProtocolGuidance::new("start");
596 g.bone = Some(BoneRef {
597 id: "bd-3t1d".to_string(),
598 title: "protocol: shell-safe command renderer".to_string(),
599 });
600 g.blocked("bone already claimed by another agent".to_string());
601 g.diagnostic("Check: bus claims list --format json".to_string());
602
603 let text = render_text(&g);
604 assert!(text.contains("Status: Blocked"));
605 assert!(text.contains("already claimed"));
606 assert!(text.contains("bus claims list"));
607 }
608
609 #[test]
610 fn golden_review_workflow() {
611 let mut g = ProtocolGuidance::new("review");
613 g.bone = Some(BoneRef {
614 id: "bd-3t1d".to_string(),
615 title: "protocol: shell-safe command renderer".to_string(),
616 });
617 g.workspace = Some("brave-tiger".to_string());
618 g.review = Some(ReviewRef {
619 review_id: "cr-123".to_string(),
620 status: "open".to_string(),
621 });
622 g.status = ProtocolStatus::NeedsReview;
623 g.steps(vec![
624 "maw exec brave-tiger -- crit reviews request cr-123 --reviewers edict-security --agent crimson-storm".to_string(),
625 "bus send --agent crimson-storm edict 'Review requested: cr-123 @edict-security' -L review-request".to_string(),
626 ]);
627 g.advise("Review is open. Awaiting approval from edict-security.".to_string());
628
629 let text = render_text(&g);
630 assert!(text.contains("Status: Needs Review"));
631 assert!(text.contains("cr-123"));
632 assert!(text.contains("edict-security"));
633 }
634
635 #[test]
636 fn golden_cleanup_workflow() {
637 let mut g = ProtocolGuidance::new("cleanup");
639 g.status = ProtocolStatus::Clean;
640 g.steps(vec![
641 "bus claims list --agent crimson-storm --mine --format json".to_string(),
642 "bus claims release --agent crimson-storm --all".to_string(),
643 ]);
644 g.advise("All held resources released.".to_string());
645
646 let text = render_text(&g);
647 assert!(text.contains("Command: cleanup"));
648 assert!(text.contains("bus claims release") && text.contains("--all"));
649 assert!(text.contains("Clean"));
650 }
651
652 #[test]
653 fn status_serialization() {
654 let json = serde_json::to_string(&ProtocolStatus::Ready).unwrap();
656 assert_eq!(json, "\"Ready\"");
657
658 let json = serde_json::to_string(&ProtocolStatus::NeedsReview).unwrap();
659 assert_eq!(json, "\"NeedsReview\"");
660
661 let json = serde_json::to_string(&ProtocolStatus::HasWork).unwrap();
662 assert_eq!(json, "\"HasWork\"");
663 }
664
665 #[test]
666 fn guidance_json_roundtrip() {
667 let mut original = ProtocolGuidance::new("start");
668 original.bone = Some(BoneRef {
669 id: "bd-abc".to_string(),
670 title: "test".to_string(),
671 });
672 original.steps = vec!["echo hello".to_string()];
673
674 let json = render_json(&original).unwrap();
676 assert!(json.contains("command"));
677 assert!(json.contains("start"));
678 assert!(json.contains("bd-abc"));
679 assert!(json.contains("echo hello"));
680
681 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
683 assert_eq!(parsed["command"].as_str(), Some("start"));
684 assert_eq!(parsed["status"].as_str(), Some("Ready"));
685 }
686
687 #[test]
688 fn snapshot_at_is_rfc3339() {
689 let g = ProtocolGuidance::new("start");
690 assert!(g.snapshot_at.contains("T"));
692 assert!(g.snapshot_at.contains("Z") || g.snapshot_at.contains("+"));
693 }
694
695 #[test]
698 fn guidance_default_freshness() {
699 let g = ProtocolGuidance::new("start");
700 assert_eq!(g.valid_for_sec, 300); assert!(g.revalidate_cmd.is_none());
702 }
703
704 #[test]
705 fn guidance_set_freshness() {
706 let mut g = ProtocolGuidance::new("start");
707 g.set_freshness(600, Some("edict protocol start".to_string()));
708 assert_eq!(g.valid_for_sec, 600);
709 assert_eq!(g.revalidate_cmd, Some("edict protocol start".to_string()));
710 }
711
712 #[test]
713 fn render_text_includes_freshness() {
714 let mut g = ProtocolGuidance::new("start");
715 g.set_freshness(300, Some("edict protocol start".to_string()));
716 let text = render_text(&g);
717 assert!(text.contains("Snapshot:"));
718 assert!(text.contains("valid for 300s"));
719 assert!(text.contains("Revalidate: edict protocol start"));
720 }
721
722 #[test]
723 fn render_json_includes_freshness() {
724 let mut g = ProtocolGuidance::new("start");
725 g.set_freshness(600, Some("edict protocol start".to_string()));
726 let json = render_json(&g).unwrap();
727 assert!(json.contains("valid_for_sec"));
728 assert!(json.contains("600"));
729 assert!(json.contains("revalidate_cmd"));
730 }
731
732 #[test]
733 fn guidance_stale_window_logic() {
734 let mut g = ProtocolGuidance::new("start");
736 g.set_freshness(1, Some("edict protocol start".to_string())); let guidance_json = render_json(&g).unwrap();
739 let parsed: serde_json::Value = serde_json::from_str(&guidance_json).unwrap();
740
741 let snapshot_str = parsed["snapshot_at"].as_str().unwrap();
742 let valid_for_sec = parsed["valid_for_sec"].as_u64().unwrap();
743 let revalidate_cmd = parsed["revalidate_cmd"].as_str();
744
745 assert!(!snapshot_str.is_empty());
746 assert_eq!(valid_for_sec, 1);
747 assert!(revalidate_cmd.is_some());
748 }
749
750 #[test]
753 fn golden_schema_version_is_stable() {
754 let g = ProtocolGuidance::new("start");
755 assert_eq!(g.schema, "protocol-guidance.v1");
756 }
757
758 #[test]
759 fn golden_status_variants_are_complete() {
760 let _statuses = vec![
761 ProtocolStatus::Ready,
762 ProtocolStatus::Blocked,
763 ProtocolStatus::Resumable,
764 ProtocolStatus::NeedsReview,
765 ProtocolStatus::HasResources,
766 ProtocolStatus::Clean,
767 ProtocolStatus::HasWork,
768 ProtocolStatus::Fresh,
769 ];
770 assert_eq!(_statuses.len(), 8);
771 }
772
773 #[test]
774 fn golden_guidance_json_structure() {
775 let mut g = ProtocolGuidance::new("start");
776 g.bone = Some(BoneRef {
777 id: "bd-3t1d".to_string(),
778 title: "test".to_string(),
779 });
780 g.workspace = Some("test-ws".to_string());
781 g.step("echo test".to_string());
782 g.diagnostic("info".to_string());
783
784 let json = render_json(&g).unwrap();
785 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
786
787 assert!(parsed.get("schema").is_some(), "schema field missing");
788 assert!(parsed.get("command").is_some(), "command field missing");
789 assert!(parsed.get("status").is_some(), "status field missing");
790 assert!(
791 parsed.get("snapshot_at").is_some(),
792 "snapshot_at field missing"
793 );
794 assert!(
795 parsed.get("valid_for_sec").is_some(),
796 "valid_for_sec field missing"
797 );
798 assert!(parsed.get("steps").is_some(), "steps field missing");
799 assert!(
800 parsed.get("diagnostics").is_some(),
801 "diagnostics field missing"
802 );
803 }
804
805 #[test]
806 fn golden_minimal_guidance_json() {
807 let g = ProtocolGuidance::new("cleanup");
808 let json = render_json(&g).unwrap();
809 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
810
811 assert_eq!(parsed["schema"].as_str(), Some("protocol-guidance.v1"));
812 assert_eq!(parsed["command"].as_str(), Some("cleanup"));
813 assert_eq!(parsed["status"].as_str(), Some("Ready"));
814 assert!(parsed["snapshot_at"].is_string());
815 assert_eq!(parsed["valid_for_sec"].as_u64(), Some(300));
816 assert!(parsed["steps"].is_array());
817 assert!(parsed["diagnostics"].is_array());
818 }
819
820 #[test]
821 fn golden_full_guidance_json() {
822 let mut g = ProtocolGuidance::new("review");
823 g.bone = Some(BoneRef {
824 id: "bd-abc".to_string(),
825 title: "Feature X".to_string(),
826 });
827 g.workspace = Some("worker-1".to_string());
828 g.review = Some(ReviewRef {
829 review_id: "cr-123".to_string(),
830 status: "open".to_string(),
831 });
832 g.set_freshness(600, Some("edict protocol review".to_string()));
833 g.step("maw exec worker-1 -- crit reviews request cr-123 --reviewers edict-security --agent crimson-storm".to_string());
834 g.diagnostic("awaiting review approval".to_string());
835 g.advise("Review is pending.".to_string());
836
837 let json = render_json(&g).unwrap();
838 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
839
840 assert!(parsed["bone"]["id"].as_str().is_some());
841 assert!(parsed["workspace"].as_str().is_some());
842 assert!(parsed["review"]["review_id"].as_str().is_some());
843 assert_eq!(parsed["valid_for_sec"].as_u64(), Some(600));
844 assert!(parsed["revalidate_cmd"].as_str().is_some());
845 assert!(!parsed["steps"].as_array().unwrap().is_empty());
846 assert!(!parsed["diagnostics"].as_array().unwrap().is_empty());
847 assert!(parsed["advice"].is_string());
848 }
849
850 #[test]
851 fn golden_text_render_includes_all_fields() {
852 let mut g = ProtocolGuidance::new("start");
853 g.bone = Some(BoneRef {
854 id: "bd-3t1d".to_string(),
855 title: "protocol: renderer".to_string(),
856 });
857 g.workspace = Some("work-1".to_string());
858 g.set_freshness(300, Some("edict protocol start".to_string()));
859 g.step("maw ws create work-1".to_string());
860 g.advise("Start implementation".to_string());
861
862 let text = render_text(&g);
863
864 assert!(text.contains("Command:"), "Command field missing");
865 assert!(text.contains("Status:"), "Status field missing");
866 assert!(text.contains("Snapshot:"), "Snapshot field missing");
867 assert!(text.contains("Bone:"), "Bone field missing");
868 assert!(text.contains("Workspace:"), "Workspace field missing");
869 assert!(text.contains("Revalidate:"), "Revalidate field missing");
870 assert!(text.contains("Steps:"), "Steps field missing");
871 assert!(text.contains("Advice:"), "Advice field missing");
872 }
873
874 #[test]
875 fn golden_compatibility_additive_only() {
876 let g = ProtocolGuidance::new("start");
877
878 let _schema = g.schema;
879 let _command = g.command;
880 let _status = g.status;
881 let _snapshot_at = g.snapshot_at;
882 let _valid_for_sec = g.valid_for_sec;
883 let _steps = g.steps;
884 let _diagnostics = g.diagnostics;
885
886 assert!(g.bone.is_none());
887 assert!(g.workspace.is_none());
888 assert!(g.review.is_none());
889 assert!(g.revalidate_cmd.is_none());
890 assert!(g.advice.is_none());
891 }
892
893 #[test]
896 fn render_text_status_resumable() {
897 let mut g = ProtocolGuidance::new("resume");
898 g.status = ProtocolStatus::Resumable;
899 g.bone = Some(BoneRef {
900 id: "bd-abc".to_string(),
901 title: "In progress task".to_string(),
902 });
903 g.advise("Resume from previous work state.".to_string());
904
905 let text = render_text(&g);
906 assert!(text.contains("Status: Resumable"));
907 assert!(text.contains("bd-abc"));
908 assert!(text.contains("resume"));
909 }
910
911 #[test]
912 fn render_json_status_resumable() {
913 let mut g = ProtocolGuidance::new("resume");
914 g.status = ProtocolStatus::Resumable;
915 g.bone = Some(BoneRef {
916 id: "bd-abc".to_string(),
917 title: "In progress".to_string(),
918 });
919
920 let json = render_json(&g).unwrap();
921 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
922 assert_eq!(parsed["status"].as_str(), Some("Resumable"));
923 assert_eq!(parsed["command"].as_str(), Some("resume"));
924 }
925
926 #[test]
927 fn render_text_status_has_resources() {
928 let mut g = ProtocolGuidance::new("cleanup");
929 g.status = ProtocolStatus::HasResources;
930 g.steps(vec!["bus claims list --agent $AGENT --mine".to_string()]);
931
932 let text = render_text(&g);
933 assert!(text.contains("Status: Has Resources"));
934 assert!(text.contains("bus claims list"));
935 }
936
937 #[test]
938 fn render_json_status_has_resources() {
939 let mut g = ProtocolGuidance::new("cleanup");
940 g.status = ProtocolStatus::HasResources;
941
942 let json = render_json(&g).unwrap();
943 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
944 assert_eq!(parsed["status"].as_str(), Some("HasResources"));
945 }
946
947 #[test]
948 fn render_text_status_has_work() {
949 let mut g = ProtocolGuidance::new("start");
950 g.status = ProtocolStatus::HasWork;
951 g.steps(vec!["maw exec default -- bn next".to_string()]);
952
953 let text = render_text(&g);
954 assert!(text.contains("Status: Has Work"));
955 }
956
957 #[test]
958 fn render_json_status_has_work() {
959 let mut g = ProtocolGuidance::new("start");
960 g.status = ProtocolStatus::HasWork;
961
962 let json = render_json(&g).unwrap();
963 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
964 assert_eq!(parsed["status"].as_str(), Some("HasWork"));
965 }
966
967 #[test]
968 fn render_text_status_fresh() {
969 let mut g = ProtocolGuidance::new("start");
970 g.status = ProtocolStatus::Fresh;
971 g.advise("Starting fresh with no prior state.".to_string());
972
973 let text = render_text(&g);
974 assert!(text.contains("Status: Fresh"));
975 assert!(text.contains("Fresh"));
976 }
977
978 #[test]
979 fn render_json_status_fresh() {
980 let mut g = ProtocolGuidance::new("start");
981 g.status = ProtocolStatus::Fresh;
982
983 let json = render_json(&g).unwrap();
984 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
985 assert_eq!(parsed["status"].as_str(), Some("Fresh"));
986 }
987
988 #[test]
991 fn render_text_with_execution_report_success() {
992 let mut g = ProtocolGuidance::new("start");
993 g.executed = true;
994 g.execution_report = Some(ExecutionReport {
995 results: vec![StepResult {
996 command: "echo test".to_string(),
997 success: true,
998 stdout: "test".to_string(),
999 stderr: String::new(),
1000 }],
1001 remaining: vec![],
1002 });
1003
1004 let text = render_text(&g);
1005 assert!(text.contains("Execution:"));
1006 assert!(text.contains("ok"));
1007 }
1008
1009 #[test]
1010 fn render_json_with_execution_report() {
1011 let mut g = ProtocolGuidance::new("start");
1012 g.executed = true;
1013 g.execution_report = Some(ExecutionReport {
1014 results: vec![StepResult {
1015 command: "echo hello".to_string(),
1016 success: true,
1017 stdout: "hello".to_string(),
1018 stderr: String::new(),
1019 }],
1020 remaining: vec![],
1021 });
1022
1023 let json = render_json(&g).unwrap();
1024 assert!(json.contains("executed"));
1025 assert!(json.contains("true"));
1026 assert!(json.contains("execution_report"));
1027 assert!(json.contains("hello"));
1028 }
1029
1030 #[test]
1031 fn render_pretty_with_execution_report() {
1032 let mut g = ProtocolGuidance::new("start");
1033 g.executed = true;
1034 g.execution_report = Some(ExecutionReport {
1035 results: vec![StepResult {
1036 command: "echo test".to_string(),
1037 success: true,
1038 stdout: "test".to_string(),
1039 stderr: String::new(),
1040 }],
1041 remaining: vec![],
1042 });
1043
1044 let pretty = render_pretty(&g);
1045 assert!(pretty.contains("Execution:"));
1046 }
1047
1048 #[test]
1049 fn render_text_execution_report_with_failure() {
1050 let mut g = ProtocolGuidance::new("finish");
1051 g.executed = true;
1052 g.execution_report = Some(ExecutionReport {
1053 results: vec![StepResult {
1054 command: "maw ws merge --destroy".to_string(),
1055 success: false,
1056 stdout: String::new(),
1057 stderr: "error: workspace not found".to_string(),
1058 }],
1059 remaining: vec!["next step".to_string()],
1060 });
1061
1062 let text = render_text(&g);
1063 assert!(text.contains("Execution:"));
1064 assert!(text.contains("FAILED"));
1065 }
1066
1067 #[test]
1068 fn render_json_execution_report_with_remaining_steps() {
1069 let mut g = ProtocolGuidance::new("finish");
1070 g.executed = true;
1071 g.execution_report = Some(ExecutionReport {
1072 results: vec![StepResult {
1073 command: "step1".to_string(),
1074 success: true,
1075 stdout: String::new(),
1076 stderr: String::new(),
1077 }],
1078 remaining: vec!["step2".to_string(), "step3".to_string()],
1079 });
1080
1081 let json = render_json(&g).unwrap();
1082 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1083 assert!(parsed["execution_report"].is_object());
1084 let report = &parsed["execution_report"];
1085 assert!(report["results"].is_array());
1086 assert!(report["remaining"].is_array());
1087 assert_eq!(report["remaining"].as_array().unwrap().len(), 2);
1088 }
1089
1090 #[test]
1091 fn render_text_without_execution_report_shows_steps() {
1092 let mut g = ProtocolGuidance::new("start");
1093 g.executed = false;
1094 g.step("echo hello".to_string());
1095 g.step("echo world".to_string());
1096
1097 let text = render_text(&g);
1098 assert!(text.contains("Steps:"));
1100 assert!(text.contains("echo hello"));
1101 assert!(!text.contains("Execution:"));
1102 }
1103
1104 #[test]
1105 fn render_pretty_executed_true_skips_steps_section() {
1106 let mut g = ProtocolGuidance::new("start");
1107 g.executed = true;
1108 g.step("echo hello".to_string());
1109 g.execution_report = Some(ExecutionReport {
1110 results: vec![StepResult {
1111 command: "echo hello".to_string(),
1112 success: true,
1113 stdout: "hello".to_string(),
1114 stderr: String::new(),
1115 }],
1116 remaining: vec![],
1117 });
1118
1119 let pretty = render_pretty(&g);
1120 assert!(pretty.contains("Execution:"));
1122 }
1123}