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
176  // Pre-compute available stretch width if any token uses stretch.
177  let stretch_width: Option<usize> = if tokens.iter().any(|t| {
178    matches!(
179      t,
180      Token::Placeholder {
181        stretch: true,
182        ..
183      }
184    )
185  }) {
186    let fixed: usize = tokens
187      .iter()
188      .map(|t| match t {
189        Token::Color(_) => 0,
190        Token::Literal(text) => colors::visible_len(text),
191        Token::Placeholder {
192          stretch: true, ..
193        } => 0,
194        Token::Placeholder {
195          indent,
196          kind,
197          marker,
198          prefix,
199          width,
200          ..
201        } => {
202          let raw = values.get(kind).cloned().unwrap_or_default();
203          let s = format_value(
204            &raw,
205            *kind,
206            *width,
207            marker.as_ref(),
208            indent.as_ref(),
209            prefix.as_deref(),
210            0,
211          );
212          colors::visible_len(&s)
213        }
214      })
215      .sum();
216    let term = terminal_width();
217    Some(term.saturating_sub(fixed))
218  } else {
219    None
220  };
221
222  let mut output = String::new();
223
224  for token in tokens {
225    match token {
226      Token::Color(color) => output.push_str(&color.to_ansi()),
227      Token::Literal(text) => output.push_str(text),
228      Token::Placeholder {
229        indent,
230        kind,
231        marker,
232        min_width,
233        prefix,
234        stretch,
235        width,
236      } => {
237        let raw = values.get(kind).cloned().unwrap_or_default();
238        let effective_width = if *stretch {
239          let avail = stretch_width.unwrap_or(0);
240          let min = min_width.map(|m| m as usize).unwrap_or(0);
241          Some(avail.max(min) as i32)
242        } else {
243          *width
244        };
245        let effective_wrap = if *stretch && matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote) {
246          let avail = stretch_width.unwrap_or(0);
247          let min = min_width.map(|m| m as usize).unwrap_or(0);
248          avail.max(min) as u32
249        } else {
250          options.wrap_width
251        };
252        let formatted = format_value(
253          &raw,
254          *kind,
255          effective_width,
256          marker.as_ref(),
257          indent.as_ref(),
258          prefix.as_deref(),
259          effective_wrap,
260        );
261        output.push_str(&formatted);
262      }
263    }
264  }
265
266  output
267}
268
269/// Return the current terminal width in columns.
270///
271/// Checks `COLUMNS` env var first, then falls back to 80.
272fn terminal_width() -> usize {
273  if let Ok(cols) = std::env::var("COLUMNS")
274    && let Ok(n) = cols.parse::<usize>()
275  {
276    return n;
277  }
278  80
279}
280
281fn apply_width(raw: &str, width: Option<i32>) -> String {
282  use unicode_width::UnicodeWidthStr;
283
284  match width {
285    Some(w) if w > 0 => {
286      let w = w as usize;
287      let display_width = UnicodeWidthStr::width(raw);
288      if display_width > w {
289        truncate_to_width(raw, w)
290      } else {
291        let padding = w - display_width;
292        format!("{raw}{}", " ".repeat(padding))
293      }
294    }
295    Some(w) if w < 0 => {
296      let w = w.unsigned_abs() as usize;
297      let display_width = UnicodeWidthStr::width(raw);
298      if display_width >= w {
299        raw.to_string()
300      } else {
301        let padding = w - display_width;
302        format!("{}{raw}", " ".repeat(padding))
303      }
304    }
305    _ => raw.to_string(),
306  }
307}
308
309fn build_indent(indent: &Indent) -> String {
310  let ch = match indent.kind {
311    IndentChar::Custom(c) => c,
312    IndentChar::Space => ' ',
313    IndentChar::Tab => '\t',
314  };
315  std::iter::repeat_n(ch, indent.count as usize).collect()
316}
317
318fn build_values(entry: &Entry, options: &RenderOptions, config: &Config) -> HashMap<TokenKind, String> {
319  let mut values = HashMap::new();
320
321  // Date
322  values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
323
324  // Shortdate
325  let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
326  values.insert(TokenKind::Shortdate, shortdate.to_string());
327
328  // Title
329  values.insert(TokenKind::Title, entry.full_title());
330
331  // Section
332  values.insert(TokenKind::Section, entry.section().to_string());
333
334  // Note variants
335  let note = entry.note();
336  if options.include_notes && !note.is_empty() {
337    values.insert(TokenKind::Note, note.to_line(" "));
338    values.insert(TokenKind::Chompnote, note.to_line(" "));
339
340    // Outdented: one less tab than standard
341    let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
342    values.insert(TokenKind::Odnote, lines.join("\n\t"));
343
344    // Indented: one more tab than standard
345    let indented: Vec<String> = note.lines().iter().map(|l| format!("\t\t\t{l}")).collect();
346    values.insert(TokenKind::Idnote, indented.join("\n"));
347  }
348
349  // Interval
350  if let Some(interval) = entry.interval() {
351    let fmt = DurationFormat::from_config(&config.interval_format);
352    let formatted = FormattedDuration::new(interval, fmt);
353    values.insert(TokenKind::Interval, formatted.to_string());
354  }
355
356  // Duration (elapsed time for unfinished entries)
357  if let Some(duration) = entry.duration() {
358    let fmt = DurationFormat::from_config(&config.timer_format);
359    let formatted = FormattedDuration::new(duration, fmt);
360    values.insert(TokenKind::Duration, formatted.to_string());
361  }
362
363  // Tags
364  let tags = entry.tags();
365  if !tags.is_empty() {
366    let tags_str = tags.to_string();
367    let colored = if let Some(color_name) = &config.tags_color
368      && let Some(color) = colors::Color::parse(color_name)
369    {
370      let ansi = color.to_ansi();
371      let reset = colors::Color::parse("reset").map(|c| c.to_ansi()).unwrap_or_default();
372      if ansi.is_empty() {
373        tags_str
374      } else {
375        format!("{ansi}{tags_str}{reset}")
376      }
377    } else {
378      tags_str
379    };
380    values.insert(TokenKind::Tags, colored);
381  }
382
383  // Special tokens
384  values.insert(TokenKind::Hr, "-".repeat(80));
385  values.insert(TokenKind::HrUnder, "_".repeat(80));
386  values.insert(TokenKind::Newline, "\n".to_string());
387  values.insert(TokenKind::Tab, "\t".to_string());
388
389  values
390}
391
392/// Return the built-in template config for a named template.
393fn builtin_template(name: &str) -> TemplateConfig {
394  match name {
395    "last" | "yesterday" => TemplateConfig {
396      template: BUILTIN_TEMPLATE_SIMPLE.into(),
397      ..TemplateConfig::default()
398    },
399    _ => TemplateConfig {
400      template: BUILTIN_TEMPLATE_FULL.into(),
401      ..TemplateConfig::default()
402    },
403  }
404}
405
406fn format_note(
407  raw: &str,
408  marker: Option<&char>,
409  indent: Option<&Indent>,
410  prefix: Option<&str>,
411  wrap_width: u32,
412) -> String {
413  let indent_str = indent.map(build_indent).unwrap_or_default();
414  let prefix_str = prefix.unwrap_or("");
415  let marker_str = marker.map(|c| c.to_string()).unwrap_or_default();
416  let continuation_len = marker_str.len() + indent_str.len() + prefix_str.len();
417
418  let mut result = String::from("\n");
419
420  for (i, line) in raw.lines().enumerate() {
421    if i > 0 {
422      result.push('\n');
423    }
424    result.push_str(&marker_str);
425    result.push_str(&indent_str);
426    result.push_str(prefix_str);
427
428    let wrapped = wrap::wrap_with_indent(line, wrap_width as usize, continuation_len);
429    result.push_str(&wrapped);
430  }
431
432  result
433}
434
435fn format_value(
436  raw: &str,
437  kind: TokenKind,
438  width: Option<i32>,
439  marker: Option<&char>,
440  indent: Option<&Indent>,
441  prefix: Option<&str>,
442  wrap_width: u32,
443) -> String {
444  let is_note = matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote);
445
446  if is_note && !raw.is_empty() {
447    return format_note(raw, marker, indent, prefix, wrap_width);
448  }
449
450  if matches!(
451    kind,
452    TokenKind::Newline | TokenKind::Tab | TokenKind::Hr | TokenKind::HrUnder
453  ) {
454    return raw.to_string();
455  }
456
457  let sized = apply_width(raw, width);
458  if matches!(kind, TokenKind::Title) && wrap_width > 0 {
459    return wrap::wrap(&sized, wrap_width as usize);
460  }
461  sized
462}
463
464/// Truncate a string to fit within `max_width` display columns.
465fn truncate_to_width(s: &str, max_width: usize) -> String {
466  use unicode_width::UnicodeWidthChar;
467
468  let mut result = String::new();
469  let mut current_width = 0;
470  for ch in s.chars() {
471    let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
472    if current_width + ch_width > max_width {
473      break;
474    }
475    result.push(ch);
476    current_width += ch_width;
477  }
478  result
479}
480
481#[cfg(test)]
482mod test {
483  use chrono::{Duration, Local, TimeZone};
484  use doing_config::SortOrder;
485  use doing_taskpaper::{Note, Tag, Tags};
486
487  use super::*;
488
489  fn sample_config() -> Config {
490    Config::default()
491  }
492
493  fn sample_date() -> chrono::DateTime<Local> {
494    Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
495  }
496
497  fn sample_entry() -> Entry {
498    Entry::new(
499      sample_date(),
500      "Working on project",
501      Tags::from_iter(vec![
502        Tag::new("coding", None::<String>),
503        Tag::new("done", Some("2024-03-17 15:00")),
504      ]),
505      Note::from_text("Some notes here"),
506      "Currently",
507      None::<String>,
508    )
509  }
510
511  fn sample_options() -> RenderOptions {
512    RenderOptions {
513      date_format: "%Y-%m-%d %H:%M".into(),
514      include_notes: true,
515      template: String::new(),
516      wrap_width: 0,
517    }
518  }
519
520  mod apply_width {
521    use pretty_assertions::assert_eq;
522
523    use super::super::apply_width;
524
525    #[test]
526    fn it_pads_short_text_to_positive_width() {
527      let result = apply_width("hi", Some(10));
528
529      assert_eq!(result, "hi        ");
530    }
531
532    #[test]
533    fn it_returns_raw_when_no_width() {
534      let result = apply_width("hello", None);
535
536      assert_eq!(result, "hello");
537    }
538
539    #[test]
540    fn it_right_aligns_with_negative_width() {
541      let result = apply_width("hi", Some(-10));
542
543      assert_eq!(result, "        hi");
544    }
545
546    #[test]
547    fn it_truncates_long_text_to_positive_width() {
548      let result = apply_width("hello world", Some(5));
549
550      assert_eq!(result, "hello");
551    }
552  }
553
554  mod format_value {
555    use pretty_assertions::assert_eq;
556
557    use super::super::{TokenKind, format_value};
558
559    #[test]
560    fn it_applies_width_before_wrapping_title() {
561      let raw = "This is a long title that should be truncated first";
562      let result = format_value(raw, TokenKind::Title, Some(20), None, None, None, 80);
563
564      assert_eq!(result, "This is a long title");
565    }
566  }
567
568  mod render {
569    use pretty_assertions::assert_eq;
570
571    use super::*;
572
573    #[test]
574    fn it_expands_date_token() {
575      let entry = sample_entry();
576      let config = sample_config();
577      let options = RenderOptions {
578        template: "%date".into(),
579        ..sample_options()
580      };
581
582      let result = render(&entry, &options, &config);
583
584      assert_eq!(result, "2024-03-17 14:30");
585    }
586
587    #[test]
588    fn it_expands_duration_for_unfinished_entry() {
589      let entry = Entry::new(
590        Local::now() - Duration::hours(2),
591        "test",
592        Tags::new(),
593        Note::new(),
594        "Currently",
595        None::<String>,
596      );
597      let config = sample_config();
598      let options = RenderOptions {
599        template: "%duration".into(),
600        ..sample_options()
601      };
602
603      let result = render(&entry, &options, &config);
604
605      assert!(result.contains("hour"), "expected duration text, got: {result}");
606    }
607
608    #[test]
609    fn it_expands_hr_token() {
610      let entry = sample_entry();
611      let config = sample_config();
612      let options = RenderOptions {
613        template: "%hr".into(),
614        ..sample_options()
615      };
616
617      let result = render(&entry, &options, &config);
618
619      assert_eq!(result, "-".repeat(80));
620    }
621
622    #[test]
623    fn it_expands_interval_token() {
624      let entry = sample_entry();
625      let config = sample_config();
626      let options = RenderOptions {
627        template: "%interval".into(),
628        ..sample_options()
629      };
630
631      let result = render(&entry, &options, &config);
632
633      assert_eq!(result, "00:30:00");
634    }
635
636    #[test]
637    fn it_expands_literal_and_tokens() {
638      let entry = sample_entry();
639      let config = sample_config();
640      let options = RenderOptions {
641        template: "Title: %title (%section)".into(),
642        ..sample_options()
643      };
644
645      let result = render(&entry, &options, &config);
646
647      assert_eq!(
648        result,
649        "Title: Working on project @coding @done(2024-03-17 15:00) (Currently)"
650      );
651    }
652
653    #[test]
654    fn it_expands_newline_and_tab_tokens() {
655      let entry = sample_entry();
656      let config = sample_config();
657      let options = RenderOptions {
658        template: "%title%n%t%section".into(),
659        ..sample_options()
660      };
661
662      let result = render(&entry, &options, &config);
663
664      assert_eq!(
665        result,
666        "Working on project @coding @done(2024-03-17 15:00)\n\tCurrently"
667      );
668    }
669
670    #[test]
671    fn it_expands_note_on_new_line() {
672      let entry = sample_entry();
673      let config = sample_config();
674      let options = RenderOptions {
675        template: "%title%note".into(),
676        ..sample_options()
677      };
678
679      let result = render(&entry, &options, &config);
680
681      assert_eq!(
682        result,
683        "Working on project @coding @done(2024-03-17 15:00)\nSome notes here"
684      );
685    }
686
687    #[test]
688    fn it_expands_note_with_prefix() {
689      let entry = sample_entry();
690      let config = sample_config();
691      let options = RenderOptions {
692        template: "%title%: note".into(),
693        ..sample_options()
694      };
695
696      let result = render(&entry, &options, &config);
697
698      assert_eq!(
699        result,
700        "Working on project @coding @done(2024-03-17 15:00)\n: Some notes here"
701      );
702    }
703
704    #[test]
705    fn it_expands_section_token() {
706      let entry = sample_entry();
707      let config = sample_config();
708      let options = RenderOptions {
709        template: "%section".into(),
710        ..sample_options()
711      };
712
713      let result = render(&entry, &options, &config);
714
715      assert_eq!(result, "Currently");
716    }
717
718    #[test]
719    fn it_expands_tags_token() {
720      let entry = sample_entry();
721      let config = sample_config();
722      let options = RenderOptions {
723        template: "%tags".into(),
724        ..sample_options()
725      };
726
727      let result = render(&entry, &options, &config);
728
729      assert_eq!(result, "@coding @done(2024-03-17 15:00)");
730    }
731
732    #[test]
733    fn it_expands_title_with_width() {
734      let entry = sample_entry();
735      let config = sample_config();
736      let options = RenderOptions {
737        template: "%30title|".into(),
738        ..sample_options()
739      };
740
741      let result = render(&entry, &options, &config);
742
743      assert_eq!(result, "Working on project @coding @do|");
744    }
745
746    #[test]
747    fn it_returns_empty_for_missing_optional_values() {
748      let entry = Entry::new(
749        sample_date(),
750        "test",
751        Tags::from_iter(vec![Tag::new("done", Some("invalid"))]),
752        Note::new(),
753        "Currently",
754        None::<String>,
755      );
756      let config = sample_config();
757      let options = RenderOptions {
758        template: "%interval%note".into(),
759        ..sample_options()
760      };
761
762      let result = render(&entry, &options, &config);
763
764      assert_eq!(result, "");
765    }
766  }
767
768  mod render_stretch {
769    use pretty_assertions::assert_eq;
770    use temp_env;
771
772    use super::*;
773
774    #[test]
775    fn it_stretches_title_to_columns_env_var() {
776      let entry = Entry::new(
777        sample_date(),
778        "Short",
779        Tags::new(),
780        Note::new(),
781        "Currently",
782        None::<String>,
783      );
784      let config = sample_config();
785      let options = RenderOptions {
786        template: "prefix: %*title :suffix".into(),
787        ..sample_options()
788      };
789
790      let result = temp_env::with_var("COLUMNS", Some("30"), || render(&entry, &options, &config));
791
792      // "prefix: " = 8, " :suffix" = 8, total fixed = 16, stretch width = 14
793      // "Short" (5 chars) padded to 14 = "Short         "
794      assert_eq!(result, "prefix: Short          :suffix");
795    }
796
797    #[test]
798    fn it_respects_min_width_for_stretch() {
799      let entry = Entry::new(
800        sample_date(),
801        "Hi",
802        Tags::new(),
803        Note::new(),
804        "Currently",
805        None::<String>,
806      );
807      let config = sample_config();
808      let options = RenderOptions {
809        template: "%20-*title".into(),
810        ..sample_options()
811      };
812
813      let result = temp_env::with_var("COLUMNS", Some("10"), || render(&entry, &options, &config));
814
815      // terminal=10, fixed=0, available=10, min_width=20 → effective=20
816      assert_eq!(result.len(), 20);
817    }
818  }
819
820  mod render_options {
821    use pretty_assertions::assert_eq;
822
823    use super::*;
824
825    #[test]
826    fn it_falls_back_to_default_template() {
827      let mut config = sample_config();
828      config.templates.insert(
829        "default".into(),
830        TemplateConfig {
831          date_format: "%Y-%m-%d".into(),
832          template: "%date %title".into(),
833          ..TemplateConfig::default()
834        },
835      );
836
837      let options = RenderOptions::from_config("nonexistent", &config);
838
839      assert_eq!(options.date_format, "%Y-%m-%d");
840      assert_eq!(options.template, "%date %title");
841    }
842
843    #[test]
844    fn it_resolves_named_template() {
845      let mut config = sample_config();
846      config.templates.insert(
847        "today".into(),
848        TemplateConfig {
849          date_format: "%_I:%M%P".into(),
850          template: "%date: %title".into(),
851          order: Some(SortOrder::Asc),
852          wrap_width: 0,
853          ..TemplateConfig::default()
854        },
855      );
856
857      let options = RenderOptions::from_config("today", &config);
858
859      assert_eq!(options.date_format, "%_I:%M%P");
860      assert_eq!(options.template, "%date: %title");
861    }
862
863    #[test]
864    fn it_uses_builtin_defaults_when_no_templates() {
865      let config = sample_config();
866
867      let options = RenderOptions::from_config("anything", &config);
868
869      assert_eq!(options.date_format, "%Y-%m-%d %H:%M");
870    }
871
872    #[test]
873    fn it_uses_full_template_for_default() {
874      let config = sample_config();
875
876      let options = RenderOptions::from_config("default", &config);
877
878      assert!(
879        options.template.contains("\u{2551}"),
880        "default should use \u{2551} separator"
881      );
882      assert!(options.template.contains("interval"), "default should include interval");
883      assert!(options.template.contains("section"), "default should include section");
884    }
885
886    #[test]
887    fn it_uses_full_template_for_today() {
888      let config = sample_config();
889
890      let options = RenderOptions::from_config("today", &config);
891
892      assert!(
893        options.template.contains("\u{2551}"),
894        "today should use \u{2551} separator"
895      );
896      assert!(options.template.contains("interval"), "today should include interval");
897      assert!(options.template.contains("section"), "today should include section");
898    }
899
900    #[test]
901    fn it_uses_simple_template_for_last() {
902      let config = sample_config();
903
904      let options = RenderOptions::from_config("last", &config);
905
906      assert!(
907        options.template.contains("\u{2551}"),
908        "last should use \u{2551} separator"
909      );
910      assert!(
911        !options.template.contains("%interval"),
912        "last should not include interval"
913      );
914      assert!(
915        !options.template.contains("%section"),
916        "last should not include section"
917      );
918    }
919
920    #[test]
921    fn it_uses_simple_template_for_yesterday() {
922      let config = sample_config();
923
924      let options = RenderOptions::from_config("yesterday", &config);
925
926      assert!(
927        options.template.contains("\u{2551}"),
928        "yesterday should use \u{2551} separator"
929      );
930      assert!(
931        !options.template.contains("%interval"),
932        "yesterday should not include interval"
933      );
934      assert!(
935        !options.template.contains("%section"),
936        "yesterday should not include section"
937      );
938    }
939  }
940}