use std::collections::HashMap;
use doing_config::{Config, TemplateConfig};
use doing_taskpaper::Entry;
use doing_time::{DurationFormat, FormattedDuration, FormattedShortdate};
use crate::{
colors,
parser::{self, Indent, IndentChar, Token, TokenKind},
totals::{SectionTotals, TagTotals, TotalsGrouping, TotalsOptions},
wrap,
};
const BUILTIN_TEMPLATE_FULL: &str = "%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset %interval %cyan[%10section]%reset%cyan%note%reset";
const BUILTIN_TEMPLATE_SIMPLE: &str =
"%boldwhite%-10shortdate %boldcyan\u{2551} %boldwhite%title%reset%cyan%note%reset";
#[derive(Clone, Debug)]
pub struct RenderOptions {
pub date_format: String,
pub include_notes: bool,
pub template: String,
pub wrap_width: u32,
}
impl RenderOptions {
pub fn from_config(name: &str, config: &Config) -> Self {
let tc = config
.templates
.get(name)
.or_else(|| config.templates.get("default"))
.cloned()
.unwrap_or_else(|| builtin_template(name));
Self::from_template_config(&tc, config.include_notes)
}
pub fn from_template_config(tc: &TemplateConfig, include_notes: bool) -> Self {
Self {
date_format: tc.date_format.clone(),
include_notes,
template: tc.template.clone(),
wrap_width: tc.wrap_width,
}
}
}
pub fn format_items(entries: &[Entry], options: &RenderOptions, config: &Config, show_totals: bool) -> String {
format_items_with_tag_sort(
entries,
options,
config,
None,
TotalsOptions {
enabled: show_totals,
..TotalsOptions::default()
},
)
}
pub fn format_items_with_tag_sort(
entries: &[Entry],
options: &RenderOptions,
config: &Config,
title: Option<&str>,
totals: TotalsOptions,
) -> String {
let mut output = String::new();
let mut current_section = "";
let mut custom_title_shown = false;
let tokens = parser::parse(&options.template);
for entry in entries {
if let Some(title_value) = title {
if title_value.is_empty() {
if entry.section() != current_section {
if !output.is_empty() {
output.push('\n');
}
output.push_str(entry.section());
output.push_str(":\n");
current_section = entry.section();
} else if !output.is_empty() {
output.push('\n');
}
} else {
if !custom_title_shown {
output.push_str(title_value);
output.push_str(":\n");
custom_title_shown = true;
} else if !output.is_empty() {
output.push('\n');
}
}
} else if !output.is_empty() {
output.push('\n');
}
let mut line = render_with_tokens(entry, &tokens, options, config);
if entry.tags().iter().any(|t| t.name() == config.marker_tag)
&& let Some(color) = colors::Color::parse(&config.marker_color)
{
let ansi = color.to_ansi();
if !ansi.is_empty() {
let reset = colors::Color::parse("reset").map(|c| c.to_ansi()).unwrap_or_default();
line = format!("{ansi}{line}{reset}");
}
}
output.push_str(&line);
}
if totals.enabled {
let groupings = if totals.groupings.is_empty() {
vec![TotalsGrouping::Tags]
} else {
totals.groupings.clone()
};
for grouping in &groupings {
match grouping {
TotalsGrouping::Tags => {
let tag_totals = TagTotals::from_entries(entries);
if !tag_totals.is_empty() {
output.push_str(&tag_totals.render_sorted_with_averages(
totals.sort_field,
totals.sort_order,
totals.duration_format,
totals.show_averages,
));
}
}
TotalsGrouping::Section => {
let section_totals = SectionTotals::from_entries(entries);
if !section_totals.is_empty() {
output.push_str(§ion_totals.render(totals.duration_format));
}
}
}
}
}
output
}
pub fn render(entry: &Entry, options: &RenderOptions, config: &Config) -> String {
let tokens = parser::parse(&options.template);
render_with_tokens(entry, &tokens, options, config)
}
fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, config: &Config) -> String {
let values = build_values(entry, options, config);
let mut output = String::new();
for token in tokens {
match token {
Token::Color(color) => output.push_str(&color.to_ansi()),
Token::Literal(text) => output.push_str(text),
Token::Placeholder {
indent,
kind,
marker,
prefix,
width,
} => {
let raw = values.get(kind).cloned().unwrap_or_default();
let formatted = format_value(
&raw,
*kind,
*width,
marker.as_ref(),
indent.as_ref(),
prefix.as_deref(),
options.wrap_width,
);
output.push_str(&formatted);
}
}
}
output
}
fn apply_width(raw: &str, width: Option<i32>) -> String {
use unicode_width::UnicodeWidthStr;
match width {
Some(w) if w > 0 => {
let w = w as usize;
let display_width = UnicodeWidthStr::width(raw);
if display_width > w {
truncate_to_width(raw, w)
} else {
let padding = w - display_width;
format!("{raw}{}", " ".repeat(padding))
}
}
Some(w) if w < 0 => {
let w = w.unsigned_abs() as usize;
let display_width = UnicodeWidthStr::width(raw);
if display_width >= w {
raw.to_string()
} else {
let padding = w - display_width;
format!("{}{raw}", " ".repeat(padding))
}
}
_ => raw.to_string(),
}
}
fn build_indent(indent: &Indent) -> String {
let ch = match indent.kind {
IndentChar::Custom(c) => c,
IndentChar::Space => ' ',
IndentChar::Tab => '\t',
};
std::iter::repeat_n(ch, indent.count as usize).collect()
}
fn build_values(entry: &Entry, options: &RenderOptions, config: &Config) -> HashMap<TokenKind, String> {
let mut values = HashMap::new();
values.insert(TokenKind::Date, entry.date().format(&options.date_format).to_string());
let shortdate = FormattedShortdate::new(entry.date(), &config.shortdate_format);
values.insert(TokenKind::Shortdate, shortdate.to_string());
values.insert(TokenKind::Title, entry.full_title());
values.insert(TokenKind::Section, entry.section().to_string());
let note = entry.note();
if options.include_notes && !note.is_empty() {
values.insert(TokenKind::Note, note.to_line(" "));
values.insert(TokenKind::Chompnote, note.to_line(" "));
let lines: Vec<&str> = note.lines().iter().map(|l| l.as_str()).collect();
values.insert(TokenKind::Odnote, lines.join("\n\t"));
let indented: Vec<String> = note.lines().iter().map(|l| format!("\t\t\t{l}")).collect();
values.insert(TokenKind::Idnote, indented.join("\n"));
}
if let Some(interval) = entry.interval() {
let fmt = DurationFormat::from_config(&config.interval_format);
let formatted = FormattedDuration::new(interval, fmt);
values.insert(TokenKind::Interval, formatted.to_string());
}
if let Some(duration) = entry.duration() {
let fmt = DurationFormat::from_config(&config.timer_format);
let formatted = FormattedDuration::new(duration, fmt);
values.insert(TokenKind::Duration, formatted.to_string());
}
let tags = entry.tags();
if !tags.is_empty() {
values.insert(TokenKind::Tags, tags.to_string());
}
values.insert(TokenKind::Hr, "-".repeat(80));
values.insert(TokenKind::HrUnder, "_".repeat(80));
values.insert(TokenKind::Newline, "\n".to_string());
values.insert(TokenKind::Tab, "\t".to_string());
values
}
fn builtin_template(name: &str) -> TemplateConfig {
match name {
"last" | "yesterday" => TemplateConfig {
template: BUILTIN_TEMPLATE_SIMPLE.into(),
..TemplateConfig::default()
},
_ => TemplateConfig {
template: BUILTIN_TEMPLATE_FULL.into(),
..TemplateConfig::default()
},
}
}
fn format_note(
raw: &str,
marker: Option<&char>,
indent: Option<&Indent>,
prefix: Option<&str>,
wrap_width: u32,
) -> String {
let indent_str = indent.map(build_indent).unwrap_or_default();
let prefix_str = prefix.unwrap_or("");
let marker_str = marker.map(|c| c.to_string()).unwrap_or_default();
let continuation_len = marker_str.len() + indent_str.len() + prefix_str.len();
let mut result = String::from("\n");
for (i, line) in raw.lines().enumerate() {
if i > 0 {
result.push('\n');
}
result.push_str(&marker_str);
result.push_str(&indent_str);
result.push_str(prefix_str);
let wrapped = wrap::wrap_with_indent(line, wrap_width as usize, continuation_len);
result.push_str(&wrapped);
}
result
}
fn format_value(
raw: &str,
kind: TokenKind,
width: Option<i32>,
marker: Option<&char>,
indent: Option<&Indent>,
prefix: Option<&str>,
wrap_width: u32,
) -> String {
let is_note = matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote);
if is_note && !raw.is_empty() {
return format_note(raw, marker, indent, prefix, wrap_width);
}
if matches!(
kind,
TokenKind::Newline | TokenKind::Tab | TokenKind::Hr | TokenKind::HrUnder
) {
return raw.to_string();
}
let sized = apply_width(raw, width);
if matches!(kind, TokenKind::Title) && wrap_width > 0 {
return wrap::wrap(&sized, wrap_width as usize);
}
sized
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
use unicode_width::UnicodeWidthChar;
let mut result = String::new();
let mut current_width = 0;
for ch in s.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > max_width {
break;
}
result.push(ch);
current_width += ch_width;
}
result
}
#[cfg(test)]
mod test {
use chrono::{Duration, Local, TimeZone};
use doing_config::SortOrder;
use doing_taskpaper::{Note, Tag, Tags};
use super::*;
fn sample_config() -> Config {
Config::default()
}
fn sample_date() -> chrono::DateTime<Local> {
Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
}
fn sample_entry() -> Entry {
Entry::new(
sample_date(),
"Working on project",
Tags::from_iter(vec![
Tag::new("coding", None::<String>),
Tag::new("done", Some("2024-03-17 15:00")),
]),
Note::from_text("Some notes here"),
"Currently",
None::<String>,
)
}
fn sample_options() -> RenderOptions {
RenderOptions {
date_format: "%Y-%m-%d %H:%M".into(),
include_notes: true,
template: String::new(),
wrap_width: 0,
}
}
mod apply_width {
use pretty_assertions::assert_eq;
use super::super::apply_width;
#[test]
fn it_pads_short_text_to_positive_width() {
let result = apply_width("hi", Some(10));
assert_eq!(result, "hi ");
}
#[test]
fn it_returns_raw_when_no_width() {
let result = apply_width("hello", None);
assert_eq!(result, "hello");
}
#[test]
fn it_right_aligns_with_negative_width() {
let result = apply_width("hi", Some(-10));
assert_eq!(result, " hi");
}
#[test]
fn it_truncates_long_text_to_positive_width() {
let result = apply_width("hello world", Some(5));
assert_eq!(result, "hello");
}
}
mod format_value {
use pretty_assertions::assert_eq;
use super::super::{TokenKind, format_value};
#[test]
fn it_applies_width_before_wrapping_title() {
let raw = "This is a long title that should be truncated first";
let result = format_value(raw, TokenKind::Title, Some(20), None, None, None, 80);
assert_eq!(result, "This is a long title");
}
}
mod render {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_expands_date_token() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%date".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "2024-03-17 14:30");
}
#[test]
fn it_expands_duration_for_unfinished_entry() {
let entry = Entry::new(
Local::now() - Duration::hours(2),
"test",
Tags::new(),
Note::new(),
"Currently",
None::<String>,
);
let config = sample_config();
let options = RenderOptions {
template: "%duration".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert!(result.contains("hour"), "expected duration text, got: {result}");
}
#[test]
fn it_expands_hr_token() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%hr".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "-".repeat(80));
}
#[test]
fn it_expands_interval_token() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%interval".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "00:30:00");
}
#[test]
fn it_expands_literal_and_tokens() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "Title: %title (%section)".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(
result,
"Title: Working on project @coding @done(2024-03-17 15:00) (Currently)"
);
}
#[test]
fn it_expands_newline_and_tab_tokens() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%title%n%t%section".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(
result,
"Working on project @coding @done(2024-03-17 15:00)\n\tCurrently"
);
}
#[test]
fn it_expands_note_on_new_line() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%title%note".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(
result,
"Working on project @coding @done(2024-03-17 15:00)\nSome notes here"
);
}
#[test]
fn it_expands_note_with_prefix() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%title%: note".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(
result,
"Working on project @coding @done(2024-03-17 15:00)\n: Some notes here"
);
}
#[test]
fn it_expands_section_token() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%section".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "Currently");
}
#[test]
fn it_expands_tags_token() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%tags".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "@coding @done(2024-03-17 15:00)");
}
#[test]
fn it_expands_title_with_width() {
let entry = sample_entry();
let config = sample_config();
let options = RenderOptions {
template: "%30title|".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "Working on project @coding @do|");
}
#[test]
fn it_returns_empty_for_missing_optional_values() {
let entry = Entry::new(
sample_date(),
"test",
Tags::from_iter(vec![Tag::new("done", Some("invalid"))]),
Note::new(),
"Currently",
None::<String>,
);
let config = sample_config();
let options = RenderOptions {
template: "%interval%note".into(),
..sample_options()
};
let result = render(&entry, &options, &config);
assert_eq!(result, "");
}
}
mod render_options {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn it_falls_back_to_default_template() {
let mut config = sample_config();
config.templates.insert(
"default".into(),
TemplateConfig {
date_format: "%Y-%m-%d".into(),
template: "%date %title".into(),
..TemplateConfig::default()
},
);
let options = RenderOptions::from_config("nonexistent", &config);
assert_eq!(options.date_format, "%Y-%m-%d");
assert_eq!(options.template, "%date %title");
}
#[test]
fn it_resolves_named_template() {
let mut config = sample_config();
config.templates.insert(
"today".into(),
TemplateConfig {
date_format: "%_I:%M%P".into(),
template: "%date: %title".into(),
order: Some(SortOrder::Asc),
wrap_width: 0,
..TemplateConfig::default()
},
);
let options = RenderOptions::from_config("today", &config);
assert_eq!(options.date_format, "%_I:%M%P");
assert_eq!(options.template, "%date: %title");
}
#[test]
fn it_uses_builtin_defaults_when_no_templates() {
let config = sample_config();
let options = RenderOptions::from_config("anything", &config);
assert_eq!(options.date_format, "%Y-%m-%d %H:%M");
}
#[test]
fn it_uses_full_template_for_default() {
let config = sample_config();
let options = RenderOptions::from_config("default", &config);
assert!(
options.template.contains("\u{2551}"),
"default should use \u{2551} separator"
);
assert!(options.template.contains("interval"), "default should include interval");
assert!(options.template.contains("section"), "default should include section");
}
#[test]
fn it_uses_full_template_for_today() {
let config = sample_config();
let options = RenderOptions::from_config("today", &config);
assert!(
options.template.contains("\u{2551}"),
"today should use \u{2551} separator"
);
assert!(options.template.contains("interval"), "today should include interval");
assert!(options.template.contains("section"), "today should include section");
}
#[test]
fn it_uses_simple_template_for_last() {
let config = sample_config();
let options = RenderOptions::from_config("last", &config);
assert!(
options.template.contains("\u{2551}"),
"last should use \u{2551} separator"
);
assert!(
!options.template.contains("%interval"),
"last should not include interval"
);
assert!(
!options.template.contains("%section"),
"last should not include section"
);
}
#[test]
fn it_uses_simple_template_for_yesterday() {
let config = sample_config();
let options = RenderOptions::from_config("yesterday", &config);
assert!(
options.template.contains("\u{2551}"),
"yesterday should use \u{2551} separator"
);
assert!(
!options.template.contains("%interval"),
"yesterday should not include interval"
);
assert!(
!options.template.contains("%section"),
"yesterday should not include section"
);
}
}
}