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