• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdheepak / taskwarrior-tui / 18828338815

20 Oct 2025 02:44AM UTC coverage: 10.472%. Remained the same
18828338815

push

github

web-flow
feat: support custom report dataformat like Taskwarrior for `due` datetimes (#636)

To support taskwarrior's`report._.dateformat` option, this PR reads this configuration parameter
and uses it when formatting the `due` task property.

The only slight complexity in implementation is converting from
Taskwarrior's date formatting to `chrono` date format. I want to spend a
little more time cleaning this up, but wanted to post this draft PR to
see if this is an acceptable feature to add.

11 of 51 new or added lines in 2 files covered. (21.57%)

74 existing lines in 1 file now uncovered.

25095 of 239643 relevant lines covered (10.47%)

365.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.48
/src/task_report.rs
1
use std::{error::Error, process::Command};
2

3
use anyhow::Result;
4
use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, TimeZone};
5
use itertools::join;
6
use task_hookrs::{task::Task, uda::UDAValue};
7
use unicode_truncate::UnicodeTruncateStr;
8
use unicode_width::UnicodeWidthStr;
9

10
pub fn format_date_time(dt: NaiveDateTime) -> String {
×
11
  let dt = Local.from_local_datetime(&dt).unwrap();
×
12
  dt.format("%Y-%m-%d %H:%M:%S").to_string()
×
13
}
×
14

NEW
15
pub fn format_date(dt: NaiveDateTime, format: Option<String>) -> String {
×
16
  let offset = Local.offset_from_utc_datetime(&dt);
×
17
  let dt = DateTime::<Local>::from_naive_utc_and_offset(dt, offset);
×
NEW
18
  let format_str = format.unwrap_or("%Y-%m-%d".to_string());
×
NEW
19
  dt.format(&format_str).to_string()
×
20
}
×
21

22
pub fn vague_format_date_time(from_dt: NaiveDateTime, to_dt: NaiveDateTime, with_remainder: bool) -> String {
126✔
23
  let to_dt = Local.from_local_datetime(&to_dt).unwrap();
126✔
24
  let from_dt = Local.from_local_datetime(&from_dt).unwrap();
126✔
25
  let mut seconds = (to_dt - from_dt).num_seconds();
126✔
26
  let minus = if seconds < 0 {
126✔
27
    seconds *= -1;
16✔
28
    "-"
16✔
29
  } else {
30
    ""
110✔
31
  };
32

33
  const YEAR: i64 = 60 * 60 * 24 * 365;
34
  const MONTH: i64 = 60 * 60 * 24 * 30;
35
  const WEEK: i64 = 60 * 60 * 24 * 7;
36
  const DAY: i64 = 60 * 60 * 24;
37
  const HOUR: i64 = 60 * 60;
38
  const MINUTE: i64 = 60;
39

40
  if seconds >= YEAR {
126✔
41
    return if with_remainder {
120✔
UNCOV
42
      format!("{}{}y{}mo", minus, seconds / YEAR, (seconds - YEAR * (seconds / YEAR)) / MONTH)
×
43
    } else {
44
      format!("{}{}y", minus, seconds / YEAR)
120✔
45
    };
46
  }
6✔
47
  if seconds >= MONTH * 3 {
6✔
UNCOV
48
    return if with_remainder {
×
49
      format!("{}{}mo{}w", minus, seconds / MONTH, (seconds - MONTH * (seconds / MONTH)) / WEEK)
×
50
    } else {
UNCOV
51
      format!("{}{}mo", minus, seconds / MONTH)
×
52
    };
53
  }
6✔
54
  if seconds >= WEEK * 2 {
6✔
UNCOV
55
    return if with_remainder {
×
56
      format!("{}{}w{}d", minus, seconds / WEEK, (seconds - WEEK * (seconds / WEEK)) / DAY)
×
57
    } else {
UNCOV
58
      format!("{}{}w", minus, seconds / WEEK)
×
59
    };
60
  }
6✔
61
  if seconds >= DAY {
6✔
UNCOV
62
    return if with_remainder {
×
63
      format!("{}{}d{}h", minus, seconds / DAY, (seconds - DAY * (seconds / DAY)) / HOUR)
×
64
    } else {
UNCOV
65
      format!("{}{}d", minus, seconds / DAY)
×
66
    };
67
  }
6✔
68
  if seconds >= HOUR {
6✔
UNCOV
69
    return if with_remainder {
×
70
      format!("{}{}h{}min", minus, seconds / HOUR, (seconds - HOUR * (seconds / HOUR)) / MINUTE)
×
71
    } else {
UNCOV
72
      format!("{}{}h", minus, seconds / HOUR)
×
73
    };
74
  }
6✔
75
  if seconds >= MINUTE {
6✔
UNCOV
76
    return if with_remainder {
×
77
      format!("{}{}min{}s", minus, seconds / MINUTE, (seconds - MINUTE * (seconds / MINUTE)))
×
78
    } else {
UNCOV
79
      format!("{}{}min", minus, seconds / MINUTE)
×
80
    };
81
  }
6✔
82
  format!("{}{}s", minus, seconds)
6✔
83
}
126✔
84

