tiempo 1.6.0

A command line time tracker
Documentation
#![allow(clippy::type_complexity)]
use std::borrow::Cow;

use ansi_term::Style;
use regex::Regex;

lazy_static! {
    // https://en.wikipedia.org/wiki/ANSI_escape_code#DOS,_OS/2,_and_Windows
    //
    // For Control Sequence Introducer, or CSI, commands, the ESC [ is followed
    // by any number (including none) of "parameter bytes" in the range
    // 0x30–0x3F (ASCII 0–9:;<=>?), then by any number of "intermediate bytes"
    // in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), then finally
    // by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~)
    //
    // The lazy regex bellow doesn't cover all of that. It just works on ansi
    // colors.
    pub static ref ANSI_REGEX: Regex = Regex::new("\x1b\\[[\\d;]*m").unwrap();
}

/// An abstract way of getting the visual size of a string in a terminal
pub trait VisualSize {
    fn size(&self) -> usize;
}

impl VisualSize for &str {
    fn size(&self) -> usize {
        let s = ANSI_REGEX.replace_all(self, "");

        s.chars().count()
    }
}

impl VisualSize for String {
    fn size(&self) -> usize {
        self.as_str().size()
    }
}

fn lpad(s: &str, len: usize) -> String {
    let padding = " ".repeat(len.saturating_sub(s.size()));

    padding + s
}

fn rpad(s: &str, len: usize) -> String {
    let padding = " ".repeat(len.saturating_sub(s.size()));

    s.to_string() + &padding
}

fn constrained_lines(text: &str, width: usize) -> Vec<Cow<'_, str>> {
    textwrap::wrap(text, width)
}

#[derive(Copy, Clone)]
pub enum Align {
    Left,
    Right,
}

use Align::*;

#[derive(Clone)]
pub struct Col {
    min_width: usize,
    max_width: Option<usize>,
    align: Align,
    conditonal_styles: Vec<(Style, fn(&str) -> bool)>,
}

impl Col {
    pub fn new() -> Col {
        Col {
            min_width: 0,
            align: Align::Left,
            max_width: None,
            conditonal_styles: Vec::new(),
        }
    }

    pub fn min_width(self, size: usize) -> Col {
        Col {
            min_width: size,
            ..self
        }
    }

    pub fn and_alignment(self, align: Align) -> Col {
        Col {
            align,
            ..self
        }
    }

    pub fn max_width(self, size: usize) -> Col {
        Col {
            max_width: Some(size),
            ..self
        }
    }

    pub fn color_if(self, style: Style, f: fn(&str) -> bool) -> Col {
        let mut conditonal_styles = self.conditonal_styles;

        conditonal_styles.push((style, f));

        Col {
            conditonal_styles,
            ..self
        }
    }
}

impl Default for Col {
    fn default() -> Col {
        Col::new()
    }
}

enum DataOrSep {
    Data(Vec<String>),
    Sep(char),
}

pub struct Tabulate {
    cols: Vec<Col>,
    widths: Vec<usize>,
    data: Vec<DataOrSep>,
}

impl Tabulate {
    pub fn with_columns(cols: Vec<Col>) -> Tabulate {
        Tabulate {
            widths: cols.iter().map(|c| c.min_width).collect(),
            cols,
            data: Vec::new(),
        }
    }

    pub fn feed<T: AsRef<str>>(&mut self, data: Vec<T>) {
        let mut lines: Vec<Vec<String>> = Vec::new();

        for (col, ((w, d), c)) in self.widths.iter_mut().zip(data.iter()).zip(self.cols.iter()).enumerate() {
            for (r1, dl) in d.as_ref().split('\n').enumerate() {
                for (r2, l) in constrained_lines(dl, c.max_width.unwrap_or(usize::MAX)).into_iter().enumerate() {
                    let width = l.as_ref().size();

                    if width > *w {
                        *w = width;
                    }

                    if let Some(line) = lines.get_mut(r1 + r2) {
                        if let Some(pos) = line.get_mut(col) {
                            *pos = l.into();
                        } else {
                            line.push(l.into());
                        }
                    } else {
                        lines.push({
                            let mut prev: Vec<_> = if (r1 + r2) == 0 {
                                data[..col].iter().map(|s| s.as_ref().to_string()).collect()
                            } else {
                                (0..col).map(|_| "".into()).collect()
                            };

                            prev.push(l.into());

                            prev
                        });
                    }
                }
            }
        }

        for line in lines {
            self.data.push(DataOrSep::Data(line));
        }
    }

