Skip to main content

metalcraft_flows/
log.rs

1//! Flow execution log entries.
2//!
3//! Enabled by the `log` feature.
4
5use serde::{Deserialize, Serialize};
6use std::io;
7use std::path::Path;
8
9/// The maximum number of entries retained on disk before old entries are
10/// rotated out by [`append_flow_log`].
11pub const DEFAULT_LOG_RETENTION: usize = 2000;
12
13/// A single line in a flow's execution log.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct FlowLogEntry {
16    /// ISO-8601 / RFC-3339 timestamp of when the entry was recorded.
17    pub timestamp: String,
18    /// Id of the flow this entry belongs to.
19    pub flow_id: String,
20    /// Human-readable name of the flow at the time the entry was recorded.
21    pub flow_name: String,
22    /// Short verb describing what happened (e.g. `"started"`, `"node_ran"`,
23    /// `"failed"`). The spec does not constrain the value set.
24    pub action: String,
25    /// Longer free-form detail about the action.
26    pub detail: String,
27    /// Optional run identifier to correlate entries from a single execution.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub run_id: Option<String>,
30}
31
32/// Append `entry` to `log_path` (a JSON-array file), trimming to the most
33/// recent [`DEFAULT_LOG_RETENTION`] entries.
34///
35/// Creates the file if it doesn't exist.
36pub fn append_flow_log(log_path: &Path, entry: &FlowLogEntry) -> io::Result<()> {
37    let mut entries = load_flow_logs(log_path);
38    entries.push(entry.clone());
39    if entries.len() > DEFAULT_LOG_RETENTION {
40        let drop = entries.len() - DEFAULT_LOG_RETENTION;
41        entries.drain(0..drop);
42    }
43    let json = serde_json::to_string_pretty(&entries)
44        .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
45    std::fs::write(log_path, json)
46}
47
48/// Read all log entries from `log_path`.
49///
50/// Returns an empty vec if the file is missing or unparseable.
51pub fn load_flow_logs(log_path: &Path) -> Vec<FlowLogEntry> {
52    if !log_path.exists() {
53        return vec![];
54    }
55    std::fs::read_to_string(log_path)
56        .ok()
57        .and_then(|s| serde_json::from_str(&s).ok())
58        .unwrap_or_default()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use tempfile::tempdir;
65
66    fn entry(action: &str) -> FlowLogEntry {
67        FlowLogEntry {
68            timestamp: "2026-01-01T00:00:00Z".into(),
69            flow_id: "f1".into(),
70            flow_name: "F1".into(),
71            action: action.into(),
72            detail: "stuff".into(),
73            run_id: Some("r1".into()),
74        }
75    }
76
77    #[test]
78    fn append_then_load_round_trip() {
79        let dir = tempdir().unwrap();
80        let path = dir.path().join("log.json");
81        append_flow_log(&path, &entry("started")).unwrap();
82        append_flow_log(&path, &entry("finished")).unwrap();
83        let loaded = load_flow_logs(&path);
84        assert_eq!(loaded.len(), 2);
85        assert_eq!(loaded[0].action, "started");
86        assert_eq!(loaded[1].action, "finished");
87    }
88
89    #[test]
90    fn missing_log_returns_empty() {
91        let dir = tempdir().unwrap();
92        assert!(load_flow_logs(&dir.path().join("nope.json")).is_empty());
93    }
94
95    #[test]
96    fn retention_drops_oldest() {
97        let dir = tempdir().unwrap();
98        let path = dir.path().join("log.json");
99        for i in 0..(DEFAULT_LOG_RETENTION + 50) {
100            let mut e = entry("tick");
101            e.detail = format!("{i}");
102            append_flow_log(&path, &e).unwrap();
103        }
104        let loaded = load_flow_logs(&path);
105        assert_eq!(loaded.len(), DEFAULT_LOG_RETENTION);
106        // First retained entry should be the 50th appended (0-indexed).
107        assert_eq!(loaded.first().unwrap().detail, "50");
108        assert_eq!(
109            loaded.last().unwrap().detail,
110            format!("{}", DEFAULT_LOG_RETENTION + 49)
111        );
112    }
113}