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