    pub fn separator(&mut self, c: char) {
        self.data.push(DataOrSep::Sep(c));
    }

    pub fn print(self, color: bool) -> String {
        let widths = self.widths;
        let cols = self.cols;

        self.data.into_iter().map(|row| match row {
            DataOrSep::Sep(c) => {
                if c == ' ' {
                    "\n".into()
                } else {
                    c.to_string().repeat(widths.iter().sum::<usize>() + widths.len() -1) + "\n"
                }
            },
            DataOrSep::Data(d) => {
                d.into_iter().zip(widths.iter()).zip(cols.iter()).map(|((d, &w), c)| {
                    let style = c.conditonal_styles.iter().find(|(_s, f)| {
                        f(&d)
                    }).map(|(s, _f)| s);

                    let s = match c.align {
                        Left => rpad(&d, w),
                        Right => lpad(&d, w),
                    };

                    if let Some(style) = style {
                        if color {
                            style.paint(s).to_string()
                        } else {
                            s
                        }
                    } else {
                        s
                    }
                }).collect::<Vec<_>>().join(" ").trim_end().to_string() + "\n"
            },
        }).collect::<Vec<_>>().join("")
    }
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;
    use ansi_term::Color::Fixed;

    use super::*;

    const LONG_NOTE: &str = "chatting with bob about upcoming task, district sharing of images, how the user settings currently works etc. Discussing the fingerprinting / cache busting issue with CKEDITOR, suggesting perhaps looking into forking the rubygem and seeing if we can work in our own changes, however hard that might be.";

    #[test]
    fn test_constrained_lines_long_text() {
        assert_eq!(constrained_lines(LONG_NOTE, 46), vec![
            "chatting with bob about upcoming task,",
            "district sharing of images, how the user",
            "settings currently works etc. Discussing the",
            "fingerprinting / cache busting issue with",
            "CKEDITOR, suggesting perhaps looking into",
            "forking the rubygem and seeing if we can work",
            "in our own changes, however hard that might",
            "be.",
        ]);
    }

    #[test]
    fn test_constrained_lines_nowrap() {
        assert_eq!(constrained_lines(LONG_NOTE, LONG_NOTE.len()), vec![
            LONG_NOTE,
        ]);
    }