NEW
85
fn taskwarrior_to_chrono(fmt: &str) -> String {
×
NEW
86
  fmt
×
NEW
87
    .chars()
×
NEW
88
    .map(|c| match c {
×
NEW
89
      'Y' => "%Y".to_string(),  // four-digit year
×
NEW
90
      'y' => "%y".to_string(),  // two-digit year
×
NEW
91
      'M' => "%m".to_string(),  // two-digit month
×
NEW
92
      'm' => "%-m".to_string(), // minimal digit month
×
NEW
93
      'D' => "%d".to_string(),  // two-digit day
×
NEW
94
      'd' => "%-d".to_string(), // minimal-digit day
×
NEW
95
      'A' => "%A".to_string(),  // short name of weekday
×
NEW
96
      'a' => "%a".to_string(),  // long name of weekday
×
NEW
97
      'B' => "%B".to_string(),  // long name of month
×
NEW
98
      'b' => "%b".to_string(),  // short name of month
×
NEW
99
      'V' => "%V".to_string(),  // two-digit week
×
NEW
100
      'v' => "%-V".to_string(), // minimal-digit week
×
NEW
101
      'J' => "%j".to_string(),  // three-digit Julian day (e.g. 023 or 365)
×
NEW
102
      'j' => "%-j".to_string(), // Julian day (e.g. 23 or 365)
×
NEW
103
      'H' => "%H".to_string(),  // two-digit hour
×
NEW
104
      'h' => "%-H".to_string(), // minimal-digit hour
×
NEW
105
      'N' => "%M".to_string(),  // two-digit minutes
×
NEW
106
      'n' => "%-M".to_string(), // minimal-digit minutes
×
NEW
107
      'S' => "%S".to_string(),  // two-digit seconds
×
NEW
108
      's' => "%-S".to_string(), // minimal-digit seconds
×
NEW
109
      'w' => "%u".to_string(),  // week day (e.g. 0 for Monday, 5 for Friday)
×
NEW
110
      other => other.to_string(),
×
NEW
111
    })
×
NEW
112
    .collect()
×
NEW
113
}
×
114

115
pub struct TaskReportTable {
116
  pub labels: Vec<String>,
117
  pub columns: Vec<String>,
118
  pub tasks: Vec<Vec<String>>,
119
  pub virtual_tags: Vec<String>,
120
  pub description_width: usize,
121
  pub date_time_vague_precise: bool,
122
  pub date_format: String,
123
}
124

