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