    #[test]
    fn test_text_output() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).and_alignment(Left),
        ]);

        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["Fri Oct 03, 2008", "12:00:00 - 14:00:00", "2:00:00", "entry 1"]);
        tabs.feed(vec!["", "16:00:00 - 18:00:00",    "2:00:00", "entry 2"]);
        tabs.feed(vec!["", "", "4:00:00", ""]);
        tabs.feed(vec!["Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]);
        tabs.feed(vec!["", "18:00:00 - ", "2:00:00", "entry 4"]);
        tabs.feed(vec!["", "", "4:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["Total", "", "8:00:00", ""]);

        assert_eq!(&tabs.print(false), "\
Day                Start      End        Duration Notes
Fri Oct 03, 2008   12:00:00 - 14:00:00    2:00:00 entry 1
                   16:00:00 - 18:00:00    2:00:00 entry 2
                                          4:00:00
Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 entry 3
                   18:00:00 -             2:00:00 entry 4
                                          4:00:00
---------------------------------------------------------
Total                                     8:00:00
");
    }

    #[test]
    fn test_text_output_long_duration() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).and_alignment(Left),
        ]);

        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["Wed Oct 01, 2008",   "12:00:00 - 14:00:00+2d", "50:00:00", "entry 1"]);
        tabs.feed(vec!["", "", "50:00:00", ""]);
        tabs.feed(vec!["Fri Oct 03, 2008",   "12:00:00 - 14:00:00",     "2:00:00", "entry 2"]);
        tabs.feed(vec!["", "", "2:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["Total",                                     "", "52:00:00", ""]);

        assert_eq!(&tabs.print(false), "\
Day                Start      End         Duration Notes
Wed Oct 01, 2008   12:00:00 - 14:00:00+2d 50:00:00 entry 1
                                          50:00:00
Fri Oct 03, 2008   12:00:00 - 14:00:00     2:00:00 entry 2
                                           2:00:00
----------------------------------------------------------
Total                                     52:00:00
");
    }

    #[test]
    fn test_text_output_with_ids() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width(3).and_alignment(Right),
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).and_alignment(Left),
        ]);

        tabs.feed(vec!["ID", "Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["1", "Fri Oct 03, 2008",   "12:00:00 - 14:00:00",    "2:00:00", "entry 1"]);
        tabs.feed(vec!["2", "", "16:00:00 - 18:00:00", "2:00:00", "entry 2"]);
        tabs.feed(vec!["", "", "", "4:00:00", ""]);
        tabs.feed(vec!["3", "Sun Oct 05, 2008", "16:00:00 - 18:00:00", "2:00:00", "entry 3"]);
        tabs.feed(vec!["4", "", "18:00:00 -", "2:00:00", "entry 4"]);
        tabs.feed(vec!["", "", "", "4:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["", "Total", "", "8:00:00"]);

        assert_eq!(&tabs.print(false), " ID Day                Start      End        Duration Notes
  1 Fri Oct 03, 2008   12:00:00 - 14:00:00    2:00:00 entry 1
  2                    16:00:00 - 18:00:00    2:00:00 entry 2
                                              4:00:00
  3 Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 entry 3
  4                    18:00:00 -             2:00:00 entry 4
                                              4:00:00
-------------------------------------------------------------
    Total                                     8:00:00
");
    }

    #[test]
    fn test_text_output_long_note_with_ids() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width(2).and_alignment(Right),
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).max_width(44).and_alignment(Left),
        ]);

        tabs.feed(vec!["ID", "Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["60000", "Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", LONG_NOTE]);
        tabs.feed(vec!["", "", "", "2:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["", "Total", "", "2:00:00"]);

        assert_eq!(&tabs.print(false), "   ID Day                Start      End        Duration Notes
60000 Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 chatting with bob about upcoming task,
                                                        district sharing of images, how the user
                                                        settings currently works etc. Discussing the
                                                        fingerprinting / cache busting issue with
                                                        CKEDITOR, suggesting perhaps looking into
                                                        forking the rubygem and seeing if we can
                                                        work in our own changes, however hard that
                                                        might be.
                                                2:00:00
----------------------------------------------------------------------------------------------------
      Total                                     2:00:00
");
    }

    #[test]
    fn test_text_output_note_with_line_breaks() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).and_alignment(Left),
        ]);

        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", "first line\nand a second line"]);
        tabs.feed(vec!["", "", "2:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["Total", "", "2:00:00", ""]);

        assert_eq!(&tabs.print(false), "\
Day                Start      End        Duration Notes
Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 first line
                                                  and a second line
                                          2:00:00
-------------------------------------------------------------------
Total                                     2:00:00
");
    }

    #[test]
    fn note_with_accents() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().min_width("Fri Oct 03, 2008  ".len()).and_alignment(Left),
            Col::new().min_width("12:00:00 - 14:00:00  ".len()).and_alignment(Left),
            Col::new().min_width("Duration".len()).and_alignment(Right),
            Col::new().min_width("Notes".len()).and_alignment(Left),
        ]);

        tabs.feed(vec!["Day", "Start      End", "Duration", "Notes"]);
        tabs.feed(vec!["Sun Oct 05, 2008",   "16:00:00 - 18:00:00",    "2:00:00", "quiúbole"]);
        tabs.feed(vec!["", "", "2:00:00", ""]);
        tabs.separator('-');
        tabs.feed(vec!["Total", "", "2:00:00", ""]);

        assert_eq!(&tabs.print(false), "\
Day                Start      End        Duration Notes
Sun Oct 05, 2008   16:00:00 - 18:00:00    2:00:00 quiúbole
                                          2:00:00
----------------------------------------------------------
Total                                     2:00:00
");
    }

    #[test]
    fn tabulate_a_blank_row() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new()
        ]);

        tabs.feed(vec!["Hola"]);
        tabs.separator(' ');
        tabs.feed(vec!["adiós"]);
        tabs.separator('-');
        tabs.feed(vec!["ta güeno"]);

        assert_eq!(&tabs.print(false), "\
Hola

adiós
--------
ta güeno
");
    }

    #[test]
    fn add_a_color_condition() {
        let mut tabs = Tabulate::with_columns(vec![
            Col::new().color_if(Style::new().dimmed(), |val| {
                val == "key"
            }),
            Col::new(),
        ]);

        tabs.feed(vec!["foo", "key"]);
        tabs.feed(vec!["key", "foo"]);

        assert_eq!(tabs.print(true), format!("\
foo key
{} foo
", Style::new().dimmed().paint("key")));
    }

    #[test]
    fn sizes_of_things() {
        assert_eq!("🥦".size(), 1);
        assert_eq!("á".size(), 1);
        assert_eq!(Fixed(10).paint("hola").to_string().size(), 4);
    }
}