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
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(entry, 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 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
143pub 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 values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
220
221 let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
223 values.insert(TokenKind::Shortdate, shortdate.to_string());
224
225 values.insert(TokenKind::Title, entry.full_title());
227
228 values.insert(TokenKind::Section, entry.section().to_string());
230
231 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 let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
239 values.insert(TokenKind::Odnote, lines.join("\n\t"));
240
241 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 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 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 let tags = entry.tags();
262 if !tags.is_empty() {
263 values.insert(TokenKind::Tags, tags.to_string());
264 }
265
266 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
275fn 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(&sized, wrap_width as usize);
343 }
344 sized
345}
346
347fn 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 format_value {
438 use pretty_assertions::assert_eq;
439
440 use super::super::{TokenKind, format_value};
441
442 #[test]
443 fn it_applies_width_before_wrapping_title() {
444 let raw = "This is a long title that should be truncated first";
445 let result = format_value(raw, TokenKind::Title, Some(20), None, None, None, 80);
446
447 assert_eq!(result, "This is a long title");
448 }
449 }
450
451 mod render {
452 use pretty_assertions::assert_eq;
453
454 use super::*;
455
456 #[test]
457 fn it_expands_date_token() {
458 let entry = sample_entry();
459 let config = sample_config();
460 let options = RenderOptions {
461 template: "%date".into(),
462 ..sample_options()
463 };
464
465 let result = render(&entry, &options, &config);
466
467 assert_eq!(result, "2024-03-17 14:30");
468 }
469
470 #[test]
471 fn it_expands_duration_for_unfinished_entry() {
472 let entry = Entry::new(
473 Local::now() - Duration::hours(2),
474 "test",
475 Tags::new(),
476 Note::new(),
477 "Currently",
478 None::<String>,
479 );
480 let config = sample_config();
481 let options = RenderOptions {
482 template: "%duration".into(),
483 ..sample_options()
484 };
485
486 let result = render(&entry, &options, &config);
487
488 assert!(result.contains("hour"), "expected duration text, got: {result}");
489 }
490
491 #[test]
492 fn it_expands_hr_token() {
493 let entry = sample_entry();
494 let config = sample_config();
495 let options = RenderOptions {
496 template: "%hr".into(),
497 ..sample_options()
498 };
499
500 let result = render(&entry, &options, &config);
501
502 assert_eq!(result, "-".repeat(80));
503 }
504
505 #[test]
506 fn it_expands_interval_token() {
507 let entry = sample_entry();
508 let config = sample_config();
509 let options = RenderOptions {
510 template: "%interval".into(),
511 ..sample_options()
512 };
513
514 let result = render(&entry, &options, &config);
515
516 assert_eq!(result, "00:30:00");
517 }
518
519 #[test]
520 fn it_expands_literal_and_tokens() {
521 let entry = sample_entry();
522 let config = sample_config();
523 let options = RenderOptions {
524 template: "Title: %title (%section)".into(),
525 ..sample_options()
526 };
527
528 let result = render(&entry, &options, &config);
529
530 assert_eq!(
531 result,
532 "Title: Working on project @coding @done(2024-03-17 15:00) (Currently)"
533 );
534 }
535
536 #[test]
537 fn it_expands_newline_and_tab_tokens() {
538 let entry = sample_entry();
539 let config = sample_config();
540 let options = RenderOptions {
541 template: "%title%n%t%section".into(),
542 ..sample_options()
543 };
544
545 let result = render(&entry, &options, &config);
546
547 assert_eq!(
548 result,
549 "Working on project @coding @done(2024-03-17 15:00)\n\tCurrently"
550 );
551 }
552
553 #[test]
554 fn it_expands_note_on_new_line() {
555 let entry = sample_entry();
556 let config = sample_config();
557 let options = RenderOptions {
558 template: "%title%note".into(),
559 ..sample_options()
560 };
561
562 let result = render(&entry, &options, &config);
563
564 assert_eq!(
565 result,
566 "Working on project @coding @done(2024-03-17 15:00)\nSome notes here"
567 );
568 }
569
570 #[test]
571 fn it_expands_note_with_prefix() {
572 let entry = sample_entry();
573 let config = sample_config();
574 let options = RenderOptions {
575 template: "%title%: note".into(),
576 ..sample_options()
577 };
578
579 let result = render(&entry, &options, &config);
580
581 assert_eq!(
582 result,
583 "Working on project @coding @done(2024-03-17 15:00)\n: Some notes here"
584 );
585 }
586
587 #[test]
588 fn it_expands_section_token() {
589 let entry = sample_entry();
590 let config = sample_config();
591 let options = RenderOptions {
592 template: "%section".into(),
593 ..sample_options()
594 };
595
596 let result = render(&entry, &options, &config);
597
598 assert_eq!(result, "Currently");
599 }
600
601 #[test]
602 fn it_expands_tags_token() {
603 let entry = sample_entry();
604 let config = sample_config();
605 let options = RenderOptions {
606 template: "%tags".into(),
607 ..sample_options()
608 };
609
610 let result = render(&entry, &options, &config);
611
612 assert_eq!(result, "@coding @done(2024-03-17 15:00)");
613 }
614
615 #[test]
616 fn it_expands_title_with_width() {
617 let entry = sample_entry();
618 let config = sample_config();
619 let options = RenderOptions {
620 template: "%30title|".into(),
621 ..sample_options()
622 };
623
624 let result = render(&entry, &options, &config);
625
626 assert_eq!(result, "Working on project @coding @do|");
627 }
628
629 #[test]
630 fn it_returns_empty_for_missing_optional_values() {
631 let entry = Entry::new(
632 sample_date(),
633 "test",
634 Tags::from_iter(vec![Tag::new("done", Some("invalid"))]),
635 Note::new(),
636 "Currently",
637 None::<String>,
638 );
639 let config = sample_config();
640 let options = RenderOptions {
641 template: "%interval%note".into(),
642 ..sample_options()
643 };
644
645 let result = render(&entry, &options, &config);
646
647 assert_eq!(result, "");
648 }
649 }
650
651 mod render_options {
652 use pretty_assertions::assert_eq;
653
654 use super::*;
655
656 #[test]
657 fn it_falls_back_to_default_template() {
658 let mut config = sample_config();
659 config.templates.insert(
660 "default".into(),
661 TemplateConfig {
662 date_format: "%Y-%m-%d".into(),
663 template: "%date %title".into(),
664 ..TemplateConfig::default()
665 },
666 );
667
668 let options = RenderOptions::from_config("nonexistent", &config);
669
670 assert_eq!(options.date_format, "%Y-%m-%d");
671 assert_eq!(options.template, "%date %title");
672 }
673
674 #[test]
675 fn it_resolves_named_template() {
676 let mut config = sample_config();
677 config.templates.insert(
678 "today".into(),
679 TemplateConfig {
680 date_format: "%_I:%M%P".into(),
681 template: "%date: %title".into(),
682 order: Some(SortOrder::Asc),
683 wrap_width: 0,
684 ..TemplateConfig::default()
685 },
686 );
687
688 let options = RenderOptions::from_config("today", &config);
689
690 assert_eq!(options.date_format, "%_I:%M%P");
691 assert_eq!(options.template, "%date: %title");
692 }
693
694 #[test]
695 fn it_uses_builtin_defaults_when_no_templates() {
696 let config = sample_config();
697
698 let options = RenderOptions::from_config("anything", &config);
699
700 assert_eq!(options.date_format, "%Y-%m-%d %H:%M");
701 }
702
703 #[test]
704 fn it_uses_full_template_for_default() {
705 let config = sample_config();
706
707 let options = RenderOptions::from_config("default", &config);
708
709 assert!(
710 options.template.contains("\u{2551}"),
711 "default should use \u{2551} separator"
712 );
713 assert!(options.template.contains("interval"), "default should include interval");
714 assert!(options.template.contains("section"), "default should include section");
715 }
716
717 #[test]
718 fn it_uses_full_template_for_today() {
719 let config = sample_config();
720
721 let options = RenderOptions::from_config("today", &config);
722
723 assert!(
724 options.template.contains("\u{2551}"),
725 "today should use \u{2551} separator"
726 );
727 assert!(options.template.contains("interval"), "today should include interval");
728 assert!(options.template.contains("section"), "today should include section");
729 }
730
731 #[test]
732 fn it_uses_simple_template_for_last() {
733 let config = sample_config();
734
735 let options = RenderOptions::from_config("last", &config);
736
737 assert!(
738 options.template.contains("\u{2551}"),
739 "last should use \u{2551} separator"
740 );
741 assert!(
742 !options.template.contains("%interval"),
743 "last should not include interval"
744 );
745 assert!(
746 !options.template.contains("%section"),
747 "last should not include section"
748 );
749 }
750
751 #[test]
752 fn it_uses_simple_template_for_yesterday() {
753 let config = sample_config();
754
755 let options = RenderOptions::from_config("yesterday", &config);
756
757 assert!(
758 options.template.contains("\u{2551}"),
759 "yesterday should use \u{2551} separator"
760 );
761 assert!(
762 !options.template.contains("%interval"),
763 "yesterday should not include interval"
764 );
765 assert!(
766 !options.template.contains("%section"),
767 "yesterday should not include section"
768 );
769 }
770 }
771}