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