125
impl TaskReportTable {
126
  pub fn new(data: &str, report: &str) -> Result<Self> {
19✔
127
    let virtual_tags = vec![
19✔
128
      "PROJECT",
129
      "BLOCKED",
19✔
130
      "UNBLOCKED",
19✔
131
      "BLOCKING",
19✔
132
      "DUE",
19✔
133
      "DUETODAY",
19✔
134
      "TODAY",
19✔
135
      "OVERDUE",
19✔
136
      "WEEK",
19✔
137
      "MONTH",
19✔
138
      "QUARTER",
19✔
139
      "YEAR",
19✔
140
      "ACTIVE",
19✔
141
      "SCHEDULED",
19✔
142
      "PARENT",
19✔
143
      "CHILD",
19✔
144
      "UNTIL",
19✔
145
      "WAITING",
19✔
146
      "ANNOTATED",
19✔
147
      "READY",
19✔
148
      "YESTERDAY",
19✔
149
      "TOMORROW",
19✔
150
      "TAGGED",
19✔
151
      "PENDING",
19✔
152
      "COMPLETED",
19✔
153
      "DELETED",
19✔
154
      "UDA",
19✔
155
      "ORPHAN",
19✔
156
      "PRIORITY",
19✔
157
      "PROJECT",
19✔
158
      "LATEST",
19✔
159
      "RECURRING",
19✔
160
      "INSTANCE",
19✔
161
      "TEMPLATE",
19✔
162
    ];
163
    let mut task_report_table = Self {
19✔
164
      labels: vec![],
19✔
165
      columns: vec![],
19✔
166
      tasks: vec![vec![]],
19✔
167
      virtual_tags: virtual_tags.iter().map(ToString::to_string).collect::<Vec<_>>(),
19✔
168
      description_width: 100,
19✔
169
      date_time_vague_precise: false,
19✔
170
      date_format: "%Y-%m-%d".to_string(),
19✔
171
    };
19✔
172
    task_report_table.export_headers(Some(data), report)?;
19✔
173
    Ok(task_report_table)
19✔
174
  }
19✔
175

176
  pub fn export_headers(&mut self, data: Option<&str>, report: &str) -> Result<()> {
64✔
177
    self.columns = vec![];
64✔
178
    self.labels = vec![];
64✔
179

180
    let data = if let Some(s) = data {
64✔
181
      s.to_string()
19✔
182
    } else {
183
      let output = Command::new("task")
45✔
184
        .arg("show")
45✔
185
        .arg("rc.defaultwidth=0")
45✔
186
        .arg(format!("report.{}.columns", report))
45✔
187
        .output()?;
45✔
188
      String::from_utf8_lossy(&output.stdout).into_owned()
45✔
189
    };
190

191
    for line in data.split('\n') {
6,865✔
192
      if line.starts_with(format!("report.{}.columns", report).as_str()) {
6,865✔
193
        let column_names = line.split_once(' ').unwrap().1;
64✔
194
        for column in column_names.split(',') {
896✔
195
          self.columns.push(column.to_string());
896✔
196
        }
896✔
197
      }
6,801✔
198
    }
199

200
    let output = Command::new("task")
64✔
201
      .arg("show")
64✔
202
      .arg("rc.defaultwidth=0")
64✔
203
      .arg(format!("report.{}.labels", report))
64✔
204
      .output()?;
64✔
205
    let data = String::from_utf8_lossy(&output.stdout);
64✔
206

207
    for line in data.split('\n') {
576✔
208
      if line.starts_with(format!("report.{}.labels", report).as_str()) {
576✔
209
        let label_names = line.split_once(' ').unwrap().1;
64✔
210
        for label in label_names.split(',') {
896✔
211
          self.labels.push(label.to_string());
896✔
212
        }
896✔
213
      }
512✔
214
    }
215

216
    let output = Command::new("task")
64✔
217
      .arg("show")
64✔
218
      .arg("rc.defaultwidth=0")
64✔
219
      .arg(format!("report.{}.dateformat", report))
64✔
220
      .output()?;
64✔
221
    let data = String::from_utf8_lossy(&output.stdout);
64✔
222

223
    for line in data.split('\n') {
448✔
224
      if line.starts_with(format!("report.{}.dateformat", report).as_str()) {
448✔
NEW
225
        let taskwarrior_dateformat = line.split_once(' ').unwrap().1;
×
NEW
226
        self.date_format = taskwarrior_to_chrono(taskwarrior_dateformat.trim());
×
227
      }
448✔
228
    }
229

230
    if self.labels.is_empty() {
64✔
UNCOV
231
      for label in &self.columns {
×
UNCOV
232
        let label = label.split('.').collect::<Vec<&str>>()[0];
×
UNCOV
233
        let label = if label == "id" { "ID" } else { label };
×
UNCOV
234
        let mut c = label.chars();
×
UNCOV
235
        let label = match c.next() {
×
UNCOV
236
          None => String::new(),
×
UNCOV
237
          Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
×
238
        };
UNCOV
239
        if !label.is_empty() {
×
UNCOV
240
          self.labels.push(label);
×
UNCOV
241
        }
×
242
      }
243
    }
64✔
244
    let num_labels = self.labels.len();
64✔
245
    let num_columns = self.columns.len();
64✔
246
    assert!(num_labels == num_columns, "Must have the same number of labels (currently {}) and columns (currently {}). Compare their values as shown by \"task show report.{}.\" and fix your taskwarrior config.", num_labels, num_columns, report);
64✔
247

248
    Ok(())
64✔
249
  }
64✔
250

251
  pub fn generate_table(&mut self, tasks: &[Task]) {
5✔
252
    self.tasks = vec![];
5✔
253

254
    // get all tasks as their string representation
255
    for task in tasks {
115✔
256
      if self.columns.is_empty() {
110✔
UNCOV
257
        break;
×
258
      }
110✔
259
      let mut item = vec![];
110✔
260
      for name in &self.columns {
1,650✔
261
        let s = self.get_string_attribute(name, task, tasks);
1,540✔
262
        item.push(s);
1,540✔
263
      }
1,540✔
264
      self.tasks.push(item);
110✔
265
    }
266
  }
5✔
267

268
  pub fn simplify_table(&mut self) -> (Vec<Vec<String>>, Vec<String>) {
5✔
269
    // find which columns are empty
270
    if self.tasks.is_empty() {
5✔
271
      return (vec![], vec![]);
1✔
272
    }
4✔
273

274
    let mut null_columns = vec![0; self.tasks[0].len()];
4✔
275

276
    for task in &self.tasks {
114✔
277
      for (i, s) in task.iter().enumerate() {
1,540✔
278
        null_columns[i] += s.len();
1,540✔
279
      }
1,540✔
280
    }
281

282
    // filter out columns where everything is empty
283
    let mut tasks = vec![];
4✔
284
    for task in &self.tasks {
114✔
285
      let t = task.clone();
110✔
286
      let t: Vec<String> = t
110✔
287
        .iter()
110✔
288
        .enumerate()
110✔
289
        .filter(|&(i, _)| null_columns[i] != 0)
1,540✔
290
        .map(|(_, e)| e.clone())
990✔
291
        .collect();
110✔
292
      tasks.push(t);
110✔
293
    }
294

295
    // filter out header where all columns are empty
296
    let headers: Vec<String> = self
4✔
297
      .labels
4✔
298
      .iter()
4✔
299
      .enumerate()
4✔
300
      .filter(|&(i, _)| null_columns[i] != 0)
56✔
301
      .map(|(_, e)| e.clone())
36✔
302
      .collect();
4✔
303

304
    (tasks, headers)
4✔
305
  }
5✔
306

307
  pub fn get_string_attribute(&self, attribute: &str, task: &Task, tasks: &[Task]) -> String {
1,540✔
308
    match attribute {
1,540✔
309
      "id" => task.id().unwrap_or_default().to_string(),
1,540✔
310
      "scheduled.relative" => match task.scheduled() {
1,430✔
UNCOV
311
        Some(v) => vague_format_date_time(
×
UNCOV
312
          Local::now().naive_utc(),
×
UNCOV
313
          NaiveDateTime::new(v.date(), v.time()),
×
UNCOV
314
          self.date_time_vague_precise,
×
315
        ),
316
        None => "".to_string(),
110✔
317
      },
318
      "scheduled.countdown" => match task.scheduled() {
1,320✔
UNCOV
319
        Some(v) => vague_format_date_time(
×
UNCOV
320
          Local::now().naive_utc(),
×
UNCOV
321
          NaiveDateTime::new(v.date(), v.time()),
×
UNCOV
322
          self.date_time_vague_precise,
×
323
        ),
324
        None => "".to_string(),
110✔
325
      },
326
      "scheduled" => match task.scheduled() {
1,210✔
NEW
327
        Some(v) => format_date(NaiveDateTime::new(v.date(), v.time()), None),
×
UNCOV
328
        None => "".to_string(),
×
329
      },
330
      "due.relative" => match task.due() {
1,210✔
331
        Some(v) => vague_format_date_time(
16✔
332
          Local::now().naive_utc(),
16✔
333
          NaiveDateTime::new(v.date(), v.time()),
16✔
334
          self.date_time_vague_precise,
16✔
335
        ),
336
        None => "".to_string(),
94✔
337
      },
338
      "due" => match task.due() {
1,100✔
NEW
339
        Some(v) => format_date(NaiveDateTime::new(v.date(), v.time()), Some(self.date_format.clone())),
×
UNCOV
340
        None => "".to_string(),
×
341
      },
342
      "until.remaining" => match task.until() {
1,100✔
UNCOV
343
        Some(v) => vague_format_date_time(
×
UNCOV
344
          Local::now().naive_utc(),
×
UNCOV
345
          NaiveDateTime::new(v.date(), v.time()),
×
UNCOV
346
          self.date_time_vague_precise,
×
347
        ),
348
        None => "".to_string(),
110✔
349
      },
350
      "until" => match task.until() {
990✔
NEW
351
        Some(v) => format_date(NaiveDateTime::new(v.date(), v.time()), None),
×
UNCOV
352
        None => "".to_string(),
×
353
      },
354
      "entry.age" => vague_format_date_time(
990✔
355
        NaiveDateTime::new(task.entry().date(), task.entry().time()),
110✔
356
        Local::now().naive_utc(),
110✔
357
        self.date_time_vague_precise,
110✔
358
      ),
359
      "entry" => format_date(NaiveDateTime::new(task.entry().date(), task.entry().time()), None),
880✔
360
      "start.age" => match task.start() {
880✔
361
        Some(v) => vague_format_date_time(
×
UNCOV
362
          NaiveDateTime::new(v.date(), v.time()),
×
UNCOV
363
          Local::now().naive_utc(),
×
UNCOV
364
          self.date_time_vague_precise,
×
365
        ),
366
        None => "".to_string(),
110✔
367
      },
368
      "start" => match task.start() {
770✔
NEW
369
        Some(v) => format_date(NaiveDateTime::new(v.date(), v.time()), None),
×
UNCOV
370
        None => "".to_string(),
×
371
      },
372
      "end.age" => match task.end() {
770✔
UNCOV
373
        Some(v) => vague_format_date_time(
×
374
          NaiveDateTime::new(v.date(), v.time()),
×
375
          Local::now().naive_utc(),
×
UNCOV
376
          self.date_time_vague_precise,
×
377
        ),
UNCOV
378
        None => "".to_string(),
×
379
      },
380
      "end" => match task.end() {
770✔
NEW
381
        Some(v) => format_date(NaiveDateTime::new(v.date(), v.time()), None),
×
UNCOV
382
        None => "".to_string(),
×
383
      },
384
      "status.short" => task.status().to_string().chars().next().unwrap().to_string(),
770✔
385
      "status" => task.status().to_string(),
770✔
386
      "priority" => match task.priority() {
770✔
387
        Some(p) => p.clone(),
8✔
388
        None => "".to_string(),
102✔
389
      },
390
      "project" => match task.project() {
660✔
391
        Some(p) => p.to_string(),
8✔
392
        None => "".to_string(),
102✔
393
      },
394
      "depends.count" => match task.depends() {
550✔
UNCOV
395
        Some(v) => {
×
UNCOV
396
          if v.is_empty() {
×
UNCOV
397
            "".to_string()
×
398
          } else {
399
            format!("{}", v.len())
×
400
          }
401
        }
UNCOV
402
        None => "".to_string(),
×
403
      },
404
      "depends" => match task.depends() {
550✔
405
        Some(v) => {
8✔
406
          if v.is_empty() {
8✔
UNCOV
407
            "".to_string()
×
408
          } else {
409
            let mut dt = vec![];
8✔
410
            for u in v {
20✔
411
              if let Some(t) = tasks.iter().find(|t| t.uuid() == u) {
52✔
412
                dt.push(t.id().unwrap());
12✔
413
              }
12✔
414
            }
415
            join(dt.iter().map(ToString::to_string), " ")
8✔
416
          }
417
        }
418
        None => "".to_string(),
102✔
419
      },
420
      "tags.count" => match task.tags() {
440✔
421
        Some(v) => {
×
422
          let t = v.iter().filter(|t| !self.virtual_tags.contains(t)).count();
×
423
          if t == 0 {
×
UNCOV
424
            "".to_string()
×
425
          } else {
UNCOV
426
            t.to_string()
×
427
          }
428
        }
429
        None => "".to_string(),
×
430
      },
431
      "tags" => match task.tags() {
440✔
432
        Some(v) => v.iter().filter(|t| !self.virtual_tags.contains(t)).cloned().collect::<Vec<_>>().join(","),
474✔
UNCOV
433
        None => "".to_string(),
×
434
      },
435
      "recur" => match task.recur() {
330✔
UNCOV
436
        Some(v) => v.clone(),
×
437
        None => "".to_string(),
110✔
438
      },
439
      "wait" => match task.wait() {
220✔
UNCOV
440
        Some(v) => vague_format_date_time(
×
UNCOV
441
          NaiveDateTime::new(v.date(), v.time()),
×
442
          Local::now().naive_utc(),
×
443
          self.date_time_vague_precise,
×
444
        ),
UNCOV
445
        None => "".to_string(),
×
446
      },
447
      "wait.remaining" => match task.wait() {
220✔
UNCOV
448
        Some(v) => vague_format_date_time(
×
449
          Local::now().naive_utc(),
×
UNCOV
450
          NaiveDateTime::new(v.date(), v.time()),
×
UNCOV
451
          self.date_time_vague_precise,
×
452
        ),
UNCOV
453
        None => "".to_string(),
×
454
      },
455
      "description.count" => {
220✔
UNCOV
456
        let c = if let Some(a) = task.annotations() {
×
UNCOV
457
          format!("[{}]", a.len())
×
458
        } else {
UNCOV
459
          Default::default()
×
460
        };
UNCOV
461
        format!("{} {}", task.description(), c)
×
462
      }
463
      "description.truncated_count" => {
220✔
464
        let c = if let Some(a) = task.annotations() {
110✔
465
          format!("[{}]", a.len())
24✔
466
        } else {
467
          Default::default()
86✔
468
        };
469
        let d = task.description().to_string();
110✔
470
        let mut available_width = self.description_width;
110✔
471
        if self.description_width >= c.len() {
110✔
472
          available_width = self.description_width - c.len();
110✔
473
        }
110✔
474
        let (d, _) = d.unicode_truncate(available_width);
110✔
475
        let mut d = d.to_string();
110✔
476
        if d != *task.description() {
110✔
477
          d = format!("{}\u{2026}", d);
71✔
478
        }
71✔
479
        format!("{}{}", d, c)
110✔
480
      }
481
      "description.truncated" => {
110✔
UNCOV
482
        let d = task.description().to_string();
×
483
        let available_width = self.description_width;
×
UNCOV
484
        let (d, _) = d.unicode_truncate(available_width);
×
UNCOV
485
        let mut d = d.to_string();
×
UNCOV
486
        if d != *task.description() {
×
487
          d = format!("{}\u{2026}", d);
×
488
        }
×
489
        d
×
490
      }
491
      "description.desc" | "description" => task.description().to_string(),
110✔
492
      "urgency" => match &task.urgency() {
110✔
493
        Some(f) => format!("{:.2}", *f),
110✔
UNCOV
494
        None => "0.00".to_string(),
×
495
      },
496
      s => {
×
497
        let u = &task.uda();
×
498
        let v = u.get(s);
×
UNCOV
499
        if v.is_none() {
×
500
          return "".to_string();
×
UNCOV
501
        }
×
UNCOV
502
        match v.unwrap() {
×
503
          UDAValue::Str(s) => s.to_string(),
×
504
          UDAValue::F64(f) => f.to_string(),
×
UNCOV
505
          UDAValue::U64(u) => u.to_string(),
×
506
        }
507
      }
508
    }
509
  }
1,540✔
510
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc