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 match width {
180 Some(w) if w > 0 => {
181 let w = w as usize;
182 let char_count = raw.chars().count();
183 if char_count > w {
184 raw.chars().take(w).collect()
185 } else {
186 format!("{raw:<w$}")
187 }
188 }
189 Some(w) if w < 0 => {
190 let w = w.unsigned_abs() as usize;
191 format!("{raw:>w$}")
192 }
193 _ => raw.to_string(),
194 }
195}
196
197fn build_indent(indent: &Indent) -> String {
198 let ch = match indent.kind {
199 IndentChar::Custom(c) => c,
200 IndentChar::Space => ' ',
201 IndentChar::Tab => '\t',
202 };
203 std::iter::repeat_n(ch, indent.count as usize).collect()
204}
205
206fn build_values(entry: &Entry, options: &RenderOptions, config: &Config) -> HashMap<TokenKind, String> {
207 let mut values = HashMap::new();
208
209 values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
211
212 let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
214 values.insert(TokenKind::Shortdate, shortdate.to_string());
215
216 values.insert(TokenKind::Title, entry.full_title());
218
219 values.insert(TokenKind::Section, entry.section().to_string());
221
222 let note = entry.note();
224 if options.include_notes && !note.is_empty() {
225 values.insert(TokenKind::Note, note.to_line(" "));
226 values.insert(TokenKind::Chompnote, note.to_line(" "));
227
228 let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
230 values.insert(TokenKind::Odnote, lines.join("\n\t"));
231
232 let indented: Vec<String> = note.lines().iter().map(|l| format!("\t\t\t{l}")).collect();
234 values.insert(TokenKind::Idnote, indented.join("\n"));
235 }
236
237 if let Some(interval) = entry.interval() {
239 let fmt = DurationFormat::from_config(&config.interval_format);
240 let formatted = FormattedDuration::new(interval, fmt);
241 values.insert(TokenKind::Interval, formatted.to_string());
242 }
243
244 if let Some(duration) = entry.duration() {
246 let fmt = DurationFormat::from_config(&config.timer_format);
247 let formatted = FormattedDuration::new(duration, fmt);
248 values.insert(TokenKind::Duration, formatted.to_string());
249 }
250
251 let tags = entry.tags();
253 if !tags.is_empty() {
254 values.insert(TokenKind::Tags, tags.to_string());
255 }
256
257 values.insert(TokenKind::Hr, "-".repeat(80));
259 values.insert(TokenKind::HrUnder, "_".repeat(80));
260 values.insert(TokenKind::Newline, "\n".to_string());
261 values.insert(TokenKind::Tab, "\t".to_string());
262
263 values
264}
265
266fn builtin_template(name: &str) -> TemplateConfig {
268 match name {
269 "last" | "yesterday" => TemplateConfig {
270 template: BUILTIN_TEMPLATE_SIMPLE.into(),
271 ..TemplateConfig::default()
272 },
273 _ => TemplateConfig {
274 template: BUILTIN_TEMPLATE_FULL.into(),
275 ..TemplateConfig::default()
276 },
277 }
278}
279
280fn format_note(
281 raw: &str,
282 marker: Option<&char>,
283 indent: Option<&Indent>,
284 prefix: Option<&str>,
285 wrap_width: u32,
286) -> String {
287 let indent_str = indent.map(build_indent).unwrap_or_default();
288 let prefix_str = prefix.unwrap_or("");
289 let marker_str = marker.map(|c| c.to_string()).unwrap_or_default();
290 let continuation_len = marker_str.len() + indent_str.len() + prefix_str.len();
291
292 let mut result = String::from("\n");
293
294 for (i, line) in raw.lines().enumerate() {
295 if i > 0 {
296 result.push('\n');
297 }
298 result.push_str(&marker_str);
299 result.push_str(&indent_str);
300 result.push_str(prefix_str);
301
302 let wrapped = wrap::wrap_with_indent(line, wrap_width as usize, continuation_len);
303 result.push_str(&wrapped);
304 }
305
306 result
307}
308
309fn format_value(
310 raw: &str,
311 kind: TokenKind,
312 width: Option<i32>,
313 marker: Option<&char>,
314 indent: Option<&Indent>,
315 prefix: Option<&str>,
316 wrap_width: u32,
317) -> String {
318 let is_note = matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote);
319
320 if is_note && !raw.is_empty() {
321 return format_note(raw, marker, indent, prefix, wrap_width);
322 }
323
324 if matches!(
325 kind,
326 TokenKind::Newline | TokenKind::Tab | TokenKind::Hr | TokenKind::HrUnder
327 ) {
328 return raw.to_string();
329 }
330
331 let sized = apply_width(raw, width);
332 if matches!(kind, TokenKind::Title) && wrap_width > 0 {
333 return wrap::wrap(raw, wrap_width as usize);
334 }
335 sized
336}
337
338#[cfg(test)]
339mod test {
340 use chrono::{Duration, Local, TimeZone};
341 use doing_config::SortOrder;
342 use doing_taskpaper::{Note, Tag, Tags};
343
344 use super::*;
345
346 fn sample_config() -> Config {
347 Config::default()
348 }
349
350 fn sample_date() -> chrono::DateTime<Local> {
351 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
352 }
353
354 fn sample_entry() -> Entry {
355 Entry::new(
356 sample_date(),
357 "Working on project",
358 Tags::from_iter(vec![
359 Tag::new("coding", None::<String>),
360 Tag::new("done", Some("2024-03-17 15:00")),
361 ]),
362 Note::from_str("Some notes here"),
363 "Currently",
364 None::<String>,
365 )
366 }
367
368 fn sample_options() -> RenderOptions {
369 RenderOptions {
370 date_format: "%Y-%m-%d %H:%M".into(),
371 include_notes: true,
372 template: String::new(),
373 wrap_width: 0,
374 }
375 }
376
377 mod apply_width {
378 use pretty_assertions::assert_eq;
379
380 use super::super::apply_width;
381
382 #[test]
383 fn it_pads_short_text_to_positive_width() {
384 let result = apply_width("hi", Some(10));
385
386 assert_eq!(result, "hi ");
387 }
388
389 #[test]
390 fn it_returns_raw_when_no_width() {
391 let result = apply_width("hello", None);
392
393 assert_eq!(result, "hello");
394 }
395
396 #[test]
397 fn it_right_aligns_with_negative_width() {
398 let result = apply_width("hi", Some(-10));
399
400 assert_eq!(result, " hi");
401 }
402
403 #[test]
404 fn it_truncates_long_text_to_positive_width() {
405 let result = apply_width("hello world", Some(5));
406
407 assert_eq!(result, "hello");
408 }
409 }
410
411 mod render {
412 use pretty_assertions::assert_eq;
413
414 use super::*;
415
416 #[test]
417 fn it_expands_date_token() {
418 let entry = sample_entry();
419 let config = sample_config();
420 let options = RenderOptions {
421 template: "%date".into(),
422 ..sample_options()
423 };
424
425 let result = render(&entry, &options, &config);
426
427 assert_eq!(result, "2024-03-17 14:30");
428 }
429
430 #[test]
431 fn it_expands_duration_for_unfinished_entry() {
432 let entry = Entry::new(
433 Local::now() - Duration::hours(2),
434 "test",
435 Tags::new(),
436 Note::new(),
437 "Currently",
438 None::<String>,
439 );
440 let config = sample_config();
441 let options = RenderOptions {
442 template: "%duration".into(),
443 ..sample_options()
444 };
445
446 let result = render(&entry, &options, &config);
447
448 assert!(result.contains("hour"), "expected duration text, got: {result}");
449 }
450
451 #[test]
452 fn it_expands_hr_token() {
453 let entry = sample_entry();
454 let config = sample_config();
455 let options = RenderOptions {
456 template: "%hr".into(),
457 ..sample_options()
458 };
459
460 let result = render(&entry, &options, &config);
461
462 assert_eq!(result, "-".repeat(80));
463 }
464
465 #[test]
466 fn it_expands_interval_token() {
467 let entry = sample_entry();
468 let config = sample_config();
469 let options = RenderOptions {
470 template: "%interval".into(),
471 ..sample_options()
472 };
473
474 let result = render(&entry, &options, &config);
475
476 assert_eq!(result, "00:30:00");
477 }
478
479 #[test]
480 fn it_expands_literal_and_tokens() {
481 let entry = sample_entry();
482 let config = sample_config();
483 let options = RenderOptions {
484 template: "Title: %title (%section)".into(),
485 ..sample_options()
486 };
487
488 let result = render(&entry, &options, &config);
489
490 assert_eq!(
491 result,
492 "Title: Working on project @coding @done(2024-03-17 15:00) (Currently)"
493 );
494 }
495
496 #[test]
497 fn it_expands_newline_and_tab_tokens() {
498 let entry = sample_entry();
499 let config = sample_config();
500 let options = RenderOptions {
501 template: "%title%n%t%section".into(),
502 ..sample_options()
503 };
504
505 let result = render(&entry, &options, &config);
506
507 assert_eq!(
508 result,
509 "Working on project @coding @done(2024-03-17 15:00)\n\tCurrently"
510 );
511 }
512
513 #[test]
514 fn it_expands_note_on_new_line() {
515 let entry = sample_entry();
516 let config = sample_config();
517 let options = RenderOptions {
518 template: "%title%note".into(),
519 ..sample_options()
520 };
521
522 let result = render(&entry, &options, &config);
523
524 assert_eq!(
525 result,
526 "Working on project @coding @done(2024-03-17 15:00)\nSome notes here"
527 );
528 }
529
530 #[test]
531 fn it_expands_note_with_prefix() {
532 let entry = sample_entry();
533 let config = sample_config();
534 let options = RenderOptions {
535 template: "%title%: note".into(),
536 ..sample_options()
537 };
538
539 let result = render(&entry, &options, &config);
540
541 assert_eq!(
542 result,
543 "Working on project @coding @done(2024-03-17 15:00)\n: Some notes here"
544 );
545 }
546
547 #[test]
548 fn it_expands_section_token() {
549 let entry = sample_entry();
550 let config = sample_config();
551 let options = RenderOptions {
552 template: "%section".into(),
553 ..sample_options()
554 };
555
556 let result = render(&entry, &options, &config);
557
558 assert_eq!(result, "Currently");
559 }
560
561 #[test]
562 fn it_expands_tags_token() {
563 let entry = sample_entry();
564 let config = sample_config();
565 let options = RenderOptions {
566 template: "%tags".into(),
567 ..sample_options()
568 };
569
570 let result = render(&entry, &options, &config);
571
572 assert_eq!(result, "@coding @done(2024-03-17 15:00)");
573 }
574
575 #[test]
576 fn it_expands_title_with_width() {
577 let entry = sample_entry();
578 let config = sample_config();
579 let options = RenderOptions {
580 template: "%30title|".into(),
581 ..sample_options()
582 };
583
584 let result = render(&entry, &options, &config);
585
586 assert_eq!(result, "Working on project @coding @do|");
587 }
588
589 #[test]
590 fn it_returns_empty_for_missing_optional_values() {
591 let entry = Entry::new(
592 sample_date(),
593 "test",
594 Tags::from_iter(vec![Tag::new("done", Some("invalid"))]),
595 Note::new(),
596 "Currently",
597 None::<String>,
598 );
599 let config = sample_config();
600 let options = RenderOptions {
601 template: "%interval%note".into(),
602 ..sample_options()
603 };
604
605 let result = render(&entry, &options, &config);
606
607 assert_eq!(result, "");
608 }
609 }
610
611 mod render_options {
612 use pretty_assertions::assert_eq;
613
614 use super::*;
615
616 #[test]
617 fn it_falls_back_to_default_template() {
618 let mut config = sample_config();
619 config.templates.insert(
620 "default".into(),
621 TemplateConfig {
622 date_format: "%Y-%m-%d".into(),
623 template: "%date %title".into(),
624 ..TemplateConfig::default()
625 },
626 );
627
628 let options = RenderOptions::from_config("nonexistent", &config);
629
630 assert_eq!(options.date_format, "%Y-%m-%d");
631 assert_eq!(options.template, "%date %title");
632 }
633
634 #[test]
635 fn it_resolves_named_template() {
636 let mut config = sample_config();
637 config.templates.insert(
638 "today".into(),
639 TemplateConfig {
640 date_format: "%_I:%M%P".into(),
641 template: "%date: %title".into(),
642 order: Some(SortOrder::Asc),
643 wrap_width: 0,
644 ..TemplateConfig::default()
645 },
646 );
647
648 let options = RenderOptions::from_config("today", &config);
649
650 assert_eq!(options.date_format, "%_I:%M%P");
651 assert_eq!(options.template, "%date: %title");
652 }
653
654 #[test]
655 fn it_uses_builtin_defaults_when_no_templates() {
656 let config = sample_config();
657
658 let options = RenderOptions::from_config("anything", &config);
659
660 assert_eq!(options.date_format, "%Y-%m-%d %H:%M");
661 }
662
663 #[test]
664 fn it_uses_full_template_for_default() {
665 let config = sample_config();
666
667 let options = RenderOptions::from_config("default", &config);
668
669 assert!(
670 options.template.contains("\u{2551}"),
671 "default should use \u{2551} separator"
672 );
673 assert!(options.template.contains("interval"), "default should include interval");
674 assert!(options.template.contains("section"), "default should include section");
675 }
676
677 #[test]
678 fn it_uses_full_template_for_today() {
679 let config = sample_config();
680
681 let options = RenderOptions::from_config("today", &config);
682
683 assert!(
684 options.template.contains("\u{2551}"),
685 "today should use \u{2551} separator"
686 );
687 assert!(options.template.contains("interval"), "today should include interval");
688 assert!(options.template.contains("section"), "today should include section");
689 }
690
691 #[test]
692 fn it_uses_simple_template_for_last() {
693 let config = sample_config();
694
695 let options = RenderOptions::from_config("last", &config);
696
697 assert!(
698 options.template.contains("\u{2551}"),
699 "last should use \u{2551} separator"
700 );
701 assert!(
702 !options.template.contains("%interval"),
703 "last should not include interval"
704 );
705 assert!(
706 !options.template.contains("%section"),
707 "last should not include section"
708 );
709 }
710
711 #[test]
712 fn it_uses_simple_template_for_yesterday() {
713 let config = sample_config();
714
715 let options = RenderOptions::from_config("yesterday", &config);
716
717 assert!(
718 options.template.contains("\u{2551}"),
719 "yesterday should use \u{2551} separator"
720 );
721 assert!(
722 !options.template.contains("%interval"),
723 "yesterday should not include interval"
724 );
725 assert!(
726 !options.template.contains("%section"),
727 "yesterday should not include section"
728 );
729 }
730 }
731}