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
14const BUILTIN_TEMPLATE_FULL: &str = "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset %interval %cyan[%10section]%reset%cyan%note%reset";
16
17const BUILTIN_TEMPLATE_SIMPLE: &str =
19 "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset%cyan%note%reset";
20
21#[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 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 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
56pub 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
71pub 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 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 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 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(§ion_totals.render(totals.duration_format));
157 }
158 }
159 }
160 }
161 }
162
163 output
164}
165
166pub 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
172fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, config: &Config) -> String {
174 let values = build_values(entry, options, config);
175
176 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
269fn 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 values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
323
324 let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
326 values.insert(TokenKind::Shortdate, shortdate.to_string());
327
328 values.insert(TokenKind::Title, entry.full_title());
330
331 values.insert(TokenKind::Section, entry.section().to_string());
333
334 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 let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
342 values.insert(TokenKind::Odnote, lines.join("\n\t"));
343
344 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 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 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 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 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
392fn 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
464fn 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 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 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}