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