1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone};
4
5use crate::{Note, Tags};
6
7#[derive(Clone, Debug)]
12pub struct Entry {
13 date: DateTime<Local>,
14 id: String,
15 note: Note,
16 section: String,
17 tags: Tags,
18 title: String,
19}
20
21impl Entry {
22 pub fn new(
26 date: DateTime<Local>,
27 title: impl Into<String>,
28 tags: Tags,
29 note: Note,
30 section: impl Into<String>,
31 id: Option<impl Into<String>>,
32 ) -> Self {
33 let title = title.into();
34 let section = section.into();
35 let id = match id {
36 Some(id) => id.into(),
37 None => gen_id(&date, &title, §ion),
38 };
39 Self {
40 date,
41 id,
42 note,
43 section,
44 tags,
45 title,
46 }
47 }
48
49 pub fn date(&self) -> DateTime<Local> {
51 self.date
52 }
53
54 pub fn done_date(&self) -> Option<DateTime<Local>> {
56 let value = self.tag_value("done")?;
57 parse_tag_date(value)
58 }
59
60 pub fn duration(&self) -> Option<Duration> {
64 if self.finished() {
65 return None;
66 }
67 Some(Local::now().signed_duration_since(self.date))
68 }
69
70 pub fn end_date(&self) -> Option<DateTime<Local>> {
72 self.done_date()
73 }
74
75 pub fn finished(&self) -> bool {
77 self.tags.has("done")
78 }
79
80 pub fn full_title(&self) -> String {
82 if self.tags.is_empty() {
83 self.title.clone()
84 } else {
85 format!("{} {}", self.title, self.tags)
86 }
87 }
88
89 pub fn id(&self) -> &str {
91 &self.id
92 }
93
94 pub fn interval(&self) -> Option<Duration> {
98 let done = self.done_date()?;
99 Some(done.signed_duration_since(self.date))
100 }
101
102 pub fn note(&self) -> &Note {
104 &self.note
105 }
106
107 pub fn note_mut(&mut self) -> &mut Note {
109 &mut self.note
110 }
111
112 pub fn overlapping_time(&self, other: &Entry) -> bool {
117 let now = Local::now();
118 let start_a = self.date;
119 let end_a = self.end_date().unwrap_or(now);
120 let start_b = other.date;
121 let end_b = other.end_date().unwrap_or(now);
122 start_a < end_b && start_b < end_a
123 }
124
125 pub fn section(&self) -> &str {
127 &self.section
128 }
129
130 pub fn set_date(&mut self, date: DateTime<Local>) {
132 self.date = date;
133 }
134
135 pub fn set_title(&mut self, title: impl Into<String>) {
137 self.title = title.into();
138 }
139
140 pub fn should_finish(&self, never_finish: &[String]) -> bool {
145 no_patterns_match(never_finish, &self.tags, &self.section)
146 }
147
148 pub fn should_time(&self, never_time: &[String]) -> bool {
153 no_patterns_match(never_time, &self.tags, &self.section)
154 }
155
156 pub fn tags(&self) -> &Tags {
158 &self.tags
159 }
160
161 pub fn tags_mut(&mut self) -> &mut Tags {
163 &mut self.tags
164 }
165
166 pub fn title(&self) -> &str {
168 &self.title
169 }
170
171 pub fn unfinished(&self) -> bool {
173 !self.finished()
174 }
175
176 fn tag_value(&self, name: &str) -> Option<&str> {
178 self
179 .tags
180 .iter()
181 .find(|t| t.name().eq_ignore_ascii_case(name))
182 .and_then(|t| t.value())
183 }
184}
185
186impl Display for Entry {
187 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
189 write!(f, "{}", self.title)?;
190 if !self.tags.is_empty() {
191 write!(f, " {}", self.tags)?;
192 }
193 write!(f, " <{}>", self.id)
194 }
195}
196
197fn gen_id(date: &DateTime<Local>, title: &str, section: &str) -> String {
199 let content = format!("{}{}{}", date.format("%Y-%m-%d %H:%M"), title, section);
200 format!("{:x}", md5::compute(content.as_bytes()))
201}
202
203fn no_patterns_match(patterns: &[String], tags: &Tags, section: &str) -> bool {
209 for pattern in patterns {
210 if let Some(tag_name) = pattern.strip_prefix('@') {
211 if tags.has(tag_name) {
212 return false;
213 }
214 } else if section.eq_ignore_ascii_case(pattern) {
215 return false;
216 }
217 }
218 true
219}
220
221fn parse_tag_date(value: &str) -> Option<DateTime<Local>> {
223 let naive = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M").ok()?;
224 Local.from_local_datetime(&naive).single()
225}
226
227#[cfg(test)]
228mod test {
229 use chrono::TimeZone;
230
231 use super::*;
232 use crate::Tag;
233
234 fn sample_date() -> DateTime<Local> {
235 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
236 }
237
238 fn sample_entry() -> Entry {
239 Entry::new(
240 sample_date(),
241 "Working on project",
242 Tags::from_iter(vec![
243 Tag::new("coding", None::<String>),
244 Tag::new("done", Some("2024-03-17 15:00")),
245 ]),
246 Note::from_text("Some notes here"),
247 "Currently",
248 None::<String>,
249 )
250 }
251
252 mod display {
253 use pretty_assertions::assert_eq;
254
255 use super::*;
256
257 #[test]
258 fn it_formats_title_with_tags_and_id() {
259 let entry = sample_entry();
260
261 let result = entry.to_string();
262
263 assert!(result.starts_with("Working on project @coding @done(2024-03-17 15:00) <"));
264 assert!(result.ends_with(">"));
265 assert_eq!(
266 result.len(),
267 "Working on project @coding @done(2024-03-17 15:00) <".len() + 32 + ">".len()
268 );
269 }
270
271 #[test]
272 fn it_formats_title_without_tags() {
273 let entry = Entry::new(
274 sample_date(),
275 "Just a title",
276 Tags::new(),
277 Note::new(),
278 "Currently",
279 None::<String>,
280 );
281
282 let result = entry.to_string();
283
284 assert!(result.starts_with("Just a title <"));
285 assert!(result.ends_with(">"));
286 assert_eq!(result.len(), "Just a title <".len() + 32 + ">".len());
287 }
288 }
289
290 mod done_date {
291 use pretty_assertions::assert_eq;
292
293 use super::*;
294
295 #[test]
296 fn it_returns_parsed_done_date() {
297 let entry = sample_entry();
298
299 let done = entry.done_date().unwrap();
300
301 assert_eq!(done, Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap());
302 }
303
304 #[test]
305 fn it_returns_none_when_no_done_tag() {
306 let entry = Entry::new(
307 sample_date(),
308 "test",
309 Tags::new(),
310 Note::new(),
311 "Currently",
312 None::<String>,
313 );
314
315 assert!(entry.done_date().is_none());
316 }
317
318 #[test]
319 fn it_returns_none_when_done_tag_has_no_value() {
320 let entry = Entry::new(
321 sample_date(),
322 "test",
323 Tags::from_iter(vec![Tag::new("done", None::<String>)]),
324 Note::new(),
325 "Currently",
326 None::<String>,
327 );
328
329 assert!(entry.done_date().is_none());
330 }
331 }
332
333 mod duration {
334 use super::*;
335
336 #[test]
337 fn it_returns_none_for_finished_entry() {
338 let entry = sample_entry();
339
340 assert!(entry.duration().is_none());
341 }
342
343 #[test]
344 fn it_returns_some_for_unfinished_entry() {
345 let entry = Entry::new(
346 Local::now() - Duration::hours(2),
347 "test",
348 Tags::new(),
349 Note::new(),
350 "Currently",
351 None::<String>,
352 );
353
354 let dur = entry.duration().unwrap();
355
356 assert!(dur.num_minutes() >= 119);
357 }
358 }
359
360 mod finished {
361 use super::*;
362
363 #[test]
364 fn it_returns_true_when_done_tag_present() {
365 let entry = sample_entry();
366
367 assert!(entry.finished());
368 }
369
370 #[test]
371 fn it_returns_false_when_no_done_tag() {
372 let entry = Entry::new(
373 sample_date(),
374 "test",
375 Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
376 Note::new(),
377 "Currently",
378 None::<String>,
379 );
380
381 assert!(!entry.finished());
382 }
383 }
384
385 mod full_title {
386 use pretty_assertions::assert_eq;
387
388 use super::*;
389
390 #[test]
391 fn it_includes_tags_in_title() {
392 let entry = sample_entry();
393
394 assert_eq!(entry.full_title(), "Working on project @coding @done(2024-03-17 15:00)");
395 }
396
397 #[test]
398 fn it_returns_plain_title_when_no_tags() {
399 let entry = Entry::new(
400 sample_date(),
401 "Just a title",
402 Tags::new(),
403 Note::new(),
404 "Currently",
405 None::<String>,
406 );
407
408 assert_eq!(entry.full_title(), "Just a title");
409 }
410 }
411
412 mod gen_id {
413 use pretty_assertions::assert_eq;
414
415 use super::*;
416
417 #[test]
418 fn it_generates_32_char_hex_string() {
419 let id = super::super::gen_id(&sample_date(), "test", "Currently");
420
421 assert_eq!(id.len(), 32);
422 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
423 }
424
425 #[test]
426 fn it_is_deterministic() {
427 let id1 = super::super::gen_id(&sample_date(), "test", "Currently");
428 let id2 = super::super::gen_id(&sample_date(), "test", "Currently");
429
430 assert_eq!(id1, id2);
431 }
432
433 #[test]
434 fn it_differs_for_different_content() {
435 let id1 = super::super::gen_id(&sample_date(), "task one", "Currently");
436 let id2 = super::super::gen_id(&sample_date(), "task two", "Currently");
437
438 assert_ne!(id1, id2);
439 }
440 }
441
442 mod interval {
443 use pretty_assertions::assert_eq;
444
445 use super::*;
446
447 #[test]
448 fn it_returns_duration_between_start_and_done() {
449 let entry = sample_entry();
450
451 let iv = entry.interval().unwrap();
452
453 assert_eq!(iv.num_minutes(), 30);
454 }
455
456 #[test]
457 fn it_returns_none_when_not_finished() {
458 let entry = Entry::new(
459 sample_date(),
460 "test",
461 Tags::new(),
462 Note::new(),
463 "Currently",
464 None::<String>,
465 );
466
467 assert!(entry.interval().is_none());
468 }
469 }
470
471 mod new {
472 use pretty_assertions::assert_eq;
473
474 use super::*;
475
476 #[test]
477 fn it_generates_id_when_none_provided() {
478 let entry = Entry::new(
479 sample_date(),
480 "test",
481 Tags::new(),
482 Note::new(),
483 "Currently",
484 None::<String>,
485 );
486
487 assert_eq!(entry.id().len(), 32);
488 assert!(entry.id().chars().all(|c| c.is_ascii_hexdigit()));
489 }
490
491 #[test]
492 fn it_uses_provided_id() {
493 let entry = Entry::new(
494 sample_date(),
495 "test",
496 Tags::new(),
497 Note::new(),
498 "Currently",
499 Some("abcdef01234567890abcdef012345678"),
500 );
501
502 assert_eq!(entry.id(), "abcdef01234567890abcdef012345678");
503 }
504 }
505
506 mod overlapping_time {
507 use super::*;
508
509 #[test]
510 fn it_detects_overlapping_entries() {
511 let a = Entry::new(
512 Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
513 "task a",
514 Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
515 Note::new(),
516 "Currently",
517 None::<String>,
518 );
519 let b = Entry::new(
520 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
521 "task b",
522 Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:30"))]),
523 Note::new(),
524 "Currently",
525 None::<String>,
526 );
527
528 assert!(a.overlapping_time(&b));
529 assert!(b.overlapping_time(&a));
530 }
531
532 #[test]
533 fn it_returns_false_for_non_overlapping_entries() {
534 let a = Entry::new(
535 Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
536 "task a",
537 Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
538 Note::new(),
539 "Currently",
540 None::<String>,
541 );
542 let b = Entry::new(
543 Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap(),
544 "task b",
545 Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 16:00"))]),
546 Note::new(),
547 "Currently",
548 None::<String>,
549 );
550
551 assert!(!a.overlapping_time(&b));
552 }
553 }
554
555 mod should_finish {
556 use super::*;
557
558 #[test]
559 fn it_returns_true_when_no_patterns_match() {
560 let entry = sample_entry();
561
562 assert!(entry.should_finish(&[]));
563 }
564
565 #[test]
566 fn it_returns_false_when_tag_pattern_matches() {
567 let entry = sample_entry();
568
569 assert!(!entry.should_finish(&["@coding".to_string()]));
570 }
571
572 #[test]
573 fn it_returns_false_when_section_pattern_matches() {
574 let entry = sample_entry();
575
576 assert!(!entry.should_finish(&["Currently".to_string()]));
577 }
578
579 #[test]
580 fn it_matches_section_case_insensitively() {
581 let entry = sample_entry();
582
583 assert!(!entry.should_finish(&["currently".to_string()]));
584 }
585 }
586
587 mod should_time {
588 use super::*;
589
590 #[test]
591 fn it_returns_true_when_no_patterns_match() {
592 let entry = sample_entry();
593
594 assert!(entry.should_time(&[]));
595 }
596
597 #[test]
598 fn it_returns_false_when_tag_pattern_matches() {
599 let entry = sample_entry();
600
601 assert!(!entry.should_time(&["@coding".to_string()]));
602 }
603 }
604
605 mod unfinished {
606 use super::*;
607
608 #[test]
609 fn it_returns_true_when_no_done_tag() {
610 let entry = Entry::new(
611 sample_date(),
612 "test",
613 Tags::new(),
614 Note::new(),
615 "Currently",
616 None::<String>,
617 );
618
619 assert!(entry.unfinished());
620 }
621
622 #[test]
623 fn it_returns_false_when_done_tag_present() {
624 let entry = sample_entry();
625
626 assert!(!entry.unfinished());
627 }
628 }
629}