1use std::collections::HashMap;
2
3use doing_config::{Config, TemplateConfig};
4use doing_taskpaper::Entry;
5use doing_time::{DurationFormat, FormattedDuration, FormattedShortdate};
6
7use crate::{
8 colors,
9 parser::{self, Indent, IndentChar, Token, TokenKind},
10 totals::{SectionTotals, TagTotals, TotalsGrouping, TotalsOptions},
11 wrap,
12};
13
14const BUILTIN_TEMPLATE_FULL: &str = "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset %interval %cyan[%10section]%reset%cyan%note%reset";
16
17const BUILTIN_TEMPLATE_SIMPLE: &str =
19 "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset%cyan%note%reset";
20
21#[derive(Clone, Debug)]
23pub struct RenderOptions {
24 pub date_format: String,
25 pub include_notes: bool,
26 pub template: String,
27 pub wrap_width: u32,
28}
29
30impl RenderOptions {
31 pub fn from_config(name: &str, config: &Config) -> Self {
36 let tc = config
37 .templates
38 .get(name)
39 .or_else(|| config.templates.get("default"))
40 .cloned()
41 .unwrap_or_else(|| builtin_template(name));
42 Self::from_template_config(&tc, config.include_notes)
43 }
44
45 pub fn from_template_config(tc: &TemplateConfig, include_notes: bool) -> Self {
47 Self {
48 date_format: tc.date_format.clone(),
49 include_notes,
50 template: tc.template.clone(),
51 wrap_width: tc.wrap_width,
52 }
53 }
54}
55
56pub fn format_items(entries: &[Entry], options: &RenderOptions, config: &Config, show_totals: bool) -> String {
59 format_items_with_tag_sort(
60 entries,
61 options,
62 config,
63 None,
64 TotalsOptions {
65 enabled: show_totals,
66 ..TotalsOptions::default()
67 },
68 )
69}
70
71pub fn format_items_with_tag_sort(
78 entries: &[Entry],
79 options: &RenderOptions,
80 config: &Config,
81 title: Option<&str>,
82 totals: TotalsOptions,
83) -> String {
84 let mut output = String::new();
85 let mut current_section = "";
86 let mut custom_title_shown = false;
87 let tokens = parser::parse(&options.template);
88
89 for entry in entries {
90 if let Some(title_value) = title {
91 if title_value.is_empty() {
92 if entry.section() != current_section {
94 if !output.is_empty() {
95 output.push('\n');
96 }
97 output.push_str(entry.section());
98 output.push_str(":\n");
99 current_section = entry.section();
100 } else if !output.is_empty() {
101 output.push('\n');
102 }
103 } else {
104 if !custom_title_shown {
106 output.push_str(title_value);
107 output.push_str(":\n");
108 custom_title_shown = true;
109 } else if !output.is_empty() {
110 output.push('\n');
111 }
112 }
113 } else if !output.is_empty() {
114 output.push('\n');
115 }
116
117 let mut line = render_with_tokens(entry, &tokens, options, config);
118
119 if entry.tags().iter().any(|t| t.name() == config.marker_tag)
121 && let Some(color) = colors::Color::parse(&config.marker_color)
122 {
123 let ansi = color.to_ansi();
124 if !ansi.is_empty() {
125 let reset = colors::Color::parse("reset").map(|c| c.to_ansi()).unwrap_or_default();
126 line = format!("{ansi}{line}{reset}");
127 }
128 }
129
130 output.push_str(&line);
131 }
132
133 if totals.enabled {
134 let groupings = if totals.groupings.is_empty() {
135 vec![TotalsGrouping::Tags]
136 } else {
137 totals.groupings.clone()
138 };
139
140 for grouping in &groupings {
141 match grouping {
142 TotalsGrouping::Tags => {
143 let tag_totals = TagTotals::from_entries(entries);
144 if !tag_totals.is_empty() {
145 output.push_str(&tag_totals.render_sorted_with_averages(
146 totals.sort_field,
147 totals.sort_order,
148 totals.duration_format,
149 totals.show_averages,
150 ));
151 }
152 }
153 TotalsGrouping::Section => {
154 let section_totals = SectionTotals::from_entries(entries);
155 if !section_totals.is_empty() {
156 output.push_str(§ion_totals.render(totals.duration_format));
157 }
158 }
159 }
160 }
161 }
162
163 output
164}
165
166pub fn render(entry: &Entry, options: &RenderOptions, config: &Config) -> String {
168 let tokens = parser::parse(&options.template);
169 render_with_tokens(entry, &tokens, options, config)
170}
171
172fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, config: &Config) -> String {
174 let values = build_values(entry, options, config);
175 let mut output = String::new();
176
177 for token in tokens {
178 match token {
179 Token::Color(color) => output.push_str(&color.to_ansi()),
180 Token::Literal(text) => output.push_str(text),
181 Token::Placeholder {
182 indent,
183 kind,
184 marker,
185 prefix,
186 width,
187 } => {
188 let raw = values.get(kind).cloned().unwrap_or_default();
189 let formatted = format_value(
190 &raw,
191 *kind,
192 *width,
193 marker.as_ref(),
194 indent.as_ref(),
195 prefix.as_deref(),
196 options.wrap_width,
197 );
198 output.push_str(&formatted);
199 }
200 }
201 }
202
203 output
204}
205
206fn apply_width(raw: &str, width: Option<i32>) -> String {
207 use unicode_width::UnicodeWidthStr;
208
209 match width {
210 Some(w) if w > 0 => {
211 let w = w as usize;
212 let display_width = UnicodeWidthStr::width(raw);
213 if display_width > w {
214 truncate_to_width(raw, w)
215 } else {
216 let padding = w - display_width;
217 format!("{raw}{}", " ".repeat(padding))
218 }
219 }
220 Some(w) if w < 0 => {
221 let w = w.unsigned_abs() as usize;
222 let display_width = UnicodeWidthStr::width(raw);
223 if display_width >= w {
224 raw.to_string()
225 } else {
226 let padding = w - display_width;
227 format!("{}{raw}", " ".repeat(padding))
228 }
229 }
230 _ => raw.to_string(),
231 }
232}
233
234fn build_indent(indent: &Indent) -> String {
235 let ch = match indent.kind {
236 IndentChar::Custom(c) => c,
237 IndentChar::Space => ' ',
238 IndentChar::Tab => '\t',
239 };
240 std::iter::repeat_n(ch, indent.count as usize).collect()
241}
242
243fn build_values(entry: &Entry, options: &RenderOptions, config: &Config) -> HashMap<TokenKind, String> {
244 let mut values = HashMap::new();
245
246 values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
248
249 let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
251 values.insert(TokenKind::Shortdate, shortdate.to_string());
252
253 values.insert(TokenKind::Title, entry.full_title());
255
256 values.insert(TokenKind::Section, entry.section().to_string());
258
259 let note = entry.note();
261 if options.include_notes && !note.is_empty() {
262 values.insert(TokenKind::Note, note.to_line(" "));
263 values.insert(TokenKind::Chompnote, note.to_line(" "));
264
265 let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
267 values.insert(TokenKind::Odnote, lines.join("\n\t"));
268
269 let indented: Vec<String> = note.lines().iter().map(|l| format!("\t\t\t{l}")).collect();
271 values.insert(TokenKind::Idnote, indented.join("\n"));
272 }
273
274 if let Some(interval) = entry.interval() {
276 let fmt = DurationFormat::from_config(&config.interval_format);
277 let formatted = FormattedDuration::new(interval, fmt);
278 values.insert(TokenKind::Interval, formatted.to_string());
279 }
280
281 if let Some(duration) = entry.duration() {
283 let fmt = DurationFormat::from_config(&config.timer_format);
284 let formatted = FormattedDuration::new(duration, fmt);
285 values.insert(TokenKind::Duration, formatted.to_string());
286 }
287
288 let tags = entry.tags();
290 if !tags.is_empty() {
291 values.insert(TokenKind::Tags, tags.to_string());
292 }
293
294 values.insert(TokenKind::Hr, "-".repeat(80));
296 values.insert(TokenKind::HrUnder, "_".repeat(80));
297 values.insert(TokenKind::Newline, "\n".to_string());
298 values.insert(TokenKind::Tab, "\t".to_string());
299
300 values
301}
302
303fn builtin_template(name: &str) -> TemplateConfig {
305 match name {
306 "last" | "yesterday" => TemplateConfig {
307 template: BUILTIN_TEMPLATE_SIMPLE.into(),
308 ..TemplateConfig::default()
309 },
310 _ => TemplateConfig {
311 template: BUILTIN_TEMPLATE_FULL.into(),
312 ..TemplateConfig::default()
313 },
314 }
315}
316
317fn format_note(
318 raw: &str,
319 marker: Option<&char>,
320 indent: Option<&Indent>,
321 prefix: Option<&str>,
322 wrap_width: u32,
323) -> String {
324 let indent_str = indent.map(build_indent).unwrap_or_default();
325 let prefix_str = prefix.unwrap_or("");
326 let marker_str = marker.map(|c| c.to_string()).unwrap_or_default();
327 let continuation_len = marker_str.len() + indent_str.len() + prefix_str.len();
328
329 let mut result = String::from("\n");
330
331 for (i, line) in raw.lines().enumerate() {
332 if i > 0 {
333 result.push('\n');
334 }
335 result.push_str(&marker_str);
336 result.push_str(&indent_str);
337 result.push_str(prefix_str);
338
339 let wrapped = wrap::wrap_with_indent(line, wrap_width as usize, continuation_len);
340 result.push_str(&wrapped);
341 }
342
343 result
344}
345
346fn format_value(
347 raw: &str,
348 kind: TokenKind,
349 width: Option<i32>,
350 marker: Option<&char>,
351 indent: Option<&Indent>,
352 prefix: Option<&str>,
353 wrap_width: u32,
354) -> String {
355 let is_note = matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote);
356
357 if is_note && !raw.is_empty() {
358 return format_note(raw, marker, indent, prefix, wrap_width);
359 }
360
361 if matches!(
362 kind,
363 TokenKind::Newline | TokenKind::Tab | TokenKind::Hr | TokenKind::HrUnder
364 ) {
365 return raw.to_string();
366 }
367
368 let sized = apply_width(raw, width);
369 if matches!(kind, TokenKind::Title) && wrap_width > 0 {
370 return wrap::wrap(&sized, wrap_width as usize);
371 }
372 sized
373}
374
375fn truncate_to_width(s: &str, max_width: usize) -> String {
377 use unicode_width::UnicodeWidthChar;
378
379 let mut result = String::new();
380 let mut current_width = 0;
381 for ch in s.chars() {
382 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
383 if current_width + ch_width > max_width {
384 break;
385 }
386 result.push(ch);
387 current_width += ch_width;
388 }
389 result
390}
391
392#[cfg(test)]
393mod test {
394 use chrono::{Duration, Local, TimeZone};
395 use doing_config::SortOrder;
396 use doing_taskpaper::{Note, Tag, Tags};
397
398 use super::*;
399
400 fn sample_config() -> Config {
401 Config::default()
402 }
403
404 fn sample_date() -> chrono::DateTime<Local> {
405 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
406 }
407
408 fn sample_entry() -> Entry {
409 Entry::new(
410 sample_date(),
411 "Working on project",
412 Tags::from_iter(vec![
413 Tag::new("coding", None::<String>),
414 Tag::new("done", Some("2024-03-17 15:00")),
415 ]),
416 Note::from_text("Some notes here"),
417 "Currently",
418 None::<String>,
419 )
420 }
421
422 fn sample_options() -> RenderOptions {
423 RenderOptions {
424 date_format: "%Y-%m-%d %H:%M".into(),
425 include_notes: true,
426 template: String::new(),
427 wrap_width: 0,
428 }
429 }
430
431 mod apply_width {
432 use pretty_assertions::assert_eq;
433
434 use super::super::apply_width;
435
436 #[test]
437 fn it_pads_short_text_to_positive_width() {
438 let result = apply_width("hi", Some(10));
439
440 assert_eq!(result, "hi ");
441 }
442
443 #[test]
444 fn it_returns_raw_when_no_width() {
445 let result = apply_width("hello", None);
446
447 assert_eq!(result, "hello");
448 }
449
450 #[test]
451 fn it_right_aligns_with_negative_width() {
452 let result = apply_width("hi", Some(-10));
453
454 assert_eq!(result, " hi");
455 }
456
457 #[test]
458 fn it_truncates_long_text_to_positive_width() {
459 let result = apply_width("hello world", Some(5));
460
461 assert_eq!(result, "hello");
462 }
463 }
464
465 mod format_value {
466 use pretty_assertions::assert_eq;
467
468 use super::super::{TokenKind, format_value};
469
470 #[test]
471 fn it_applies_width_before_wrapping_title() {
472 let raw = "This is a long title that should be truncated first";
473 let result = format_value(raw, TokenKind::Title, Some(20), None, None, None, 80);
474
475 assert_eq!(result, "This is a long title");
476 }
477 }
478
479 mod render {
480 use pretty_assertions::assert_eq;
481
482 use super::*;
483
484 #[test]
485 fn it_expands_date_token() {
486 let entry = sample_entry();
487 let config = sample_config();
488 let options = RenderOptions {
489 template: "%date".into(),
490 ..sample_options()
491 };
492
493 let result = render(&entry, &options, &config);
494
495 assert_eq!(result, "2024-03-17 14:30");
496 }
497
498 #[test]
499 fn it_expands_duration_for_unfinished_entry() {
500 let entry = Entry::new(
501 Local::now() - Duration::hours(2),
502 "test",
503 Tags::new(),
504 Note::new(),
505 "Currently",
506 None::<String>,
507 );
508 let config = sample_config();
509 let options = RenderOptions {
510 template: "%duration".into(),
511 ..sample_options()
512 };
513
514 let result = render(&entry, &options, &config);
515
516 assert!(result.contains("hour"), "expected duration text, got: {result}");
517 }
518
519 #[test]
520 fn it_expands_hr_token() {
521 let entry = sample_entry();
522 let config = sample_config();
523 let options = RenderOptions {
524 template: "%hr".into(),
525 ..sample_options()
526 };
527
528 let result = render(&entry, &options, &config);
529
530 assert_eq!(result, "-".repeat(80));
531 }
532
533 #[test]
534 fn it_expands_interval_token() {
535 let entry = sample_entry();
536 let config = sample_config();
537 let options = RenderOptions {
538 template: "%interval".into(),
539 ..sample_options()
540 };
541
542 let result = render(&entry, &options, &config);
543
544 assert_eq!(result, "00:30:00");
545 }
546
547 #[test]
548 fn it_expands_literal_and_tokens() {
549 let entry = sample_entry();
550 let config = sample_config();
551 let options = RenderOptions {
552 template: "Title: %title (%section)".into(),
553 ..sample_options()
554 };
555
556 let result = render(&entry, &options, &config);
557
558 assert_eq!(
559 result,
560 "Title: Working on project @coding @done(2024-03-17 15:00) (Currently)"
561 );
562 }
563
564 #[test]
565 fn it_expands_newline_and_tab_tokens() {
566 let entry = sample_entry();
567 let config = sample_config();
568 let options = RenderOptions {
569 template: "%title%n%t%section".into(),
570 ..sample_options()
571 };
572
573 let result = render(&entry, &options, &config);
574
575 assert_eq!(
576 result,
577 "Working on project @coding @done(2024-03-17 15:00)\n\tCurrently"
578 );
579 }
580
581 #[test]
582 fn it_expands_note_on_new_line() {
583 let entry = sample_entry();
584 let config = sample_config();
585 let options = RenderOptions {
586 template: "%title%note".into(),
587 ..sample_options()
588 };
589
590 let result = render(&entry, &options, &config);
591
592 assert_eq!(
593 result,
594 "Working on project @coding @done(2024-03-17 15:00)\nSome notes here"
595 );
596 }
597
598 #[test]
599 fn it_expands_note_with_prefix() {
600 let entry = sample_entry();
601 let config = sample_config();
602 let options = RenderOptions {
603 template: "%title%: note".into(),
604 ..sample_options()
605 };
606
607 let result = render(&entry, &options, &config);
608
609 assert_eq!(
610 result,
611 "Working on project @coding @done(2024-03-17 15:00)\n: Some notes here"
612 );
613 }
614
615 #[test]
616 fn it_expands_section_token() {
617 let entry = sample_entry();
618 let config = sample_config();
619 let options = RenderOptions {
620 template: "%section".into(),
621 ..sample_options()
622 };
623
624 let result = render(&entry, &options, &config);
625
626 assert_eq!(result, "Currently");
627 }
628
629 #[test]
630 fn it_expands_tags_token() {
631 let entry = sample_entry();
632 let config = sample_config();
633 let options = RenderOptions {
634 template: "%tags".into(),
635 ..sample_options()
636 };
637
638 let result = render(&entry, &options, &config);
639
640 assert_eq!(result, "@coding @done(2024-03-17 15:00)");
641 }
642
643 #[test]
644 fn it_expands_title_with_width() {
645 let entry = sample_entry();
646 let config = sample_config();
647 let options = RenderOptions {
648 template: "%30title|".into(),
649 ..sample_options()
650 };
651
652 let result = render(&entry, &options, &config);
653
654 assert_eq!(result, "Working on project @coding @do|");
655 }
656
657 #[test]
658 fn it_returns_empty_for_missing_optional_values() {
659 let entry = Entry::new(
660 sample_date(),
661 "test",
662 Tags::from_iter(vec![Tag::new("done", Some("invalid"))]),
663 Note::new(),
664 "Currently",
665 None::<String>,
666 );
667 let config = sample_config();
668 let options = RenderOptions {
669 template: "%interval%note".into(),
670 ..sample_options()
671 };
672
673 let result = render(&entry, &options, &config);
674
675 assert_eq!(result, "");
676 }
677 }
678
679 mod render_options {
680 use pretty_assertions::assert_eq;
681
682 use super::*;
683
684 #[test]
685 fn it_falls_back_to_default_template() {
686 let mut config = sample_config();
687 config.templates.insert(
688 "default".into(),
689 TemplateConfig {
690 date_format: "%Y-%m-%d".into(),
691 template: "%date %title".into(),
692 ..TemplateConfig::default()
693 },
694 );
695
696 let options = RenderOptions::from_config("nonexistent", &config);
697
698 assert_eq!(options.date_format, "%Y-%m-%d");
699 assert_eq!(options.template, "%date %title");
700 }
701
702 #[test]
703 fn it_resolves_named_template() {
704 let mut config = sample_config();
705 config.templates.insert(
706 "today".into(),
707 TemplateConfig {
708 date_format: "%_I:%M%P".into(),
709 template: "%date: %title".into(),
710 order: Some(SortOrder::Asc),
711 wrap_width: 0,
712 ..TemplateConfig::default()
713 },
714 );
715
716 let options = RenderOptions::from_config("today", &config);
717
718 assert_eq!(options.date_format, "%_I:%M%P");
719 assert_eq!(options.template, "%date: %title");
720 }
721
722 #[test]
723 fn it_uses_builtin_defaults_when_no_templates() {
724 let config = sample_config();
725
726 let options = RenderOptions::from_config("anything", &config);
727
728 assert_eq!(options.date_format, "%Y-%m-%d %H:%M");
729 }
730
731 #[test]
732 fn it_uses_full_template_for_default() {
733 let config = sample_config();
734
735 let options = RenderOptions::from_config("default", &config);
736
737 assert!(
738 options.template.contains("\u{2551}"),
739 "default should use \u{2551} separator"
740 );
741 assert!(options.template.contains("interval"), "default should include interval");
742 assert!(options.template.contains("section"), "default should include section");
743 }
744
745 #[test]
746 fn it_uses_full_template_for_today() {
747 let config = sample_config();
748
749 let options = RenderOptions::from_config("today", &config);
750
751 assert!(
752 options.template.contains("\u{2551}"),
753 "today should use \u{2551} separator"
754 );
755 assert!(options.template.contains("interval"), "today should include interval");
756 assert!(options.template.contains("section"), "today should include section");
757 }
758
759 #[test]
760 fn it_uses_simple_template_for_last() {
761 let config = sample_config();
762
763 let options = RenderOptions::from_config("last", &config);
764
765 assert!(
766 options.template.contains("\u{2551}"),
767 "last should use \u{2551} separator"
768 );
769 assert!(
770 !options.template.contains("%interval"),
771 "last should not include interval"
772 );
773 assert!(
774 !options.template.contains("%section"),
775 "last should not include section"
776 );
777 }
778
779 #[test]
780 fn it_uses_simple_template_for_yesterday() {
781 let config = sample_config();
782
783 let options = RenderOptions::from_config("yesterday", &config);
784
785 assert!(
786 options.template.contains("\u{2551}"),
787 "yesterday should use \u{2551} separator"
788 );
789 assert!(
790 !options.template.contains("%interval"),
791 "yesterday should not include interval"
792 );
793 assert!(
794 !options.template.contains("%section"),
795 "yesterday should not include section"
796 );
797 }
798 }
799}