1use crate::errors::LitError;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub enum EventType {
14 CommitPushed,
15 BranchCreated,
16 BranchDeleted,
17 BranchUpdated,
18 MergeCompleted,
19 TagCreated,
20 IssueOpened,
21 IssueClosed,
22 PrOpened,
23 PrMerged,
24 PrClosed,
25 AgentJoined,
26 AgentLeft,
27 TaskDelegated,
28 TaskCompleted,
29 TrustChanged,
30 UcanIssued,
31 UcanRevoked,
32 All,
33}
34
35impl std::fmt::Display for EventType {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 write!(f, "{:?}", self)
38 }
39}
40
41impl std::str::FromStr for EventType {
42 type Err = String;
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 match s.to_lowercase().as_str() {
45 "commitpushed" | "commit" => Ok(EventType::CommitPushed),
46 "branchcreated" | "branch-created" => Ok(EventType::BranchCreated),
47 "branchdeleted" | "branch-deleted" => Ok(EventType::BranchDeleted),
48 "branchupdated" | "branch-updated" => Ok(EventType::BranchUpdated),
49 "mergecompleted" | "merge" => Ok(EventType::MergeCompleted),
50 "tagcreated" | "tag" => Ok(EventType::TagCreated),
51 "issueopened" | "issue-opened" => Ok(EventType::IssueOpened),
52 "issueclosed" | "issue-closed" => Ok(EventType::IssueClosed),
53 "propened" | "pr-opened" => Ok(EventType::PrOpened),
54 "prmerged" | "pr-merged" => Ok(EventType::PrMerged),
55 "prclosed" | "pr-closed" => Ok(EventType::PrClosed),
56 "agentjoined" | "agent-joined" => Ok(EventType::AgentJoined),
57 "agentleft" | "agent-left" => Ok(EventType::AgentLeft),
58 "taskdelegated" | "task-delegated" => Ok(EventType::TaskDelegated),
59 "taskcompleted" | "task-completed" => Ok(EventType::TaskCompleted),
60 "trustchanged" | "trust-changed" => Ok(EventType::TrustChanged),
61 "ucanissued" | "ucan-issued" => Ok(EventType::UcanIssued),
62 "ucanrevoked" | "ucan-revoked" => Ok(EventType::UcanRevoked),
63 "all" | "*" => Ok(EventType::All),
64 _ => Err(format!("Unknown event type: {}", s)),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct EventSubscription {
72 pub id: String,
74 pub subscriber: String,
76 pub event_types: Vec<EventType>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub branch_filter: Option<String>,
81 pub created: String,
83 pub active: bool,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Event {
90 pub event_type: EventType,
92 pub timestamp: String,
94 pub actor: String,
96 pub payload: serde_json::Value,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub branch: Option<String>,
101}
102
103fn subscriptions_dir(repo_root: &Path) -> std::path::PathBuf {
104 repo_root.join(".lit").join("events").join("subscriptions")
105}
106
107fn events_log_path(repo_root: &Path) -> std::path::PathBuf {
108 repo_root.join(".lit").join("events").join("log.jsonl")
109}
110
111pub fn subscribe(
113 repo_root: &Path,
114 subscriber: &str,
115 event_types: Vec<EventType>,
116 branch_filter: Option<String>,
117) -> Result<EventSubscription, LitError> {
118 let dir = subscriptions_dir(repo_root);
119 fs::create_dir_all(&dir)
120 .map_err(|e| LitError::io(format!("Failed to create subscriptions dir: {}", e)))?;
121
122 let id = format!(
123 "{:016x}",
124 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
125 );
126 let sub = EventSubscription {
127 id: id.clone(),
128 subscriber: subscriber.to_string(),
129 event_types,
130 branch_filter,
131 created: chrono::Utc::now().to_rfc3339(),
132 active: true,
133 };
134
135 let path = dir.join(format!("{}.json", id));
136 let json = serde_json::to_string_pretty(&sub)
137 .map_err(|e| LitError::general(format!("Serialize error: {}", e)))?;
138 fs::write(&path, json).map_err(|e| LitError::io(format!("Write error: {}", e)))?;
139
140 Ok(sub)
141}
142
143pub fn list_subscriptions(repo_root: &Path) -> Result<Vec<EventSubscription>, LitError> {
145 let dir = subscriptions_dir(repo_root);
146 if !dir.exists() {
147 return Ok(Vec::new());
148 }
149
150 let mut subs = Vec::new();
151 for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
152 let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
153 if entry.path().extension().is_some_and(|e| e == "json") {
154 if let Ok(json) = fs::read_to_string(entry.path()) {
155 if let Ok(sub) = serde_json::from_str::<EventSubscription>(&json) {
156 subs.push(sub);
157 }
158 }
159 }
160 }
161 Ok(subs)
162}
163
164pub fn unsubscribe(repo_root: &Path, sub_id: &str) -> Result<(), LitError> {
166 let path = subscriptions_dir(repo_root).join(format!("{}.json", sub_id));
167 if path.exists() {
168 fs::remove_file(&path).map_err(|e| LitError::io(format!("Remove error: {}", e)))?;
169 Ok(())
170 } else {
171 Err(LitError::general(format!(
172 "Subscription not found: {}",
173 sub_id
174 )))
175 }
176}
177
178pub fn emit_event(repo_root: &Path, event: &Event) -> Result<(), LitError> {
180 let log_path = events_log_path(repo_root);
181 if let Some(parent) = log_path.parent() {
182 fs::create_dir_all(parent).map_err(|e| LitError::io(format!("IO: {}", e)))?;
183 }
184
185 let line = serde_json::to_string(event)
186 .map_err(|e| LitError::general(format!("Serialize error: {}", e)))?;
187
188 use std::io::Write;
189 let mut file = fs::OpenOptions::new()
190 .create(true)
191 .append(true)
192 .open(&log_path)
193 .map_err(|e| LitError::io(format!("IO: {}", e)))?;
194 writeln!(file, "{}", line).map_err(|e| LitError::io(format!("Write error: {}", e)))?;
195
196 Ok(())
197}
198
199pub fn read_events(
201 repo_root: &Path,
202 event_type: Option<&EventType>,
203 limit: usize,
204) -> Result<Vec<Event>, LitError> {
205 let log_path = events_log_path(repo_root);
206 if !log_path.exists() {
207 return Ok(Vec::new());
208 }
209
210 let content = fs::read_to_string(&log_path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
211
212 let mut events: Vec<Event> = content
213 .lines()
214 .filter_map(|line| serde_json::from_str::<Event>(line).ok())
215 .filter(|e| event_type.is_none_or(|et| *et == EventType::All || e.event_type == *et))
216 .collect();
217
218 events.reverse();
220 events.truncate(limit);
221 Ok(events)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::path::PathBuf;
228 use std::sync::atomic::{AtomicU32, Ordering};
229
230 static COUNTER: AtomicU32 = AtomicU32::new(0);
231
232 fn tmp_dir() -> PathBuf {
239 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
240 let dir =
241 std::env::temp_dir().join(format!("lit_events_test_{}_{}", std::process::id(), n));
242 let _ = fs::remove_dir_all(&dir);
243 fs::create_dir_all(&dir).unwrap();
244 dir
245 }
246
247 #[test]
248 fn test_subscribe_and_list() {
249 let dir = tmp_dir();
250 let sub = subscribe(
251 &dir,
252 "did:lit:user1",
253 vec![EventType::CommitPushed, EventType::MergeCompleted],
254 Some("main".to_string()),
255 )
256 .unwrap();
257
258 assert!(sub.active);
259 assert_eq!(sub.event_types.len(), 2);
260
261 let subs = list_subscriptions(&dir).unwrap();
262 assert_eq!(subs.len(), 1);
263
264 let _ = fs::remove_dir_all(&dir);
265 }
266
267 #[test]
268 fn test_emit_and_read() {
269 let dir = tmp_dir();
270 let event = Event {
271 event_type: EventType::CommitPushed,
272 timestamp: chrono::Utc::now().to_rfc3339(),
273 actor: "did:lit:agent1".to_string(),
274 payload: serde_json::json!({"hash": "abc123"}),
275 branch: Some("main".to_string()),
276 };
277 emit_event(&dir, &event).unwrap();
278
279 let events = read_events(&dir, None, 10).unwrap();
280 assert_eq!(events.len(), 1);
281 assert_eq!(events[0].event_type, EventType::CommitPushed);
282
283 let _ = fs::remove_dir_all(&dir);
284 }
285
286 #[test]
287 fn test_event_type_parse() {
288 assert_eq!(
289 "commit".parse::<EventType>().unwrap(),
290 EventType::CommitPushed
291 );
292 assert_eq!("all".parse::<EventType>().unwrap(), EventType::All);
293 assert!("invalid".parse::<EventType>().is_err());
294 }
295}