Skip to main content

edict/commands/protocol/
render.rs

1//! Shell-safe command renderer and protocol guidance types.
2//!
3//! Renders protocol guidance with shell-safe commands, validation, and format support.
4
5use crate::commands::doctor::OutputFormat;
6use crate::commands::protocol::executor::ExecutionReport;
7use serde::{Deserialize, Serialize};
8use std::fmt::Write;
9
10// --- Core Types ---
11
12/// A rendered protocol guidance output.
13///
14/// Provides a snapshot of agent state (bones, workspaces, reviews) with
15/// next steps as shell commands agents can execute.
16///
17/// Freshness Semantics:
18/// - `snapshot_at`: UTC timestamp when this guidance was generated
19/// - `valid_for_sec`: How long this guidance remains fresh (in seconds)
20/// - `revalidate_cmd`: If present, run this command to refresh guidance
21///
22/// Agents receiving stale guidance (snapshot_at + valid_for_sec < now) should
23/// re-run the revalidate_cmd to get fresh state before executing steps.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ProtocolGuidance {
26    /// Schema version for machine parsing
27    pub schema: &'static str,
28    /// Command type: "start", "finish", "review", "cleanup", "resume"
29    pub command: &'static str,
30    /// Status indicating readiness or blocker
31    pub status: ProtocolStatus,
32    /// UTC ISO 8601 snapshot timestamp
33    pub snapshot_at: String,
34    /// Validity duration in seconds (how long this guidance is fresh)
35    pub valid_for_sec: u32,
36    /// Command to re-fetch fresh guidance if stale (e.g., "edict protocol start")
37    pub revalidate_cmd: Option<String>,
38    /// Bone context (if applicable)
39    pub bone: Option<BoneRef>,
40    /// Workspace name (if applicable)
41    pub workspace: Option<String>,
42    /// Review context (if applicable)
43    pub review: Option<ReviewRef>,
44    /// Rendered shell commands (ready to copy-paste)
45    pub steps: Vec<String>,
46    /// Diagnostic messages if blocked or errored
47    pub diagnostics: Vec<String>,
48    /// Human-readable summary
49    pub advice: Option<String>,
50    /// Whether commands were executed (--execute mode)
51    #[serde(default)]
52    pub executed: bool,
53    /// Execution report (if --execute was used)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub execution_report: Option<ExecutionReport>,
56}
57
58impl ProtocolGuidance {
59    /// Create a new guidance with ready status.
60    /// Default freshness: 300 seconds (5 minutes)
61    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, // 5 minutes default
68            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    /// Set the validity duration and optional revalidate command.
81    /// Use this to control how long guidance remains fresh.
82    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    /// Add a step command.
88    pub fn step(&mut self, cmd: String) {
89        self.steps.push(cmd);
90    }
91
92    /// Add multiple steps.
93    pub fn steps(&mut self, cmds: Vec<String>) {
94        self.steps.extend(cmds);
95    }
96
97    /// Add a diagnostic message (e.g., reason for blocked status).
98    pub fn diagnostic(&mut self, msg: String) {
99        self.diagnostics.push(msg);
100    }
101
102    /// Set status and add corresponding diagnostics.
103    pub fn blocked(&mut self, reason: String) {
104        self.status = ProtocolStatus::Blocked;
105        self.diagnostic(reason);
106    }
107
108    /// Set advice message (human-readable summary).
109    pub fn advise(&mut self, msg: String) {
110        self.advice = Some(msg);
111    }
112}
113
114/// Protocol status indicating readiness, blockers, or next action.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "PascalCase")]
117pub enum ProtocolStatus {
118    Ready,        // Commands are ready to run
119    Blocked,      // Cannot proceed; diagnostics explain why
120    Resumable,    // Work in progress; resume from previous state
121    NeedsReview,  // Awaiting review approval
122    HasResources, // Workspace/claims held
123    Clean,        // No held resources
124    HasWork,      // Ready bones available
125    Fresh,        // Starting fresh (no prior state)
126}
127
128/// Bone reference in protocol output.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct BoneRef {
131    pub id: String,
132    pub title: String,
133}
134
135/// Review reference in protocol output.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ReviewRef {
138    pub review_id: String,
139    pub status: String,
140}
141
142// --- Validation (from shell module) ---
143
144use super::shell::{
145    ValidationError, validate_bone_id, validate_review_id, validate_workspace_name,
146};
147
148/// Validate all dynamic values in a guidance before rendering.
149pub 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
162// --- Rendering ---
163
164/// Render guidance as human/agent-readable text.
165///
166/// Format:
167/// ```text
168/// Command: start
169/// Status: Ready
170/// Bone: bd-3t1d (protocol: shell-safe command renderer)
171/// Workspace: brave-tiger
172///
173/// Steps:
174/// 1. bus send --agent $AGENT edict 'Working...' -L task-claim
175/// 2. maw ws create --random
176///
177/// Advice: Create workspace and stake claims before starting implementation.
178/// ```
179pub fn render_text(guidance: &ProtocolGuidance) -> String {
180    let mut out = String::new();
181
182    // Header
183    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    // Show execution results if --execute was used
215    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        // Show steps only if not executed
226        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
241/// Render guidance as JSON with schema version and structured data.
242pub fn render_json(guidance: &ProtocolGuidance) -> Result<String, serde_json::Error> {
243    let json = serde_json::to_string_pretty(guidance)?;
244    Ok(json)
245}
246
247/// Render guidance as colored TTY output (for humans).
248///
249/// Uses ANSI color codes for status, headers, and command highlighting.
250pub fn render_pretty(guidance: &ProtocolGuidance) -> String {
251    let mut out = String::new();
252
253    // Color codes
254    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    // Status color
261    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    // Header
268    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    // Show execution results if --execute was used
319    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        // Show steps only if not executed
329        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
342/// Format status as human-readable string.
343fn 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
356/// Render guidance using the specified format.
357pub fn render(guidance: &ProtocolGuidance, format: OutputFormat) -> Result<String, String> {
358    // Validate before rendering
359    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    // --- ProtocolGuidance builder tests ---
374
375    #[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    // --- Validation tests ---
442
443    #[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    // --- Rendering tests ---
472
473    #[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        // Should have ANSI color codes
544        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        // Green code appears before Ready
552        assert!(pretty.contains("\x1b[32m")); // green
553    }
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        // Red code appears in output
561        assert!(pretty.contains("\x1b[31m")); // red
562    }
563
564    // --- Integration tests (golden-style) ---
565
566    #[test]
567    fn golden_start_workflow() {
568        // Typical start workflow
569        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        // Blocked due to claim conflict
595        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        // Review requested and pending approval
612        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        // Release all held claims
638        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        // Statuses should serialize to PascalCase
655        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        // Serialize and verify JSON contains expected fields
675        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        // Verify it's valid JSON that can be parsed
682        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        // Should be ISO 8601 / RFC 3339 format
691        assert!(g.snapshot_at.contains("T"));
692        assert!(g.snapshot_at.contains("Z") || g.snapshot_at.contains("+"));
693    }
694
695    // --- Freshness Semantics Tests ---
696
697    #[test]
698    fn guidance_default_freshness() {
699        let g = ProtocolGuidance::new("start");
700        assert_eq!(g.valid_for_sec, 300); // 5 minutes default
701        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        // This test demonstrates how clients should detect stale guidance.
735        let mut g = ProtocolGuidance::new("start");
736        g.set_freshness(1, Some("edict protocol start".to_string())); // 1 second fresh
737
738        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    // --- Golden Schema Tests: Contract Stability ---
751
752    #[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    // --- Status Rendering Tests: All Variants ---
894
895    #[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    // --- Execution Report Rendering Tests ---
989
990    #[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        // When not executed, should show steps, not execution report
1099        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        // When executed, should show execution report instead of steps section
1121        assert!(pretty.contains("Execution:"));
1122    }
1123}