driven/strategy/
evidence.rs1use crate::{DrivenError, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5pub const COMMAND_EVIDENCE_CAPTURED_BY: &str = "driven.strategy.command_evidence.v1";
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct CommandEvidence {
9 pub cwd: String,
10 pub stdout_digest: String,
11 pub stderr_digest: String,
12 pub stdout_bytes: u64,
13 pub stderr_bytes: u64,
14 #[serde(default)]
15 pub stdout_truncated: bool,
16 #[serde(default)]
17 pub stderr_truncated: bool,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub output_limit_bytes: Option<u64>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub duration_ms: Option<u64>,
22 pub started_unix_seconds: u64,
23 pub finished_unix_seconds: u64,
24 pub captured_by: String,
25}
26
27impl CommandEvidence {
28 pub fn from_streams(
29 cwd: &Path,
30 stdout: &[u8],
31 stderr: &[u8],
32 started_unix_seconds: u64,
33 finished_unix_seconds: u64,
34 ) -> Result<Self> {
35 let evidence = Self {
36 cwd: normalize_path(cwd.display().to_string()),
37 stdout_digest: digest_bytes(stdout),
38 stderr_digest: digest_bytes(stderr),
39 stdout_bytes: stdout.len() as u64,
40 stderr_bytes: stderr.len() as u64,
41 stdout_truncated: false,
42 stderr_truncated: false,
43 output_limit_bytes: None,
44 duration_ms: None,
45 started_unix_seconds,
46 finished_unix_seconds,
47 captured_by: COMMAND_EVIDENCE_CAPTURED_BY.to_string(),
48 };
49 evidence.validate()?;
50 Ok(evidence)
51 }
52
53 #[allow(clippy::too_many_arguments)]
54 pub fn from_captured_streams(
55 cwd: &Path,
56 stdout_digest: impl Into<String>,
57 stderr_digest: impl Into<String>,
58 stdout_bytes: u64,
59 stderr_bytes: u64,
60 stdout_truncated: bool,
61 stderr_truncated: bool,
62 output_limit_bytes: u64,
63 duration_ms: u64,
64 started_unix_seconds: u64,
65 finished_unix_seconds: u64,
66 ) -> Result<Self> {
67 let evidence = Self {
68 cwd: normalize_path(cwd.display().to_string()),
69 stdout_digest: stdout_digest.into(),
70 stderr_digest: stderr_digest.into(),
71 stdout_bytes,
72 stderr_bytes,
73 stdout_truncated,
74 stderr_truncated,
75 output_limit_bytes: Some(output_limit_bytes),
76 duration_ms: Some(duration_ms),
77 started_unix_seconds,
78 finished_unix_seconds,
79 captured_by: COMMAND_EVIDENCE_CAPTURED_BY.to_string(),
80 };
81 evidence.validate()?;
82 Ok(evidence)
83 }
84
85 pub fn validate(&self) -> Result<()> {
86 if self.cwd.trim().is_empty() {
87 return Err(DrivenError::Validation(
88 "command evidence cwd cannot be empty".to_string(),
89 ));
90 }
91 if self.finished_unix_seconds < self.started_unix_seconds {
92 return Err(DrivenError::Validation(
93 "command evidence finish time cannot precede start time".to_string(),
94 ));
95 }
96 if self.captured_by != COMMAND_EVIDENCE_CAPTURED_BY {
97 return Err(DrivenError::Validation(format!(
98 "command evidence captured_by must be {}",
99 COMMAND_EVIDENCE_CAPTURED_BY
100 )));
101 }
102 if (self.stdout_truncated || self.stderr_truncated) && self.output_limit_bytes.is_none() {
103 return Err(DrivenError::Validation(
104 "truncated command evidence requires an output limit".to_string(),
105 ));
106 }
107 Ok(())
108 }
109}
110
111fn digest_bytes(bytes: &[u8]) -> String {
112 blake3::hash(bytes).to_hex().to_string()
113}
114
115fn normalize_path(path: String) -> String {
116 path.replace('\\', "/")
117}