1use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::time::{SystemTime, UNIX_EPOCH};
23use tracing::warn;
24
25pub fn events_path(project_root: &Path) -> PathBuf {
27 project_root.join(".devflow").join("events.jsonl")
28}
29
30const SCHEMA_VERSION: u32 = 1;
32
33pub fn emit(project_root: &Path, phase: u32, event: &str, fields: serde_json::Value) {
36 let mut line = serde_json::json!({
37 "v": SCHEMA_VERSION,
38 "ts": unix_now(),
39 "phase": phase,
40 "event": event,
41 });
42 match fields {
43 serde_json::Value::Object(map) => {
44 let base = line.as_object_mut().expect("line is an object");
45 for (key, value) in map {
46 base.entry(key).or_insert(value);
49 }
50 }
51 serde_json::Value::Null => {}
52 other => {
53 line["data"] = other;
54 }
55 }
56 let path = events_path(project_root);
57 if let Some(parent) = path.parent()
58 && let Err(err) = std::fs::create_dir_all(parent)
59 {
60 warn!("could not create events dir: {err}");
61 return;
62 }
63 let result = std::fs::OpenOptions::new()
64 .create(true)
65 .append(true)
66 .open(&path)
67 .and_then(|mut f| f.write_all(format!("{line}\n").as_bytes()));
68 if let Err(err) = result {
69 warn!("could not append to {}: {err}", path.display());
70 }
71}
72
73pub fn last_events_by_phase(
76 project_root: &Path,
77) -> std::collections::HashMap<u32, serde_json::Value> {
78 let mut latest = std::collections::HashMap::new();
79 let Ok(contents) = std::fs::read_to_string(events_path(project_root)) else {
80 return latest;
81 };
82 for event in contents
83 .lines()
84 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
85 {
86 if let Some(phase) = event.get("phase").and_then(|p| p.as_u64()) {
87 latest.insert(phase as u32, event);
89 }
90 }
91 latest
92}
93
94pub fn last_event_for_phase(project_root: &Path, phase: u32) -> Option<serde_json::Value> {
96 last_events_by_phase(project_root).remove(&phase)
97}
98
99pub fn describe(event: &serde_json::Value) -> String {
101 let kind = event
102 .get("event")
103 .and_then(|e| e.as_str())
104 .unwrap_or("unknown");
105 let detail = ["to", "stage", "status", "hook", "reason"]
106 .iter()
107 .find_map(|key| event.get(*key).and_then(|v| v.as_str()));
108 match detail {
109 Some(detail) => format!("{kind} ({detail})"),
110 None => kind.to_string(),
111 }
112}
113
114fn unix_now() -> u64 {
115 SystemTime::now()
116 .duration_since(UNIX_EPOCH)
117 .map(|d| d.as_secs())
118 .unwrap_or(0)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn read_lines(root: &Path) -> Vec<serde_json::Value> {
126 std::fs::read_to_string(events_path(root))
127 .unwrap_or_default()
128 .lines()
129 .map(|l| serde_json::from_str(l).expect("every line parses as JSON"))
130 .collect()
131 }
132
133 #[test]
134 fn emit_appends_parseable_lines_with_envelope_fields() {
135 let dir = tempfile::tempdir().unwrap();
136 emit(
137 dir.path(),
138 14,
139 "transition",
140 serde_json::json!({"from": "code", "to": "validate"}),
141 );
142 emit(
143 dir.path(),
144 15,
145 "gate_fired",
146 serde_json::json!({"stage": "ship"}),
147 );
148
149 let lines = read_lines(dir.path());
150 assert_eq!(lines.len(), 2);
151 for line in &lines {
152 assert_eq!(line["v"], 1);
153 assert!(line["ts"].as_u64().is_some());
154 assert!(line["phase"].as_u64().is_some());
155 assert!(line["event"].as_str().is_some());
156 }
157 assert_eq!(lines[0]["phase"], 14);
158 assert_eq!(lines[0]["from"], "code");
159 assert_eq!(lines[1]["phase"], 15);
160 assert_eq!(lines[1]["stage"], "ship");
161 }
162
163 #[test]
164 fn emit_never_lets_payload_forge_envelope_keys() {
165 let dir = tempfile::tempdir().unwrap();
166 emit(
167 dir.path(),
168 7,
169 "transition",
170 serde_json::json!({"phase": 99, "event": "forged", "note": "kept"}),
171 );
172
173 let lines = read_lines(dir.path());
174 assert_eq!(lines[0]["phase"], 7, "envelope phase must win");
175 assert_eq!(lines[0]["event"], "transition", "envelope event must win");
176 assert_eq!(lines[0]["note"], "kept");
177 }
178
179 #[test]
180 fn last_event_for_phase_filters_by_phase() {
181 let dir = tempfile::tempdir().unwrap();
182 emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
183 emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
184 emit(
185 dir.path(),
186 1,
187 "transition",
188 serde_json::json!({"to": "plan"}),
189 );
190
191 let last = last_event_for_phase(dir.path(), 1).expect("phase 1 events exist");
192 assert_eq!(last["event"], "transition");
193 let other = last_event_for_phase(dir.path(), 2).expect("phase 2 events exist");
194 assert_eq!(other["event"], "workflow_started");
195 assert!(last_event_for_phase(dir.path(), 3).is_none());
196 }
197
198 #[test]
200 fn last_events_by_phase_collects_latest_per_phase_in_one_pass() {
201 let dir = tempfile::tempdir().unwrap();
202 emit(dir.path(), 1, "workflow_started", serde_json::Value::Null);
203 emit(dir.path(), 2, "workflow_started", serde_json::Value::Null);
204 emit(
205 dir.path(),
206 1,
207 "transition",
208 serde_json::json!({"to": "plan"}),
209 );
210 emit(
211 dir.path(),
212 2,
213 "gate_fired",
214 serde_json::json!({"stage": "ship"}),
215 );
216
217 let latest = last_events_by_phase(dir.path());
218 assert_eq!(latest.len(), 2);
219 assert_eq!(latest[&1]["event"], "transition");
220 assert_eq!(latest[&2]["event"], "gate_fired");
221 assert!(last_events_by_phase(&dir.path().join("empty")).is_empty());
222 }
223
224 #[test]
225 fn last_event_skips_corrupt_lines() {
226 let dir = tempfile::tempdir().unwrap();
227 emit(dir.path(), 4, "workflow_started", serde_json::Value::Null);
228 let path = events_path(dir.path());
229 let mut contents = std::fs::read_to_string(&path).unwrap();
230 contents.push_str("{truncated\n");
231 std::fs::write(&path, contents).unwrap();
232
233 let last = last_event_for_phase(dir.path(), 4).expect("valid line still found");
234 assert_eq!(last["event"], "workflow_started");
235 }
236
237 #[test]
238 fn describe_prefers_detail_fields() {
239 assert_eq!(
240 describe(&serde_json::json!({"event": "transition", "to": "ship"})),
241 "transition (ship)"
242 );
243 assert_eq!(
244 describe(&serde_json::json!({"event": "workflow_finished"})),
245 "workflow_finished"
246 );
247 assert_eq!(describe(&serde_json::json!({})), "unknown");
248 }
249
250 #[test]
251 fn emit_is_fail_soft_on_unwritable_path() {
252 let dir = tempfile::tempdir().unwrap();
255 std::fs::write(dir.path().join(".devflow"), "not a dir").unwrap();
256 emit(dir.path(), 1, "transition", serde_json::Value::Null);
257 }
258}