use affidavit::chain::ChainAssembler;
use affidavit::ocel::{build_event, object_ref, SeqCounter};
use affidavit::types::{OperationEvent, Receipt};
use assert_cmd::Command;
use predicates::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use tempfile::TempDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub id: String,
pub label: String,
#[serde(rename = "type")]
pub node_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: String,
pub target: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiptGraph {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
impl ReceiptGraph {
pub fn from_receipt_dfg(receipt: &Receipt) -> Self {
let mut nodes_map = BTreeMap::new();
let mut edges_set = BTreeSet::new();
for event in &receipt.events {
nodes_map
.entry(event.event_type.clone())
.or_insert(GraphNode {
id: event.event_type.clone(),
label: event.event_type.clone(),
node_type: "activity".to_string(),
});
}
for i in 0..receipt.events.len().saturating_sub(1) {
let current = &receipt.events[i];
let next = &receipt.events[i + 1];
edges_set.insert((current.event_type.clone(), next.event_type.clone()));
}
let nodes = nodes_map.into_values().collect();
let edges = edges_set
.into_iter()
.map(|(source, target)| GraphEdge {
source,
target,
label: "→".to_string(),
})
.collect();
ReceiptGraph { nodes, edges }
}
pub fn to_dot(&self) -> String {
let mut dot = String::from("digraph Receipt {\n");
dot.push_str(" rankdir=LR;\n");
for node in &self.nodes {
dot.push_str(&format!(
" \"{}\" [label=\"{}\", shape=ellipse];\n",
node.id, node.label
));
}
for edge in &self.edges {
dot.push_str(&format!(
" \"{}\" -> \"{}\" [label=\"{}\"];\n",
edge.source, edge.target, edge.label
));
}
dot.push_str("}\n");
dot
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap()
}
}
fn create_receipt(events: &[(&str, &[(&str, &str)])]) -> Receipt {
let mut asm = ChainAssembler::new();
let mut counter = SeqCounter::new();
for (ty, objects) in events {
let obj_refs = objects
.iter()
.map(|(id, ot)| object_ref(*id, *ot))
.collect();
let ev = build_event(*ty, obj_refs, ty.as_bytes(), &mut counter).expect("build event");
asm.append(ev).expect("append");
}
asm.finalize()
}
fn affi() -> Command {
Command::cargo_bin("affi").expect("affi binary builds")
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn test_visualize_json_structure_ac1_ac2_ac3() {
let receipt = create_receipt(&[
("create", &[("f1", "file")]),
("transform", &[("f1", "file")]),
("release", &[("f1", "file")]),
]);
let graph = ReceiptGraph::from_receipt_dfg(&receipt);
let json_str = graph.to_json();
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(json.get("nodes").is_some());
assert!(json.get("edges").is_some());
let nodes = json["nodes"].as_array().unwrap();
let node_ids: Vec<_> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect();
assert!(node_ids.contains(&"create"));
assert!(node_ids.contains(&"transform"));
assert!(node_ids.contains(&"release"));
let edges = json["edges"].as_array().unwrap();
let edge_pairs: Vec<_> = edges
.iter()
.map(|e| (e["source"].as_str().unwrap(), e["target"].as_str().unwrap()))
.collect();
assert!(edge_pairs.contains(&("create", "transform")));
assert!(edge_pairs.contains(&("transform", "release")));
}
#[test]
fn test_visualize_dot_format_ac4_ac5() {
let receipt = create_receipt(&[
("create", &[("f1", "file")]),
("release", &[("f1", "file")]),
]);
let graph = ReceiptGraph::from_receipt_dfg(&receipt);
let dot = graph.to_dot();
assert!(dot.trim().starts_with("digraph"));
assert!(dot.contains("->"));
assert!(dot.contains("label=\"create\""));
assert!(dot.contains("label=\"release\""));
}
#[test]
fn test_visualize_repeated_event_types_ac8() {
let receipt = create_receipt(&[
("build", &[("f1", "file")]),
("build", &[("f1", "file")]),
("test", &[("f1", "file")]),
]);
let graph = ReceiptGraph::from_receipt_dfg(&receipt);
let build_nodes: Vec<_> = graph.nodes.iter().filter(|n| n.id == "build").collect();
assert_eq!(
build_nodes.len(),
1,
"build node should appear exactly once"
);
let build_loop = graph
.edges
.iter()
.find(|e| e.source == "build" && e.target == "build");
assert!(
build_loop.is_some(),
"should contain a build->build self-loop edge"
);
let build_test = graph
.edges
.iter()
.find(|e| e.source == "build" && e.target == "test");
assert!(build_test.is_some(), "should contain a build->test edge");
}
#[test]
fn test_visualize_single_event_ac9() {
let receipt = create_receipt(&[("init", &[("f1", "file")])]);
let graph = ReceiptGraph::from_receipt_dfg(&receipt);
assert_eq!(graph.nodes.len(), 1);
assert_eq!(graph.edges.len(), 0);
}
}
#[test]
fn e2e_visualize_rejects_missing_format_ac6() {
let dir = TempDir::new().unwrap();
let receipt_path = dir.path().join("r.json");
let receipt = create_receipt(&[("op", &[])]);
std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();
affi()
.current_dir(dir.path())
.args(["receipt", "visualize", "r.json"])
.assert()
.failure();
}
#[test]
fn e2e_visualize_rejects_invalid_format_ac7() {
let dir = TempDir::new().unwrap();
let receipt_path = dir.path().join("r.json");
let receipt = create_receipt(&[("op", &[])]);
std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();
affi()
.current_dir(dir.path())
.args(["receipt", "visualize", "--format=xml", "r.json"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid value"));
}
#[test]
fn e2e_visualize_rejects_tampered_receipt_ac10() {
let dir = TempDir::new().unwrap();
let receipt_path = dir.path().join("tampered.json");
let receipt = create_receipt(&[("create", &[])]);
let json = serde_json::to_string(&receipt).unwrap();
let tampered = json.replace("\"create\"", "\"forged\"");
std::fs::write(&receipt_path, tampered).unwrap();
affi()
.current_dir(dir.path())
.args(["receipt", "visualize", "--format=json", "tampered.json"])
.assert()
.failure()
.stderr(predicate::str::contains("chain hash mismatch"));
}
#[test]
#[ignore = "Skip until 'visualize' is implemented in the binary"]
fn e2e_visualize_success_json() {
let dir = TempDir::new().unwrap();
let receipt_path = dir.path().join("r.json");
let receipt = create_receipt(&[("create", &[]), ("transform", &[]), ("release", &[])]);
std::fs::write(&receipt_path, serde_json::to_string(&receipt).unwrap()).unwrap();
affi()
.current_dir(dir.path())
.args(["receipt", "visualize", "--format=json", "r.json"])
.assert()
.success()
.stdout(predicate::str::contains("\"nodes\""))
.stdout(predicate::str::contains("\"edges\""));
}