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 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 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
116pub 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#[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
183pub 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 log_files.sort_by_key(|e| std::cmp::Reverse(e.file_name()));
206
207 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 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
254fn escape_details(s: &str) -> String {
256 s.replace('\\', "\\\\").replace('\n', "\\n")
257}
258
259fn 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
281fn 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
286fn parse_log_line(line: &str) -> Option<LogEntry> {
288 let line = line.trim();
289 if line.is_empty() {
290 return None;
291 }
292
293 if !line.as_bytes().first().is_some_and(|b| b.is_ascii_digit()) {
295 return None;
296 }
297
298 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 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 let parts: Vec<&str> = rest.splitn(3, ' ').collect();
324 if parts.len() < 3 {
325 return None;
326 }
327
328 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
342pub 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 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
377pub 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
387pub 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
419pub struct ActorStats {
421 pub id: String,
422 pub events: usize,
423 pub items: usize,
424}
425
426pub 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
460pub fn get_git_email() -> Result<String, JoyError> {
462 crate::vcs::default_vcs().user_email()
463}
464
465pub 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
480pub 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 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}