Skip to main content

imp_core/
evidence.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(default)]
9pub struct EvidencePacket {
10    pub run_id: String,
11    pub workflow_id: Option<String>,
12    pub session_id: Option<String>,
13    pub objective: String,
14    pub workflow_type: Option<String>,
15    pub risk_level: Option<String>,
16    pub autonomy_mode: Option<String>,
17    pub final_status: Option<String>,
18    pub summary: Vec<String>,
19    pub plan: Vec<String>,
20    pub actions: EvidenceActions,
21    pub policy: EvidencePolicy,
22    pub trust: EvidenceTrustSummary,
23    pub verification: Vec<EvidenceVerificationGate>,
24    pub artifacts: Vec<EvidenceArtifact>,
25    pub concerns: Vec<String>,
26    pub next_steps: Vec<String>,
27}
28
29impl EvidencePacket {
30    pub fn new(run_id: impl Into<String>, objective: impl Into<String>) -> Self {
31        Self {
32            run_id: run_id.into(),
33            objective: objective.into(),
34            ..Self::default()
35        }
36    }
37
38    pub fn render_markdown(&self) -> String {
39        let mut out = String::new();
40        out.push_str("# Evidence Packet\n\n");
41        self.render_workflow(&mut out);
42        self.render_summary(&mut out);
43        self.render_plan(&mut out);
44        self.render_actions(&mut out);
45        self.render_policy(&mut out);
46        self.render_trust(&mut out);
47        self.render_verification(&mut out);
48        self.render_artifacts(&mut out);
49        self.render_closeout(&mut out);
50        out
51    }
52
53    pub fn write_markdown(&self, path: impl AsRef<Path>) -> io::Result<()> {
54        if let Some(parent) = path.as_ref().parent() {
55            fs::create_dir_all(parent)?;
56        }
57        fs::write(path, self.render_markdown())
58    }
59
60    fn render_workflow(&self, out: &mut String) {
61        out.push_str("## Workflow\n\n");
62        bullet(out, "Run", &self.run_id);
63        optional_bullet(out, "Workflow", self.workflow_id.as_deref());
64        optional_bullet(out, "Session", self.session_id.as_deref());
65        bullet(out, "Objective", &safe_inline(&self.objective));
66        optional_bullet(out, "Type", self.workflow_type.as_deref());
67        optional_bullet(out, "Risk", self.risk_level.as_deref());
68        optional_bullet(out, "Autonomy", self.autonomy_mode.as_deref());
69        out.push('\n');
70    }
71
72    fn render_summary(&self, out: &mut String) {
73        out.push_str("## Summary\n\n");
74        optional_bullet(out, "Final status", self.final_status.as_deref());
75        render_list_or_none(out, &self.summary);
76        out.push('\n');
77    }
78
79    fn render_plan(&self, out: &mut String) {
80        out.push_str("## Plan\n\n");
81        render_list_or_none(out, &self.plan);
82        out.push('\n');
83    }
84
85    fn render_actions(&self, out: &mut String) {
86        out.push_str("## Actions\n\n");
87        render_named_list(out, "Files inspected", &self.actions.files_inspected);
88        render_named_list(out, "Files changed", &self.actions.files_changed);
89        render_named_list(out, "Commands run", &self.actions.commands_run);
90        render_named_list(out, "Searches", &self.actions.searches);
91        render_named_list(out, "Tools", &self.actions.tools);
92        out.push('\n');
93    }
94
95    fn render_policy(&self, out: &mut String) {
96        out.push_str("## Policy\n\n");
97        render_named_list(out, "Decisions", &self.policy.decisions);
98        render_named_list(out, "Denials", &self.policy.denials);
99        render_named_list(out, "Approvals", &self.policy.approvals);
100        out.push('\n');
101    }
102
103    fn render_trust(&self, out: &mut String) {
104        out.push_str("## Trust & Provenance\n\n");
105        render_named_list(out, "Sources", &self.trust.sources);
106        render_named_list(
107            out,
108            "Low-trust influences",
109            &self.trust.low_trust_influences,
110        );
111        render_named_list(out, "Warnings", &self.trust.warnings);
112        out.push('\n');
113    }
114
115    fn render_verification(&self, out: &mut String) {
116        out.push_str("## Verification\n\n");
117        if self.verification.is_empty() {
118            out.push_str("No verification gates were declared.\n\n");
119            return;
120        }
121        for gate in &self.verification {
122            out.push_str(&format!(
123                "- **{}**: {}",
124                safe_inline(&gate.name),
125                safe_inline(&gate.status)
126            ));
127            if gate.required {
128                out.push_str(" (required)");
129            } else {
130                out.push_str(" (optional)");
131            }
132            if let Some(command) = &gate.command {
133                out.push_str(&format!(" — `{}`", safe_inline(command)));
134            }
135            if let Some(exit_code) = gate.exit_code {
136                out.push_str(&format!(" — exit {exit_code}"));
137            }
138            if let Some(artifact) = &gate.artifact_path {
139                out.push_str(&format!(" — `{}`", artifact.display()));
140            }
141            out.push('\n');
142        }
143        out.push('\n');
144    }
145
146    fn render_artifacts(&self, out: &mut String) {
147        out.push_str("## Artifacts\n\n");
148        if self.artifacts.is_empty() {
149            out.push_str("None recorded.\n\n");
150            return;
151        }
152        for artifact in &self.artifacts {
153            out.push_str(&format!(
154                "- **{}**: `{}`",
155                safe_inline(&artifact.kind),
156                artifact.path.display()
157            ));
158            if let Some(summary) = &artifact.summary {
159                out.push_str(&format!(" — {}", safe_inline(summary)));
160            }
161            out.push('\n');
162        }
163        out.push('\n');
164    }
165
166    fn render_closeout(&self, out: &mut String) {
167        out.push_str("## Closeout\n\n");
168        optional_bullet(out, "Final status", self.final_status.as_deref());
169        render_named_list(out, "Concerns", &self.concerns);
170        render_named_list(out, "Next steps", &self.next_steps);
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
175#[serde(default)]
176pub struct EvidenceActions {
177    pub files_inspected: Vec<String>,
178    pub files_changed: Vec<String>,
179    pub commands_run: Vec<String>,
180    pub searches: Vec<String>,
181    pub tools: Vec<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
185#[serde(default)]
186pub struct EvidencePolicy {
187    pub decisions: Vec<String>,
188    pub denials: Vec<String>,
189    pub approvals: Vec<String>,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
193#[serde(default)]
194pub struct EvidenceTrustSummary {
195    pub sources: Vec<String>,
196    pub low_trust_influences: Vec<String>,
197    pub warnings: Vec<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(default)]
202pub struct EvidenceVerificationGate {
203    pub name: String,
204    pub required: bool,
205    pub status: String,
206    pub command: Option<String>,
207    pub exit_code: Option<i32>,
208    pub artifact_path: Option<PathBuf>,
209}
210
211impl Default for EvidenceVerificationGate {
212    fn default() -> Self {
213        Self {
214            name: String::new(),
215            required: true,
216            status: "pending".into(),
217            command: None,
218            exit_code: None,
219            artifact_path: None,
220        }
221    }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(default)]
226pub struct EvidenceArtifact {
227    pub kind: String,
228    pub path: PathBuf,
229    pub summary: Option<String>,
230}
231
232impl Default for EvidenceArtifact {
233    fn default() -> Self {
234        Self {
235            kind: String::new(),
236            path: PathBuf::new(),
237            summary: None,
238        }
239    }
240}
241
242fn bullet(out: &mut String, label: &str, value: &str) {
243    out.push_str(&format!("- **{label}:** {value}\n"));
244}
245
246fn optional_bullet(out: &mut String, label: &str, value: Option<&str>) {
247    if let Some(value) = value.filter(|value| !value.is_empty()) {
248        bullet(out, label, &safe_inline(value));
249    }
250}
251
252fn render_named_list(out: &mut String, label: &str, values: &[String]) {
253    out.push_str(&format!("### {label}\n\n"));
254    render_list_or_none(out, values);
255    out.push('\n');
256}
257
258fn render_list_or_none(out: &mut String, values: &[String]) {
259    if values.is_empty() {
260        out.push_str("None recorded.\n");
261    } else {
262        for value in values {
263            out.push_str(&format!("- {}\n", safe_inline(value)));
264        }
265    }
266}
267
268fn safe_inline(value: &str) -> String {
269    const MAX: usize = 4 * 1024;
270    let single_line = value.replace('\n', " ");
271    if single_line.chars().count() > MAX {
272        format!(
273            "{}…[truncated]",
274            single_line.chars().take(MAX).collect::<String>()
275        )
276    } else {
277        single_line
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn evidence_packet_renders_expected_sections() {
287        let packet = EvidencePacket {
288            run_id: "run_1".into(),
289            workflow_id: Some("394.4".into()),
290            objective: "Emit evidence".into(),
291            autonomy_mode: Some("allow-all".into()),
292            final_status: Some("DONE".into()),
293            summary: vec!["Implemented renderer".into()],
294            plan: vec!["Create model".into()],
295            actions: EvidenceActions {
296                files_changed: vec!["crates/imp-core/src/evidence.rs".into()],
297                commands_run: vec!["cargo test -p imp-core evidence_packet".into()],
298                ..EvidenceActions::default()
299            },
300            policy: EvidencePolicy {
301                decisions: vec!["allow-all mode was active".into()],
302                ..EvidencePolicy::default()
303            },
304            verification: vec![EvidenceVerificationGate {
305                name: "unit tests".into(),
306                status: "passed".into(),
307                command: Some("cargo test".into()),
308                exit_code: Some(0),
309                artifact_path: Some(".imp/runs/run_1/verify.log".into()),
310                ..EvidenceVerificationGate::default()
311            }],
312            artifacts: vec![EvidenceArtifact {
313                kind: "trace".into(),
314                path: ".imp/runs/run_1/trace.jsonl".into(),
315                summary: None,
316            }],
317            concerns: vec![],
318            next_steps: vec!["Wire runtime collection".into()],
319            ..EvidencePacket::default()
320        };
321
322        let markdown = packet.render_markdown();
323        for heading in [
324            "# Evidence Packet",
325            "## Workflow",
326            "## Summary",
327            "## Plan",
328            "## Actions",
329            "## Policy",
330            "## Verification",
331            "## Artifacts",
332            "## Closeout",
333        ] {
334            assert!(markdown.contains(heading), "missing {heading}");
335        }
336        assert!(markdown.contains("allow-all"));
337        assert!(markdown.contains("unit tests"));
338    }
339
340    #[test]
341    fn evidence_packet_writes_markdown_file() {
342        let temp = tempfile::TempDir::new().unwrap();
343        let path = temp.path().join("run").join("evidence.md");
344        EvidencePacket::new("run_1", "Test write")
345            .write_markdown(&path)
346            .unwrap();
347        let markdown = std::fs::read_to_string(path).unwrap();
348        assert!(markdown.contains("# Evidence Packet"));
349        assert!(markdown.contains("Test write"));
350    }
351}