Skip to main content

driven/strategy/
evidence.rs

1use 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}