Skip to main content

doing_template/
renderer.rs

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
14/// Built-in template: full format with section labels, separator, and interval.
15const BUILTIN_TEMPLATE_FULL: &str = "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset  %interval  %cyan[%10section]%reset%cyan%note%reset";
16
17/// Built-in template: simplified format without section labels or interval.
18const BUILTIN_TEMPLATE_SIMPLE: &str =
19  "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset%cyan%note%reset";
20
21/// Options controlling how an entry is rendered against a template.
22#[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  /// Resolve a named template from the config's `templates` map.
32  ///
33  /// Falls back to the `"default"` template if the name is not found,
34  /// then to built-in defaults if neither exists.
35  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  /// Build render options from a `TemplateConfig`.
46  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
56/// Render a collection of entries, applying colors, wrapping, marker highlighting,
57/// and optionally appending tag totals.
58pub 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
71/// Render a collection of entries with configurable tag totals sorting and optional section titles.
72///
73/// The `title` parameter controls section header rendering:
74/// - `None` — no section headers
75/// - `Some("")` — show the section name as the header (e.g. `"Currently:"`)
76/// - `Some("Custom")` — show a custom title once before all entries
77pub 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        // Show section name as header when section changes
93        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        // Show custom title once before all entries
105        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    // Apply marker color to flagged entries
120    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(&section_totals.render(totals.duration_format));
157          }
158        }
159      }
160    }
161  }
162
163  output
164}
165
166/// Render a single entry against a template string, returning the formatted output.
167pub 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
172/// Render a single entry using pre-parsed template tokens.
173fn 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  // Date
247  values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
248
249  // Shortdate
250  let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
251  values.insert(TokenKind::Shortdate, shortdate.to_string());
252
253  // Title
254  values.insert(TokenKind::Title, entry.full_title());
255
256  // Section
257  values.insert(TokenKind::Section, entry.section().to_string());
258
259  // Note variants
260  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    // Outdented: one less tab than standard
266    let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
267    values.insert(TokenKind::Odnote, lines.join("\n\t"));
268
269    // Indented: one more tab than standard
270    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  // Interval
275  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  // Duration (elapsed time for unfinished entries)
282  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  // Tags
289  let tags = entry.tags();
290  if !tags.is_empty() {
291    values.insert(TokenKind::Tags, tags.to_string());
292  }
293
294  // Special tokens
295  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
303/// Return the built-in template config for a named template.
304fn 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
375/// Truncate a string to fit within `max_width` display columns.
376fn 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}