use std::fmt::Display;
use serde::Serialize;
use crate::events::{NodeInfo, PipelineEvent};
use crate::pipeline::{NodeMetadataById, PipelineResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RunStatus {
Pass,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NodeSummary {
pub id: String,
pub node_type: String,
pub files_processed: usize,
pub metadata: serde_json::Map<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skip_reason: Option<String>,
}
impl NodeSummary {
fn not_yet_run(info: &NodeInfo) -> Self {
Self {
id: info.id.clone(),
node_type: info.node_type.clone(),
files_processed: 0,
metadata: serde_json::Map::new(),
skip_reason: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RunSummary {
pub status: RunStatus,
pub files_written: usize,
pub duration_ms: u64,
pub nodes: Vec<NodeSummary>,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl RunSummary {
pub fn from_result(result: &PipelineResult, duration_ms: u64) -> Self {
Self {
status: RunStatus::Pass,
files_written: result.files.len(),
duration_ms,
nodes: Vec::new(),
error: None,
message: None,
}
}
pub fn from_error(error: impl Display, duration_ms: u64) -> Self {
Self {
status: RunStatus::Fail,
files_written: 0,
duration_ms,
nodes: Vec::new(),
error: Some(error.to_string()),
message: None,
}
}
pub fn with_nodes(self, nodes: Vec<NodeSummary>) -> Self {
Self { nodes, ..self }
}
pub fn with_message(self, message: Option<String>) -> Self {
Self { message, ..self }
}
pub fn with_node_metadata(mut self, metadata: &NodeMetadataById) -> Self {
for node in &mut self.nodes {
if let Some(recorded) = metadata.get(&node.id) {
node.metadata = recorded.clone();
}
}
self
}
}
#[derive(Debug, Default)]
pub struct NodeTally {
nodes: Vec<NodeSummary>,
}
impl NodeTally {
pub fn record(&mut self, event: &PipelineEvent) {
match event {
PipelineEvent::PipelineStarted { nodes, .. } => {
self.nodes = nodes.iter().map(NodeSummary::not_yet_run).collect();
}
PipelineEvent::NodeCompleted {
node_id,
files_processed,
parent_node_id: None,
..
} => self.add_files(node_id, *files_processed),
PipelineEvent::NodeSkipped {
node_id,
reason,
parent_node_id: None,
..
} => self.mark_skipped(node_id, reason),
_ => {}
}
}
fn add_files(&mut self, node_id: &str, files: usize) {
if let Some(node) = self.nodes.iter_mut().find(|n| n.id == node_id) {
node.files_processed += files;
}
}
fn mark_skipped(&mut self, node_id: &str, reason: &str) {
if let Some(node) = self.nodes.iter_mut().find(|n| n.id == node_id) {
node.skip_reason = Some(reason.to_string());
}
}
pub fn nodes(&self) -> Vec<NodeSummary> {
self.nodes.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::BntoError;
use crate::pipeline::PipelineFileResult;
use crate::processor::FileData;
use serde_json::Map;
fn result_with(names: &[&str], duration_ms: u64) -> PipelineResult {
let files = names
.iter()
.map(|n| PipelineFileResult {
name: n.to_string(),
data: FileData::Bytes(vec![]),
mime_type: "text/plain".into(),
metadata: serde_json::Map::new(),
})
.collect();
PipelineResult {
files,
duration_ms,
warnings: vec![],
node_metadata: Default::default(),
}
}
fn started(nodes: &[(&str, &str)]) -> PipelineEvent {
PipelineEvent::PipelineStarted {
total_nodes: nodes.len(),
total_files: 1,
nodes: nodes
.iter()
.map(|(id, t)| NodeInfo {
id: id.to_string(),
node_type: t.to_string(),
})
.collect(),
}
}
fn completed(id: &str, files: usize, parent: Option<&str>) -> PipelineEvent {
PipelineEvent::NodeCompleted {
node_id: id.into(),
duration_ms: 1,
files_processed: files,
parent_node_id: parent.map(String::from),
}
}
fn node(id: &str, node_type: &str, files_processed: usize) -> NodeSummary {
NodeSummary {
id: id.into(),
node_type: node_type.into(),
files_processed,
metadata: Map::new(),
skip_reason: None,
}
}
fn skipped(id: &str, parent: Option<&str>) -> PipelineEvent {
PipelineEvent::NodeSkipped {
node_id: id.into(),
node_index: 0,
total_nodes: 1,
node_type: "shell-run".into(),
parent_node_id: parent.map(String::from),
reason: "not on this OS".into(),
}
}
fn object(value: serde_json::Value) -> Map<String, serde_json::Value> {
value.as_object().unwrap().clone()
}
#[test]
fn test_from_result_passes_with_the_result_files_and_duration() {
let summary = RunSummary::from_result(&result_with(&["a.txt", "b.txt"], 9), 42);
assert_eq!(summary.status, RunStatus::Pass);
assert_eq!(summary.files_written, 2);
assert_eq!(summary.duration_ms, 42);
assert_eq!(summary.error, None);
}
#[test]
fn test_from_result_with_two_tallied_nodes_carries_each_node() {
let mut tally = NodeTally::default();
tally.record(&started(&[
("collect", "file-collect"),
("copy", "file-copy"),
]));
tally.record(&completed("collect", 1, None));
tally.record(&completed("copy", 3, None));
let summary =
RunSummary::from_result(&result_with(&["a", "b", "c"], 5), 5).with_nodes(tally.nodes());
assert_eq!(
summary.nodes,
vec![
node("collect", "file-collect", 1),
node("copy", "file-copy", 3)
]
);
}
#[test]
fn test_from_error_fails_with_the_message() {
let err = BntoError::ProcessingFailed("disk full".into());
let summary = RunSummary::from_error(&err, 7);
assert_eq!(summary.status, RunStatus::Fail);
assert_eq!(summary.error.as_deref(), Some(err.to_string().as_str()));
assert_eq!(summary.files_written, 0);
assert_eq!(summary.duration_ms, 7);
assert!(summary.nodes.is_empty());
}
#[test]
fn test_from_error_takes_a_plain_message() {
let summary = RunSummary::from_error("unresolved {{vars.Out}}", 0);
assert_eq!(summary.error.as_deref(), Some("unresolved {{vars.Out}}"));
}
#[test]
fn test_summary_serializes_to_the_documented_shape() {
let summary = RunSummary::from_result(&result_with(&["a"], 3), 3).with_nodes(vec![node(
"copy",
"file-copy",
1,
)]);
let json = serde_json::to_value(&summary).unwrap();
assert_eq!(
json,
serde_json::json!({
"status": "pass",
"files_written": 1,
"duration_ms": 3,
"nodes": [{
"id": "copy",
"node_type": "file-copy",
"files_processed": 1,
"metadata": {}
}],
"error": null
})
);
}
#[test]
fn test_with_node_metadata_fills_each_node_by_id() {
let mut result = result_with(&["a"], 1);
let copied = object(serde_json::json!({ "copied": 3, "up_to_date": 1 }));
result.node_metadata.insert("copy".into(), copied.clone());
result.node_metadata.insert("inner".into(), Map::new());
let nodes = vec![
node("collect", "file-collect", 1),
node("copy", "file-copy", 4),
];
let summary = RunSummary::from_result(&result, 1)
.with_nodes(nodes)
.with_node_metadata(&result.node_metadata);
assert_eq!(summary.nodes[0].metadata, Map::new());
assert_eq!(summary.nodes[1].metadata, copied);
}
#[test]
fn test_node_metadata_serializes_as_an_object() {
let mut copy = node("copy", "file-copy", 1);
copy.metadata = object(serde_json::json!({ "copied": 1 }));
let json = serde_json::to_value(©).unwrap();
assert_eq!(json["metadata"], serde_json::json!({ "copied": 1 }));
}
#[test]
fn test_summary_with_a_message_serializes_it() {
let summary = RunSummary::from_result(&result_with(&["sh-output"], 1), 1)
.with_message(Some("No active session.".into()));
let json = serde_json::to_value(summary).unwrap();
assert_eq!(json["message"], "No active session.");
}
#[test]
fn test_summary_without_a_message_leaves_the_field_out() {
let json = serde_json::to_value(RunSummary::from_result(&result_with(&["a"], 1), 1));
assert!(json.unwrap().get("message").is_none());
let json = serde_json::to_value(RunSummary::from_error("boom", 1)).unwrap();
assert!(json.get("message").is_none());
}
#[test]
fn test_failed_summary_serializes_status_fail() {
let json = serde_json::to_value(RunSummary::from_error("boom", 1)).unwrap();
assert_eq!(json["status"], "fail");
assert_eq!(json["error"], "boom");
}
#[test]
fn test_tally_sums_a_node_that_runs_once_per_file() {
let mut tally = NodeTally::default();
tally.record(&started(&[("rename", "file-rename")]));
tally.record(&completed("rename", 1, None));
tally.record(&completed("rename", 1, None));
assert_eq!(tally.nodes(), vec![node("rename", "file-rename", 2)]);
}
#[test]
fn test_tally_ignores_nodes_inside_a_container() {
let mut tally = NodeTally::default();
tally.record(&started(&[("loop", "loop")]));
tally.record(&completed("inner", 4, Some("loop")));
tally.record(&completed("loop", 2, None));
assert_eq!(tally.nodes(), vec![node("loop", "loop", 2)]);
}
#[test]
fn test_tally_marks_a_skipped_node_with_its_reason() {
let mut tally = NodeTally::default();
tally.record(&started(&[("mac", "group"), ("linux", "group")]));
tally.record(&completed("mac", 1, None));
tally.record(&skipped("linux", None));
let nodes = tally.nodes();
assert_eq!(nodes[0].skip_reason, None);
assert_eq!(nodes[1].skip_reason.as_deref(), Some("not on this OS"));
}
#[test]
fn test_tally_ignores_a_skip_inside_a_container() {
let mut tally = NodeTally::default();
tally.record(&started(&[("g", "group")]));
tally.record(&skipped("inner", Some("g")));
tally.record(&completed("g", 1, None));
assert_eq!(tally.nodes(), vec![node("g", "group", 1)]);
}
#[test]
fn test_skip_reason_is_in_the_json_only_for_a_skipped_node() {
let mut ran = node("a", "file-copy", 1);
let json = serde_json::to_value(&ran).unwrap();
assert!(json.get("skip_reason").is_none(), "{json}");
ran.skip_reason = Some("when a equals b".into());
let json = serde_json::to_value(&ran).unwrap();
assert_eq!(json["skip_reason"], "when a equals b");
}
#[test]
fn test_tally_keeps_a_node_that_never_completed_at_zero() {
let mut tally = NodeTally::default();
tally.record(&started(&[("a", "file-collect"), ("b", "file-copy")]));
tally.record(&completed("a", 1, None));
assert_eq!(
tally.nodes(),
vec![node("a", "file-collect", 1), node("b", "file-copy", 0)]
);
}
}