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}