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