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