1use serde::{Deserialize, Serialize};
16use std::fs;
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19use time::format_description::well_known::Rfc3339;
20use time::OffsetDateTime;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SignalKind {
25 Superseded,
27 BindingConflict,
29}
30
31impl SignalKind {
32 fn as_str(self) -> &'static str {
33 match self {
34 SignalKind::Superseded => "superseded",
35 SignalKind::BindingConflict => "binding_conflict",
36 }
37 }
38}
39
40#[derive(Debug, Serialize, Deserialize)]
41struct SignalLine {
42 ts: String,
43 kind: String,
44 key: String,
45}
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct SignalCounts {
50 pub superseded: u64,
51 pub conflicts: u64,
52}
53
54pub fn signals_path(project_id: &str) -> PathBuf {
56 edda_store::project_dir(project_id)
57 .join("state")
58 .join("decision_signals.jsonl")
59}
60
61pub fn record_decision_signal(project_id: &str, kind: SignalKind, key: &str) -> io::Result<()> {
63 record_signal_at(&signals_path(project_id), kind, key)
64}
65
66pub fn should_record_conflict(existing_actor: &str, current_actor: &str) -> bool {
70 existing_actor != current_actor
71}
72
73pub fn record_conflict_if_cross_actor(
76 path: &Path,
77 key: &str,
78 existing_actor: &str,
79 current_actor: &str,
80) -> io::Result<()> {
81 if !should_record_conflict(existing_actor, current_actor) {
82 return Ok(());
83 }
84 record_signal_at(path, SignalKind::BindingConflict, key)
85}
86
87pub fn record_conflict_signal_if_cross_actor(
89 project_id: &str,
90 key: &str,
91 existing_actor: &str,
92 current_actor: &str,
93) -> io::Result<()> {
94 record_conflict_if_cross_actor(
95 &signals_path(project_id),
96 key,
97 existing_actor,
98 current_actor,
99 )
100}
101
102pub fn record_signal_at(path: &Path, kind: SignalKind, key: &str) -> io::Result<()> {
104 if let Some(parent) = path.parent() {
105 fs::create_dir_all(parent)?;
106 }
107 let line = SignalLine {
108 ts: OffsetDateTime::now_utc()
109 .format(&Rfc3339)
110 .unwrap_or_default(),
111 kind: kind.as_str().to_string(),
112 key: key.to_string(),
113 };
114 let mut f = fs::OpenOptions::new()
115 .create(true)
116 .append(true)
117 .open(path)?;
118 writeln!(f, "{}", serde_json::to_string(&line)?)?;
119 Ok(())
120}
121
122pub fn count_signals_between(
125 project_id: &str,
126 first_ts: Option<&str>,
127 last_ts: Option<&str>,
128) -> SignalCounts {
129 count_signals_at(&signals_path(project_id), first_ts, last_ts)
130}
131
132pub fn count_signals_at(
134 path: &Path,
135 first_ts: Option<&str>,
136 last_ts: Option<&str>,
137) -> SignalCounts {
138 let mut counts = SignalCounts::default();
139 let Ok(content) = fs::read_to_string(path) else {
140 return counts;
141 };
142 let lo = first_ts.and_then(parse_ts);
143 let hi = last_ts.and_then(parse_ts);
144
145 for line in content.lines() {
146 if line.trim().is_empty() {
147 continue;
148 }
149 let Ok(sig) = serde_json::from_str::<SignalLine>(line) else {
150 continue;
151 };
152 let Some(ts) = parse_ts(&sig.ts) else {
153 continue;
154 };
155 if lo.map(|l| ts < l).unwrap_or(false) || hi.map(|h| ts > h).unwrap_or(false) {
156 continue;
157 }
158 match sig.kind.as_str() {
159 "superseded" => counts.superseded += 1,
160 "binding_conflict" => counts.conflicts += 1,
161 _ => {}
162 }
163 }
164 counts
165}
166
167fn parse_ts(s: &str) -> Option<OffsetDateTime> {
168 OffsetDateTime::parse(s, &Rfc3339).ok()
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn write_lines(path: &Path, lines: &[&str]) {
176 fs::create_dir_all(path.parent().unwrap()).unwrap();
177 fs::write(path, lines.join("\n")).unwrap();
178 }
179
180 #[test]
182 fn conflict_signal_dropped_when_same_actor_as_existing_binding() {
183 let dir = tempfile::tempdir().unwrap();
184 let path = dir.path().join("sig.jsonl");
185 assert!(
187 !should_record_conflict("cli", "cli"),
188 "same actor progressing own binding is not a conflict (病一)"
189 );
190 record_conflict_if_cross_actor(&path, "db.engine", "cli", "cli").unwrap();
192 assert_eq!(count_signals_at(&path, None, None).conflicts, 0);
193 }
194
195 #[test]
196 fn conflict_signal_recorded_when_different_actor() {
197 let dir = tempfile::tempdir().unwrap();
198 let path = dir.path().join("sig.jsonl");
199 assert!(
200 should_record_conflict("cli", "product"),
201 "different actor = real cross-agent conflict (照記)"
202 );
203 record_conflict_if_cross_actor(&path, "db.engine", "product", "cli").unwrap();
204 assert_eq!(count_signals_at(&path, None, None).conflicts, 1);
205 }
206
207 #[test]
208 fn superseded_signal_still_recorded_regardless_of_actor() {
209 let dir = tempfile::tempdir().unwrap();
210 let path = dir.path().join("sig.jsonl");
211 record_signal_at(&path, SignalKind::Superseded, "db.engine").unwrap();
213 assert_eq!(count_signals_at(&path, None, None).superseded, 1);
214 }
215
216 #[test]
217 fn record_then_count_roundtrip() {
218 let dir = tempfile::tempdir().unwrap();
219 let path = dir.path().join("state").join("decision_signals.jsonl");
220 record_signal_at(&path, SignalKind::Superseded, "db.engine").unwrap();
221 record_signal_at(&path, SignalKind::BindingConflict, "db.engine").unwrap();
222 record_signal_at(&path, SignalKind::Superseded, "auth.method").unwrap();
223
224 let counts = count_signals_at(&path, None, None);
225 assert_eq!(counts.superseded, 2);
226 assert_eq!(counts.conflicts, 1);
227 }
228
229 #[test]
230 fn missing_file_counts_zero() {
231 let dir = tempfile::tempdir().unwrap();
232 let path = dir.path().join("nope.jsonl");
233 assert_eq!(count_signals_at(&path, None, None), SignalCounts::default());
234 }
235
236 #[test]
237 fn window_filters_outside_signals() {
238 let dir = tempfile::tempdir().unwrap();
239 let path = dir.path().join("sig.jsonl");
240 write_lines(
241 &path,
242 &[
243 r#"{"ts":"2026-07-08T01:00:00Z","kind":"superseded","key":"a"}"#,
244 r#"{"ts":"2026-07-08T02:30:00Z","kind":"superseded","key":"b"}"#,
245 r#"{"ts":"2026-07-08T02:45:00.5Z","kind":"binding_conflict","key":"c"}"#,
246 r#"{"ts":"2026-07-08T04:00:00Z","kind":"superseded","key":"d"}"#,
247 ],
248 );
249 let counts = count_signals_at(
250 &path,
251 Some("2026-07-08T02:00:00Z"),
252 Some("2026-07-08T03:00:00Z"),
253 );
254 assert_eq!(counts.superseded, 1, "only the in-window supersede");
255 assert_eq!(counts.conflicts, 1, "fractional-second ts inside window");
256
257 let open_start = count_signals_at(&path, None, Some("2026-07-08T03:00:00Z"));
258 assert_eq!(open_start.superseded, 2);
259 }
260
261 #[test]
262 fn malformed_and_unknown_lines_skipped() {
263 let dir = tempfile::tempdir().unwrap();
264 let path = dir.path().join("sig.jsonl");
265 write_lines(
266 &path,
267 &[
268 "not json",
269 r#"{"ts":"garbage","kind":"superseded","key":"a"}"#,
270 r#"{"ts":"2026-07-08T01:00:00Z","kind":"weird","key":"a"}"#,
271 r#"{"ts":"2026-07-08T01:00:00Z","kind":"superseded","key":"a"}"#,
272 "",
273 ],
274 );
275 let counts = count_signals_at(&path, None, None);
276 assert_eq!(counts.superseded, 1);
277 assert_eq!(counts.conflicts, 0);
278 }
279}