1use 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 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
122pub 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#[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
189pub 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 log_files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
212
213 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 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
260fn escape_details(s: &str) -> String {
262 s.replace('\\', "\\\\").replace('\n', "\\n")
263}
264
265fn 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
287fn 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
292fn parse_log_line(line: &str) -> Option<LogEntry> {
294 let line = line.trim();
295 if line.is_empty() {
296 return None;
297 }
298
299 if !line.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) {
301 return None;
302 }
303
304 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 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 let parts: Vec<&str> = rest.splitn(3, ' ').collect();
330 if parts.len() < 3 {
331 return None;
332 }
333
334 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
348pub 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 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
383pub 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
393pub 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
425pub struct ActorStats {
427 pub id: String,
428 pub events: usize,
429 pub items: usize,
430}
431
432pub 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
466pub fn get_git_email() -> Result<String, JoyError> {
468 crate::vcs::default_vcs().user_email()
469}
470
471pub 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
486pub 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 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}