Skip to main content

joy_core/
event_log.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::fmt;
5use std::fs::{self, OpenOptions};
6use std::io::Write;
7use std::path::Path;
8
9use chrono::Utc;
10
11use crate::error::JoyError;
12use crate::store;
13use crate::vcs::Vcs;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum EventType {
17    ItemCreated,
18    ItemUpdated,
19    ItemStatusChanged,
20    ItemDeleted,
21    ItemAssigned,
22    ItemUnassigned,
23    DepAdded,
24    DepRemoved,
25    CommentAdded,
26    MilestoneCreated,
27    MilestoneUpdated,
28    MilestoneDeleted,
29    MilestoneLinked,
30    MilestoneUnlinked,
31    ReleaseCreated,
32    GuardDenied,
33    GuardWarned,
34    AuthSessionCreated,
35}
36
37impl fmt::Display for EventType {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        let s = match self {
40            Self::ItemCreated => "item.created",
41            Self::ItemUpdated => "item.updated",
42            Self::ItemStatusChanged => "item.status_changed",
43            Self::ItemDeleted => "item.deleted",
44            Self::ItemAssigned => "item.assigned",
45            Self::ItemUnassigned => "item.unassigned",
46            Self::DepAdded => "dep.added",
47            Self::DepRemoved => "dep.removed",
48            Self::CommentAdded => "comment.added",
49            Self::MilestoneCreated => "milestone.created",
50            Self::MilestoneUpdated => "milestone.updated",
51            Self::MilestoneDeleted => "milestone.deleted",
52            Self::MilestoneLinked => "milestone.linked",
53            Self::MilestoneUnlinked => "milestone.unlinked",
54            Self::ReleaseCreated => "release.created",
55            Self::GuardDenied => "guard.denied",
56            Self::GuardWarned => "guard.warned",
57            Self::AuthSessionCreated => "auth.session_created",
58        };
59        write!(f, "{s}")
60    }
61}
62
63impl EventType {
64    pub fn parse(s: &str) -> Option<Self> {
65        match s {
66            "item.created" => Some(Self::ItemCreated),
67            "item.updated" => Some(Self::ItemUpdated),
68            "item.status_changed" => Some(Self::ItemStatusChanged),
69            "item.deleted" => Some(Self::ItemDeleted),
70            "item.assigned" => Some(Self::ItemAssigned),
71            "item.unassigned" => Some(Self::ItemUnassigned),
72            "dep.added" => Some(Self::DepAdded),
73            "dep.removed" => Some(Self::DepRemoved),
74            "comment.added" => Some(Self::CommentAdded),
75            "milestone.created" => Some(Self::MilestoneCreated),
76            "milestone.updated" => Some(Self::MilestoneUpdated),
77            "milestone.deleted" => Some(Self::MilestoneDeleted),
78            "milestone.linked" => Some(Self::MilestoneLinked),
79            "milestone.unlinked" => Some(Self::MilestoneUnlinked),
80            "release.created" => Some(Self::ReleaseCreated),
81            "guard.denied" => Some(Self::GuardDenied),
82            "guard.warned" => Some(Self::GuardWarned),
83            "auth.session_created" => Some(Self::AuthSessionCreated),
84            _ => None,
85        }
86    }
87}
88
89pub struct Event {
90    pub event_type: EventType,
91    pub target: String,
92    pub details: Option<String>,
93    pub user: String,
94}
95
96/// Append an event to .joy/log/YYYY-MM-DD.log.
97pub fn append_event(root: &Path, event: &Event) -> Result<(), JoyError> {
98    let now = Utc::now();
99    let date_str = now.format("%Y-%m-%d").to_string();
100    let timestamp = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
101
102    let log_dir = store::joy_dir(root).join(store::LOG_DIR);
103    fs::create_dir_all(&log_dir).map_err(|e| JoyError::CreateDir {
104        path: log_dir.clone(),
105        source: e,
106    })?;
107
108    let log_file = log_dir.join(format!("{date_str}.log"));
109
110    let line = match &event.details {
111        Some(details) => {
112            let escaped = escape_details(details);
113            format!(
114                "{timestamp} {target} {event_type} \"{escaped}\" [{user}]\n",
115                event_type = event.event_type,
116                target = event.target,
117                user = event.user,
118            )
119        }
120        None => format!(
121            "{timestamp} {target} {event_type} [{user}]\n",
122            event_type = event.event_type,
123            target = event.target,
124            user = event.user,
125        ),
126    };
127
128    let mut file = OpenOptions::new()
129        .create(true)
130        .append(true)
131        .open(&log_file)
132        .map_err(|e| JoyError::WriteFile {
133            path: log_file.clone(),
134            source: e,
135        })?;
136
137    file.write_all(line.as_bytes())
138        .map_err(|e| JoyError::WriteFile {
139            path: log_file.clone(),
140            source: e,
141        })?;
142    let rel = format!("{}/{}/{}.log", store::JOY_DIR, store::LOG_DIR, date_str);
143    crate::git_ops::auto_git_add(root, &[&rel]);
144    Ok(())
145}
146
147/// A parsed log entry for display.
148#[derive(Debug, Clone, serde::Serialize)]
149pub struct LogEntry {
150    pub timestamp: String,
151    pub event_type: String,
152    pub target: String,
153    pub details: Option<String>,
154    pub user: String,
155}
156
157/// Read events from .joy/log/ files, newest first.
158pub fn read_events(
159    root: &Path,
160    since: Option<&str>,
161    item_filter: Option<&str>,
162    limit: usize,
163) -> Result<Vec<LogEntry>, JoyError> {
164    let log_dir = store::joy_dir(root).join(store::LOG_DIR);
165    if !log_dir.is_dir() {
166        return Ok(Vec::new());
167    }
168
169    let mut log_files: Vec<_> = fs::read_dir(&log_dir)
170        .map_err(|e| JoyError::ReadFile {
171            path: log_dir.clone(),
172            source: e,
173        })?
174        .filter_map(|e| e.ok())
175        .filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
176        .collect();
177
178    // Sort descending by filename (newest day first)
179    log_files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
180
181    // Filter by date if --since is provided
182    let since_date = since.map(|s| s.to_string());
183
184    let mut entries = Vec::new();
185
186    for file_entry in &log_files {
187        let filename = file_entry.file_name();
188        let filename = filename.to_string_lossy();
189        let file_date = filename.trim_end_matches(".log");
190
191        if let Some(ref since) = since_date {
192            if file_date < since.as_str() {
193                break;
194            }
195        }
196
197        let content = fs::read_to_string(file_entry.path()).map_err(|e| JoyError::ReadFile {
198            path: file_entry.path(),
199            source: e,
200        })?;
201
202        // Parse lines in reverse (newest first within a day)
203        let mut day_entries: Vec<LogEntry> = Vec::new();
204        for line in content.lines() {
205            if let Some(entry) = parse_log_line(line) {
206                if let Some(filter) = item_filter {
207                    if !entry.target.contains(filter) {
208                        continue;
209                    }
210                }
211                day_entries.push(entry);
212            }
213        }
214
215        day_entries.reverse();
216        entries.extend(day_entries);
217
218        if entries.len() >= limit {
219            entries.truncate(limit);
220            break;
221        }
222    }
223
224    entries.truncate(limit);
225    Ok(entries)
226}
227
228/// Escape newlines and backslashes in details for single-line log format.
229fn escape_details(s: &str) -> String {
230    s.replace('\\', "\\\\").replace('\n', "\\n")
231}
232
233/// Unescape details read from log files.
234fn unescape_details(s: &str) -> String {
235    let mut result = String::with_capacity(s.len());
236    let mut chars = s.chars();
237    while let Some(c) = chars.next() {
238        if c == '\\' {
239            match chars.next() {
240                Some('n') => result.push('\n'),
241                Some('\\') => result.push('\\'),
242                Some(other) => {
243                    result.push('\\');
244                    result.push(other);
245                }
246                None => result.push('\\'),
247            }
248        } else {
249            result.push(c);
250        }
251    }
252    result
253}
254
255/// Validate that a string looks like an ISO 8601 timestamp (starts with YYYY-).
256fn is_valid_timestamp(s: &str) -> bool {
257    s.len() >= 20 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' && s.as_bytes()[10] == b'T'
258}
259
260/// Parse a single log line into a LogEntry.
261fn parse_log_line(line: &str) -> Option<LogEntry> {
262    let line = line.trim();
263    if line.is_empty() {
264        return None;
265    }
266
267    // Fast reject: valid lines always start with a digit (timestamp year)
268    if !line.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) {
269        return None;
270    }
271
272    // Format: TIMESTAMP TARGET EVENT_TYPE ["DETAILS"] [USER]
273    // Extract user from trailing [user]
274    let user_start = line.rfind('[')?;
275    let user_end = line.rfind(']')?;
276    if user_end <= user_start {
277        return None;
278    }
279    let user = line[user_start + 1..user_end].to_string();
280    let rest = line[..user_start].trim();
281
282    // Extract optional details from "..."
283    let (rest, details) = if let Some(dq_start) = rest.rfind('"') {
284        let before_last = &rest[..dq_start];
285        if let Some(dq_open) = before_last.rfind('"') {
286            let details = unescape_details(&rest[dq_open + 1..dq_start]);
287            let rest = rest[..dq_open].trim();
288            (rest, Some(details))
289        } else {
290            (rest, None)
291        }
292    } else {
293        (rest, None)
294    };
295
296    // Split remaining: TIMESTAMP TARGET EVENT_TYPE
297    let parts: Vec<&str> = rest.splitn(3, ' ').collect();
298    if parts.len() < 3 {
299        return None;
300    }
301
302    // Validate timestamp format
303    if !is_valid_timestamp(parts[0]) {
304        return None;
305    }
306
307    Some(LogEntry {
308        timestamp: parts[0].to_string(),
309        target: parts[1].to_string(),
310        event_type: parts[2].to_string(),
311        details,
312        user,
313    })
314}
315
316/// Read all events (oldest first, no limit). Used for release computation.
317pub fn read_all_events(root: &Path) -> Result<Vec<LogEntry>, JoyError> {
318    let log_dir = store::joy_dir(root).join(store::LOG_DIR);
319    if !log_dir.is_dir() {
320        return Ok(Vec::new());
321    }
322
323    let mut log_files: Vec<_> = fs::read_dir(&log_dir)
324        .map_err(|e| JoyError::ReadFile {
325            path: log_dir.clone(),
326            source: e,
327        })?
328        .filter_map(|e| e.ok())
329        .filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
330        .collect();
331
332    // Sort ascending by filename (oldest day first)
333    log_files.sort_by_key(|e| e.file_name());
334
335    let mut entries = Vec::new();
336    for file_entry in &log_files {
337        let content = fs::read_to_string(file_entry.path()).map_err(|e| JoyError::ReadFile {
338            path: file_entry.path(),
339            source: e,
340        })?;
341        for line in content.lines() {
342            if let Some(entry) = parse_log_line(line) {
343                entries.push(entry);
344            }
345        }
346    }
347
348    Ok(entries)
349}
350
351/// Find the timestamp of the last release.created event, if any.
352pub fn last_release_timestamp(root: &Path) -> Result<Option<String>, JoyError> {
353    let events = read_all_events(root)?;
354    let last = events
355        .iter()
356        .rev()
357        .find(|e| e.event_type == "release.created");
358    Ok(last.map(|e| e.timestamp.clone()))
359}
360
361/// Collect unique item IDs that were closed after a given timestamp.
362/// If cutoff is None, returns all items ever closed.
363/// Returns deduplicated item IDs (an item closed multiple times appears once).
364pub fn closed_item_ids_since(root: &Path, cutoff: Option<&str>) -> Result<Vec<String>, JoyError> {
365    let events = read_all_events(root)?;
366    let mut seen = std::collections::HashSet::new();
367    let mut results: Vec<String> = Vec::new();
368
369    for entry in &events {
370        if entry.event_type != "item.status_changed" {
371            continue;
372        }
373        let is_close = entry
374            .details
375            .as_deref()
376            .is_some_and(|d| d.contains("-> closed"));
377        if !is_close {
378            continue;
379        }
380        if let Some(cutoff) = cutoff {
381            if entry.timestamp.as_str() <= cutoff {
382                continue;
383            }
384        }
385        if seen.insert(entry.target.clone()) {
386            results.push(entry.target.clone());
387        }
388    }
389
390    Ok(results)
391}
392
393/// Actor statistics: event count and unique item count.
394pub struct ActorStats {
395    pub id: String,
396    pub events: usize,
397    pub items: usize,
398}
399
400/// Collect actor stats for a specific set of item IDs.
401/// Only counts events whose target matches one of the given item IDs.
402pub fn actors_for_items(root: &Path, item_ids: &[String]) -> Result<Vec<ActorStats>, JoyError> {
403    let id_set: std::collections::HashSet<&str> = item_ids.iter().map(|s| s.as_str()).collect();
404    let events = read_all_events(root)?;
405    let mut event_counts: std::collections::HashMap<String, usize> =
406        std::collections::HashMap::new();
407    let mut item_sets: std::collections::HashMap<String, std::collections::HashSet<String>> =
408        std::collections::HashMap::new();
409
410    for entry in &events {
411        if !id_set.contains(entry.target.as_str()) {
412            continue;
413        }
414        if entry.event_type.starts_with("item.") || entry.event_type.starts_with("comment.") {
415            *event_counts.entry(entry.user.clone()).or_default() += 1;
416            item_sets
417                .entry(entry.user.clone())
418                .or_default()
419                .insert(entry.target.clone());
420        }
421    }
422
423    let mut result: Vec<ActorStats> = event_counts
424        .into_iter()
425        .map(|(id, events)| {
426            let items = item_sets.get(&id).map(|s| s.len()).unwrap_or(0);
427            ActorStats { id, events, items }
428        })
429        .collect();
430    result.sort_by_key(|a| std::cmp::Reverse(a.events));
431    Ok(result)
432}
433
434/// Get git user.email for the current user.
435pub fn get_git_email() -> Result<String, JoyError> {
436    crate::vcs::default_vcs().user_email()
437}
438
439/// Convenience: append an event, loading git email automatically.
440/// Errors are silently ignored to avoid breaking the main command flow.
441pub fn log_event(root: &Path, event_type: EventType, target: &str, details: Option<&str>) {
442    let Ok(user) = get_git_email() else {
443        return;
444    };
445    let event = Event {
446        event_type,
447        target: target.to_string(),
448        details: details.map(|s| s.to_string()),
449        user,
450    };
451    let _ = append_event(root, &event);
452}
453
454/// Like `log_event`, but uses a pre-resolved identity string.
455/// This allows the caller to pass the `Identity::log_user()` value
456/// which may include `delegated-by:` for AI members.
457pub fn log_event_as(
458    root: &Path,
459    event_type: EventType,
460    target: &str,
461    details: Option<&str>,
462    user: &str,
463) {
464    let event = Event {
465        event_type,
466        target: target.to_string(),
467        details: details.map(|s| s.to_string()),
468        user: user.to_string(),
469    };
470    let _ = append_event(root, &event);
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use tempfile::tempdir;
477
478    fn setup_project(dir: &Path) {
479        let log_dir = dir.join(".joy").join("logs");
480        fs::create_dir_all(log_dir).unwrap();
481    }
482
483    #[test]
484    fn append_and_read() {
485        let dir = tempdir().unwrap();
486        setup_project(dir.path());
487
488        let event = Event {
489            event_type: EventType::ItemCreated,
490            target: "JOY-0001".to_string(),
491            details: Some("User login".to_string()),
492            user: "test@example.com".to_string(),
493        };
494        append_event(dir.path(), &event).unwrap();
495
496        let entries = read_events(dir.path(), None, None, 100).unwrap();
497        assert_eq!(entries.len(), 1);
498        assert_eq!(entries[0].event_type, "item.created");
499        assert_eq!(entries[0].target, "JOY-0001");
500        assert_eq!(entries[0].details.as_deref(), Some("User login"));
501        assert_eq!(entries[0].user, "test@example.com");
502    }
503
504    #[test]
505    fn filter_by_item() {
506        let dir = tempdir().unwrap();
507        setup_project(dir.path());
508
509        for (target, details) in [
510            ("JOY-0001", "First"),
511            ("JOY-0002", "Second"),
512            ("JOY-0001", "Update"),
513        ] {
514            let event = Event {
515                event_type: EventType::ItemCreated,
516                target: target.to_string(),
517                details: Some(details.to_string()),
518                user: "test@example.com".to_string(),
519            };
520            append_event(dir.path(), &event).unwrap();
521        }
522
523        let entries = read_events(dir.path(), None, Some("JOY-0001"), 100).unwrap();
524        assert_eq!(entries.len(), 2);
525    }
526
527    #[test]
528    fn parse_line_with_details() {
529        let line =
530            r#"2026-03-11T16:14:32.320Z JOY-0048 item.created "OAuth flow" [horst@joydev.com]"#;
531        let entry = parse_log_line(line).unwrap();
532        assert_eq!(entry.timestamp, "2026-03-11T16:14:32.320Z");
533        assert_eq!(entry.event_type, "item.created");
534        assert_eq!(entry.target, "JOY-0048");
535        assert_eq!(entry.details.as_deref(), Some("OAuth flow"));
536        assert_eq!(entry.user, "horst@joydev.com");
537    }
538
539    #[test]
540    fn parse_line_without_details() {
541        let line = "2026-03-11T16:14:32.320Z JOY-0048 item.status_changed [horst@joydev.com]";
542        let entry = parse_log_line(line).unwrap();
543        assert_eq!(entry.target, "JOY-0048");
544        assert!(entry.details.is_none());
545    }
546
547    #[test]
548    fn event_type_roundtrip() {
549        let et = EventType::ItemStatusChanged;
550        assert_eq!(et.to_string(), "item.status_changed");
551        assert_eq!(EventType::parse("item.status_changed"), Some(et));
552    }
553
554    #[test]
555    fn empty_log_dir() {
556        let dir = tempdir().unwrap();
557        setup_project(dir.path());
558        let entries = read_events(dir.path(), None, None, 100).unwrap();
559        assert!(entries.is_empty());
560    }
561
562    #[test]
563    fn escape_roundtrip() {
564        assert_eq!(escape_details("simple"), "simple");
565        assert_eq!(escape_details("line1\nline2"), "line1\\nline2");
566        assert_eq!(escape_details("back\\slash"), "back\\\\slash");
567        assert_eq!(escape_details("both\nand\\"), "both\\nand\\\\");
568
569        assert_eq!(unescape_details("simple"), "simple");
570        assert_eq!(unescape_details("line1\\nline2"), "line1\nline2");
571        assert_eq!(unescape_details("back\\\\slash"), "back\\slash");
572        assert_eq!(unescape_details("both\\nand\\\\"), "both\nand\\");
573    }
574
575    #[test]
576    fn multiline_details_roundtrip() {
577        let dir = tempdir().unwrap();
578        setup_project(dir.path());
579
580        let multiline = "First line\nSecond line\nThird with \\backslash";
581        let event = Event {
582            event_type: EventType::CommentAdded,
583            target: "JOY-0001".to_string(),
584            details: Some(multiline.to_string()),
585            user: "test@example.com".to_string(),
586        };
587        append_event(dir.path(), &event).unwrap();
588
589        let entries = read_events(dir.path(), None, None, 100).unwrap();
590        assert_eq!(entries.len(), 1);
591        assert_eq!(entries[0].details.as_deref(), Some(multiline));
592    }
593
594    #[test]
595    fn reject_non_timestamp_lines() {
596        assert!(parse_log_line(">").is_none());
597        assert!(parse_log_line("> some text [user@x.com]").is_none());
598        assert!(parse_log_line("Apple Reminders <-- CalDAV --> joyint.com").is_none());
599        assert!(parse_log_line("").is_none());
600        assert!(parse_log_line("   ").is_none());
601    }
602
603    #[test]
604    fn timestamp_validation() {
605        assert!(is_valid_timestamp("2026-03-11T16:14:32.320Z"));
606        assert!(!is_valid_timestamp(">"));
607        assert!(!is_valid_timestamp("not-a-timestamp"));
608        assert!(!is_valid_timestamp("2026"));
609    }
610}