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