Ver Fonte

Merge branch 'time-styles-properly'

This merges in the ability to use different time styles, such as full ISO-formatted timestamps instead of just using the default variable style.

Firstly, this moved the Environment from the Table to the Columns, so it 1) would only be instantiated when a table is actually used, and 2) can be affected by command-line options.

Next, it renames Columns to table::Options, in line with what the view optionses were renamed to.

Finally, it adds support for more time styles, deferring timestamp formatting to an enum.

Fixes #133.
Benjamin Sago há 8 anos atrás
pai
commit
690aa21ac8

+ 2 - 0
README.md

@@ -51,10 +51,12 @@ These options are available when running with --long (`-l`):
 - **-U**, **--created**: use the created timestamp field
 - **-@**, **--extended**: list each file's extended attributes and sizes
 - **--git**: list each file's Git status, if tracked
+- **--time-style**: how to format timestamps
 
 - Valid **--color** options are **always**, **automatic**, and **never**.
 - Valid sort fields are **accessed**, **created**, **extension**, **Extension**, **inode**, **modified**, **name**, **Name**, **size**, **type**, and **none**. Fields starting with a capital letter are case-sensitive.
 - Valid time fields are **modified**, **accessed**, and **created**.
+- Valid time styles are **default**, **iso**, **long-iso**, and **full-iso**.
 
 
 ## Installation

+ 2 - 0
Vagrantfile

@@ -320,6 +320,8 @@ Vagrant.configure(2) do |config|
       touch -t #{old} -a "#{test_dir}/dates/plum"
       touch -t #{med} -a "#{test_dir}/dates/pear"
       touch -t #{new} -a "#{test_dir}/dates/peach"
+
+      sudo chown #{user}:#{user} -R "#{test_dir}/dates"
     EOF
 
 

+ 5 - 0
contrib/completions.bash

@@ -22,6 +22,11 @@ _exa()
             COMPREPLY=( $( compgen -W 'accessed modified created --' -- $cur ) )
             return
             ;;
+
+        --time-style)
+            COMPREPLY=( $( compgen -W 'default iso long-iso full-iso --' -- $cur ) )
+            return
+            ;;
     esac
 
     case "$cur" in

+ 8 - 2
contrib/completions.fish

@@ -55,8 +55,14 @@ complete -c exa -s 't' -l 'time'  -x -d "Which timestamp field to list" -a "
     created\t'Display created time'
     modified\t'Display modified time'
 "
-complete -c exa -s 'u' -l 'accessed' -d "Use the accessed timestamp field"
-complete -c exa -s 'U' -l 'created'  -d "Use the created timestamp field"
+complete -c exa -s 'u' -l 'accessed'      -d "Use the accessed timestamp field"
+complete -c exa -s 'U' -l 'created'       -d "Use the created timestamp field"
+complete -c exa        -l 'time-style' -x -d "How to format timestamps" -a "
+    default\t'Use the default time style'
+    iso\t'Display brief ISO timestamps'
+    long-iso\t'Display longer ISO timestaps, up to the minute'
+    full-iso\t'Display full ISO timestamps, up to the nanosecond'
+"
 
 # Optional extras
 complete -c exa -s 'g' -l 'git'      -d "List each file's Git status, if tracked"

+ 1 - 0
contrib/completions.zsh

@@ -29,6 +29,7 @@ __exa() {
         {-m,--modified}"[Use the modified timestamp field]" \
         {-S,--blocks}"[List each file's number of filesystem blocks]" \
         {-t,--time}"[Which time field to show]:(time field):(accessed created modified)" \
+        --time-style"[How to format timestamps]:(time style):(default iso long-iso full-iso)" \
         {-u,--accessed}"[Use the accessed timestamp field]" \
         {-U,--created}"[Use the created timestamp field]" \
         --git"[List each file's Git status, if tracked]" \

+ 5 - 0
contrib/man/exa.1

@@ -145,6 +145,11 @@ which timestamp field to list (modified, accessed, created)
 .RS
 .RE
 .TP
+.B \-\-time\-style=\f[I]STYLE\f[]
+how to format timestamps (default, iso, long-iso, full-iso)
+.RS
+.RE
+.TP
 .B \-u, \-\-accessed
 use the accessed timestamp field
 .RS

+ 5 - 1
src/fs/fields.rs

@@ -166,7 +166,11 @@ pub struct DeviceIDs {
 
 
 /// One of a file’s timestamps (created, accessed, or modified).
-pub struct Time(pub time_t);
+#[derive(Copy, Clone)]
+pub struct Time {
+    pub seconds: time_t,
+    pub nanoseconds: time_t,
+}
 
 
 /// A file’s status in a Git repository. Whether a file is in a repository or

+ 20 - 8
src/fs/file.rs

@@ -273,23 +273,35 @@ impl<'dir> File<'dir> {
         }
     }
 
+    /// This file’s last modified timestamp.
     pub fn modified_time(&self) -> f::Time {
-        f::Time(self.metadata.mtime())
+        f::Time {
+            seconds:     self.metadata.mtime(),
+            nanoseconds: self.metadata.mtime_nsec()
+        }
     }
 
+    /// This file’s created timestamp.
     pub fn created_time(&self) -> f::Time {
-        f::Time(self.metadata.ctime())
+        f::Time {
+            seconds:     self.metadata.ctime(),
+            nanoseconds: self.metadata.ctime_nsec()
+        }
     }
 
+    /// This file’s last accessed timestamp.
     pub fn accessed_time(&self) -> f::Time {
-        f::Time(self.metadata.mtime())
+        f::Time {
+            seconds:     self.metadata.atime(),
+            nanoseconds: self.metadata.atime_nsec()
+        }
     }
 
-    /// This file's 'type'.
+    /// This file’s ‘type’.
     ///
-    /// This is used in the leftmost column of the permissions column.
-    /// Although the file type can usually be guessed from the colour of the
-    /// file, `ls` puts this character there, so people will expect it.
+    /// This is used a the leftmost character of the permissions column.
+    /// The file type can usually be guessed from the colour of the file, but
+    /// ls puts this character there.
     pub fn type_char(&self) -> f::Type {
         if self.is_file() {
             f::Type::File
@@ -341,7 +353,7 @@ impl<'dir> File<'dir> {
         }
     }
 
-    /// Whether this file's extension is any of the strings that get passed in.
+    /// Whether this files extension is any of the strings that get passed in.
     ///
     /// This will always return `false` if the file has no extension.
     pub fn extension_is_one_of(&self, choices: &[&str]) -> bool {

+ 2 - 1
src/options/help.rs

@@ -40,7 +40,8 @@ LONG VIEW OPTIONS
   -S, --blocks       show number of file system blocks
   -t, --time FIELD   which timestamp field to list (modified, accessed, created)
   -u, --accessed     use the accessed timestamp field
-  -U, --created      use the created timestamp field"##;
+  -U, --created      use the created timestamp field
+  --time-style       how to format timestamps (default, iso, long-iso, full-iso)"##;
 
 static GIT_HELP:      &str = r##"  --git              list each file's Git status, if tracked"##;
 static EXTENDED_HELP: &str = r##"  -@, --extended     list each file's extended attributes and sizes"##;

+ 17 - 16
src/options/mod.rs

@@ -23,7 +23,7 @@ pub use self::view::{View, Mode};
 
 /// These **options** represent a parsed, error-checked versions of the
 /// user’s command-line options.
-#[derive(PartialEq, Debug, Clone)]
+#[derive(Debug)]
 pub struct Options {
 
     /// The action to perform when encountering a directory rather than a
@@ -77,17 +77,18 @@ impl Options {
         opts.optopt ("I", "ignore-glob", "ignore files that match these glob patterns", "GLOB1|GLOB2...");
 
         // Long view options
-        opts.optflag("b", "binary",    "list file sizes with binary prefixes");
-        opts.optflag("B", "bytes",     "list file sizes in bytes, without prefixes");
-        opts.optflag("g", "group",     "list each file's group");
-        opts.optflag("h", "header",    "add a header row to each column");
-        opts.optflag("H", "links",     "list each file's number of hard links");
-        opts.optflag("i", "inode",     "list each file's inode number");
-        opts.optflag("m", "modified",  "use the modified timestamp field");
-        opts.optflag("S", "blocks",    "list each file's number of file system blocks");
-        opts.optopt ("t", "time",      "which timestamp field to show", "WORD");
-        opts.optflag("u", "accessed",  "use the accessed timestamp field");
-        opts.optflag("U", "created",   "use the created timestamp field");
+        opts.optflag("b", "binary",     "list file sizes with binary prefixes");
+        opts.optflag("B", "bytes",      "list file sizes in bytes, without prefixes");
+        opts.optflag("g", "group",      "list each file's group");
+        opts.optflag("h", "header",     "add a header row to each column");
+        opts.optflag("H", "links",      "list each file's number of hard links");
+        opts.optflag("i", "inode",      "list each file's inode number");
+        opts.optflag("m", "modified",   "use the modified timestamp field");
+        opts.optflag("S", "blocks",     "list each file's number of file system blocks");
+        opts.optopt ("t", "time",       "which timestamp field to show", "WORD");
+        opts.optflag("u", "accessed",   "use the accessed timestamp field");
+        opts.optflag("U", "created",    "use the created timestamp field");
+        opts.optopt ("",  "time-style", "how to format timestamp fields", "STYLE");
 
         if cfg!(feature="git") {
             opts.optflag("", "git", "list each file's git status");
@@ -124,8 +125,8 @@ impl Options {
     /// results will end up being displayed.
     pub fn should_scan_for_git(&self) -> bool {
         match self.view.mode {
-            Mode::Details(details::Options { columns: Some(cols), .. }) |
-            Mode::GridDetails(_, details::Options { columns: Some(cols), .. }) => cols.should_scan_for_git(),
+            Mode::Details(details::Options { table: Some(ref table), .. }) |
+            Mode::GridDetails(_, details::Options { table: Some(ref table), .. }) => table.should_scan_for_git(),
             _ => false,
         }
     }
@@ -201,13 +202,13 @@ mod test {
     #[test]
     fn long_across() {
         let opts = Options::getopts(&[ "--long", "--across" ]);
-        assert_eq!(opts, Err(Misfire::Useless("across", true, "long")))
+        assert_eq!(opts.unwrap_err(), Misfire::Useless("across", true, "long"))
     }
 
     #[test]
     fn oneline_across() {
         let opts = Options::getopts(&[ "--oneline", "--across" ]);
-        assert_eq!(opts, Err(Misfire::Useless("across", true, "oneline")))
+        assert_eq!(opts.unwrap_err(), Misfire::Useless("across", true, "oneline"))
     }
 
     #[test]

+ 35 - 9
src/options/view.rs

@@ -4,14 +4,15 @@ use getopts;
 
 use output::Colours;
 use output::{grid, details};
-use output::column::{Columns, TimeTypes, SizeFormat};
+use output::table::{TimeTypes, Environment, SizeFormat, Options as TableOptions};
 use output::file_name::Classify;
+use output::time::TimeFormat;
 use options::Misfire;
 use fs::feature::xattr;
 
 
 /// The **view** contains all information about how to format output.
-#[derive(PartialEq, Debug, Clone)]
+#[derive(Debug)]
 pub struct View {
     pub mode: Mode,
     pub colours: Colours,
@@ -31,7 +32,7 @@ impl View {
 
 
 /// The **mode** is the “type” of output.
-#[derive(PartialEq, Debug, Clone)]
+#[derive(Debug)]
 pub enum Mode {
     Grid(grid::Options),
     Details(details::Options),
@@ -54,7 +55,7 @@ impl Mode {
             }
             else {
                 Ok(details::Options {
-                    columns: Some(Columns::deduce(matches)?),
+                    table: Some(TableOptions::deduce(matches)?),
                     header: matches.opt_present("header"),
                     xattr: xattr::ENABLED && matches.opt_present("extended"),
                 })
@@ -94,7 +95,7 @@ impl Mode {
                 }
                 else if matches.opt_present("tree") {
                     let details = details::Options {
-                        columns: None,
+                        table: None,
                         header: false,
                         xattr: false,
                     };
@@ -117,7 +118,7 @@ impl Mode {
 
                 if matches.opt_present("tree") {
                     let details = details::Options {
-                        columns: None,
+                        table: None,
                         header: false,
                         xattr: false,
                     };
@@ -194,9 +195,11 @@ impl TerminalWidth {
 }
 
 
-impl Columns {
-    fn deduce(matches: &getopts::Matches) -> Result<Columns, Misfire> {
-        Ok(Columns {
+impl TableOptions {
+    fn deduce(matches: &getopts::Matches) -> Result<Self, Misfire> {
+        Ok(TableOptions {
+            env:         Environment::load_all(),
+            time_format: TimeFormat::deduce(matches)?,
             size_format: SizeFormat::deduce(matches)?,
             time_types:  TimeTypes::deduce(matches)?,
             inode:  matches.opt_present("inode"),
@@ -233,6 +236,29 @@ impl SizeFormat {
 }
 
 
+impl TimeFormat {
+
+    /// Determine how time should be formatted in timestamp columns.
+    fn deduce(matches: &getopts::Matches) -> Result<TimeFormat, Misfire> {
+        pub use output::time::{DefaultFormat, ISOFormat};
+        const STYLES: &[&str] = &["default", "long-iso", "full-iso", "iso"];
+
+        if let Some(word) = matches.opt_str("time-style") {
+            match &*word {
+                "default"   => Ok(TimeFormat::DefaultFormat(DefaultFormat::new())),
+                "iso"       => Ok(TimeFormat::ISOFormat(ISOFormat::new())),
+                "long-iso"  => Ok(TimeFormat::LongISO),
+                "full-iso"  => Ok(TimeFormat::FullISO),
+                otherwise   => Err(Misfire::bad_argument("time-style", otherwise, STYLES)),
+            }
+        }
+        else {
+            Ok(TimeFormat::DefaultFormat(DefaultFormat::new()))
+        }
+    }
+}
+
+
 impl TimeTypes {
 
     /// Determine which of a file’s time fields should be displayed for it

+ 0 - 193
src/output/column.rs

@@ -1,193 +0,0 @@
-use fs::Dir;
-
-
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum Column {
-    Permissions,
-    FileSize(SizeFormat),
-    Timestamp(TimeType),
-    Blocks,
-    User,
-    Group,
-    HardLinks,
-    Inode,
-
-    GitStatus,
-}
-
-/// Each column can pick its own **Alignment**. Usually, numbers are
-/// right-aligned, and text is left-aligned.
-#[derive(Copy, Clone)]
-pub enum Alignment {
-    Left, Right,
-}
-
-impl Column {
-
-    /// Get the alignment this column should use.
-    pub fn alignment(&self) -> Alignment {
-        match *self {
-            Column::FileSize(_)
-            | Column::HardLinks
-            | Column::Inode
-            | Column::Blocks
-            | Column::GitStatus => Alignment::Right,
-            _                   => Alignment::Left,
-        }
-    }
-
-    /// Get the text that should be printed at the top, when the user elects
-    /// to have a header row printed.
-    pub fn header(&self) -> &'static str {
-        match *self {
-            Column::Permissions   => "Permissions",
-            Column::FileSize(_)   => "Size",
-            Column::Timestamp(t)  => t.header(),
-            Column::Blocks        => "Blocks",
-            Column::User          => "User",
-            Column::Group         => "Group",
-            Column::HardLinks     => "Links",
-            Column::Inode         => "inode",
-            Column::GitStatus     => "Git",
-        }
-    }
-}
-
-
-#[derive(PartialEq, Copy, Clone, Debug, Default)]
-pub struct Columns {
-    pub size_format: SizeFormat,
-    pub time_types: TimeTypes,
-    pub inode: bool,
-    pub links: bool,
-    pub blocks: bool,
-    pub group: bool,
-    pub git: bool
-}
-
-impl Columns {
-    pub fn should_scan_for_git(&self) -> bool {
-        self.git
-    }
-
-    pub fn for_dir(&self, dir: Option<&Dir>) -> Vec<Column> {
-        let mut columns = vec![];
-
-        if self.inode {
-            columns.push(Column::Inode);
-        }
-
-        columns.push(Column::Permissions);
-
-        if self.links {
-            columns.push(Column::HardLinks);
-        }
-
-        columns.push(Column::FileSize(self.size_format));
-
-        if self.blocks {
-            columns.push(Column::Blocks);
-        }
-
-        columns.push(Column::User);
-
-        if self.group {
-            columns.push(Column::Group);
-        }
-
-        if self.time_types.modified {
-            columns.push(Column::Timestamp(TimeType::Modified));
-        }
-
-        if self.time_types.created {
-            columns.push(Column::Timestamp(TimeType::Created));
-        }
-
-        if self.time_types.accessed {
-            columns.push(Column::Timestamp(TimeType::Accessed));
-        }
-
-        if cfg!(feature="git") {
-            if let Some(d) = dir {
-                if self.should_scan_for_git() && d.has_git_repo() {
-                    columns.push(Column::GitStatus);
-                }
-            }
-        }
-
-        columns
-    }
-}
-
-
-/// Formatting options for file sizes.
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum SizeFormat {
-
-    /// Format the file size using **decimal** prefixes, such as “kilo”,
-    /// “mega”, or “giga”.
-    DecimalBytes,
-
-    /// Format the file size using **binary** prefixes, such as “kibi”,
-    /// “mebi”, or “gibi”.
-    BinaryBytes,
-
-    /// Do no formatting and just display the size as a number of bytes.
-    JustBytes,
-}
-
-impl Default for SizeFormat {
-    fn default() -> SizeFormat {
-        SizeFormat::DecimalBytes
-    }
-}
-
-
-/// The types of a file’s time fields. These three fields are standard
-/// across most (all?) operating systems.
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub enum TimeType {
-
-    /// The file’s accessed time (`st_atime`).
-    Accessed,
-
-    /// The file’s modified time (`st_mtime`).
-    Modified,
-
-    /// The file’s creation time (`st_ctime`).
-    Created,
-}
-
-impl TimeType {
-
-    /// Returns the text to use for a column’s heading in the columns output.
-    pub fn header(&self) -> &'static str {
-        match *self {
-            TimeType::Accessed  => "Date Accessed",
-            TimeType::Modified  => "Date Modified",
-            TimeType::Created   => "Date Created",
-        }
-    }
-}
-
-
-/// Fields for which of a file’s time fields should be displayed in the
-/// columns output.
-///
-/// There should always be at least one of these--there's no way to disable
-/// the time columns entirely (yet).
-#[derive(PartialEq, Debug, Copy, Clone)]
-pub struct TimeTypes {
-    pub accessed: bool,
-    pub modified: bool,
-    pub created:  bool,
-}
-
-impl Default for TimeTypes {
-
-    /// By default, display just the ‘modified’ time. This is the most
-    /// common option, which is why it has this shorthand.
-    fn default() -> TimeTypes {
-        TimeTypes { accessed: false, modified: true, created: false }
-    }
-}

+ 9 - 11
src/output/details.rs

@@ -68,11 +68,10 @@ use fs::{Dir, File};
 use fs::feature::xattr::{Attribute, FileAttributes};
 use options::{FileFilter, RecurseOptions};
 use output::colours::Colours;
-use output::column::Columns;
 use output::cell::TextCell;
 use output::tree::{TreeTrunk, TreeParams, TreeDepth};
 use output::file_name::{FileName, LinkStyle, Classify};
-use output::table::{Table, Environment, Row as TableRow};
+use output::table::{Table, Options as TableOptions, Row as TableRow};
 
 
 /// With the **Details** view, the output gets formatted into columns, with
@@ -86,13 +85,14 @@ use output::table::{Table, Environment, Row as TableRow};
 ///
 /// Almost all the heavy lifting is done in a Table object, which handles the
 /// columns for each row.
-#[derive(PartialEq, Debug, Clone, Default)]
+#[derive(Debug)]
 pub struct Options {
 
-    /// A Columns object that says which columns should be included in the
-    /// output in the general case. Directories themselves can pick which
-    /// columns are *added* to this list, such as the Git column.
-    pub columns: Option<Columns>,
+    /// Options specific to drawing a table.
+    ///
+    /// Directories themselves can pick which columns are *added* to this
+    /// list, such as the Git column.
+    pub table: Option<TableOptions>,
 
     /// Whether to show a header line or not.
     pub header: bool,
@@ -139,10 +139,8 @@ impl<'a> Render<'a> {
     pub fn render<W: Write>(self, w: &mut W) -> IOResult<()> {
         let mut rows = Vec::new();
 
-        if let Some(columns) = self.opts.columns {
-            let env = Environment::default();
-            let colz = columns.for_dir(self.dir);
-            let mut table = Table::new(&colz, &self.colours, &env);
+        if let Some(ref table) = self.opts.table {
+            let mut table = Table::new(&table, self.dir, &self.colours);
 
             if self.opts.header {
                 let header = table.header_row();

+ 9 - 15
src/output/grid_details.rs

@@ -8,12 +8,11 @@ use fs::feature::xattr::FileAttributes;
 
 use options::FileFilter;
 use output::cell::TextCell;
-use output::column::Column;
 use output::colours::Colours;
 use output::details::{Options as DetailsOptions, Row as DetailsRow, Render as DetailsRender};
 use output::grid::Options as GridOptions;
 use output::file_name::{FileName, LinkStyle, Classify};
-use output::table::{Table, Environment, Row as TableRow};
+use output::table::{Table, Row as TableRow, Options as TableOptions};
 use output::tree::{TreeParams, TreeDepth};
 
 
@@ -42,16 +41,11 @@ impl<'a> Render<'a> {
 
     pub fn render<W: Write>(&self, w: &mut W) -> IOResult<()> {
 
-        let columns_for_dir = match self.details.columns {
-            Some(cols) => cols.for_dir(self.dir),
-            None => Vec::new(),
-        };
-
-        let env = Environment::default();
+        let options = self.details.table.as_ref().expect("Details table options not given!");
 
         let drender = self.clone().details();
 
-        let (first_table, _) = self.make_table(&env, &columns_for_dir, &drender);
+        let (first_table, _) = self.make_table(options, &drender);
 
         let rows = self.files.iter()
                        .map(|file| first_table.row_for_file(file, file_has_xattrs(file)))
@@ -61,10 +55,10 @@ impl<'a> Render<'a> {
                              .map(|file| FileName::new(file, LinkStyle::JustFilenames, self.classify, self.colours).paint().promote())
                              .collect::<Vec<TextCell>>();
 
-        let mut last_working_table = self.make_grid(&env, 1, &columns_for_dir, &file_names, rows.clone(), &drender);
+        let mut last_working_table = self.make_grid(1, options, &file_names, rows.clone(), &drender);
 
         for column_count in 2.. {
-            let grid = self.make_grid(&env, column_count, &columns_for_dir, &file_names, rows.clone(), &drender);
+            let grid = self.make_grid(column_count, options, &file_names, rows.clone(), &drender);
 
             let the_grid_fits = {
                 let d = grid.fit_into_columns(column_count);
@@ -82,8 +76,8 @@ impl<'a> Render<'a> {
         Ok(())
     }
 
-    fn make_table<'t>(&'a self, env: &'a Environment, columns_for_dir: &'a [Column], drender: &DetailsRender) -> (Table<'a>, Vec<DetailsRow>) {
-        let mut table = Table::new(columns_for_dir, self.colours, env);
+    fn make_table<'t>(&'a self, options: &'a TableOptions, drender: &DetailsRender) -> (Table<'a>, Vec<DetailsRow>) {
+        let mut table = Table::new(options, self.dir, self.colours);
         let mut rows = Vec::new();
 
         if self.details.header {
@@ -95,11 +89,11 @@ impl<'a> Render<'a> {
         (table, rows)
     }
 
-    fn make_grid(&'a self, env: &'a Environment, column_count: usize, columns_for_dir: &'a [Column], file_names: &[TextCell], rows: Vec<TableRow>, drender: &DetailsRender) -> grid::Grid {
+    fn make_grid(&'a self, column_count: usize, options: &'a TableOptions, file_names: &[TextCell], rows: Vec<TableRow>, drender: &DetailsRender) -> grid::Grid {
 
         let mut tables = Vec::new();
         for _ in 0 .. column_count {
-            tables.push(self.make_table(env.clone(), columns_for_dir, drender));
+            tables.push(self.make_table(options, drender));
         }
 
         let mut num_cells = rows.len();

+ 1 - 2
src/output/mod.rs

@@ -2,12 +2,12 @@ pub use self::cell::{TextCell, TextCellContents, DisplayWidth};
 pub use self::colours::Colours;
 pub use self::escape::escape;
 
-pub mod column;
 pub mod details;
 pub mod file_name;
 pub mod grid_details;
 pub mod grid;
 pub mod lines;
+pub mod table;
 pub mod time;
 
 mod cell;
@@ -15,4 +15,3 @@ mod colours;
 mod escape;
 mod render;
 mod tree;
-mod table;

+ 2 - 2
src/output/render/size.rs

@@ -1,7 +1,7 @@
 use fs::fields as f;
-use output::column::SizeFormat;
 use output::cell::{TextCell, DisplayWidth};
 use output::colours::Colours;
+use output::table::SizeFormat;
 use locale;
 
 
@@ -68,8 +68,8 @@ impl f::DeviceIDs {
 #[cfg(test)]
 pub mod test {
     use output::colours::Colours;
-    use output::column::SizeFormat;
     use output::cell::{TextCell, DisplayWidth};
+    use output::table::SizeFormat;
     use fs::fields as f;
 
     use locale;

+ 3 - 4
src/output/render/times.rs

@@ -6,18 +6,17 @@ use output::colours::Colours;
 use output::time::TimeFormat;
 
 
-#[allow(trivial_numeric_casts)]
 impl f::Time {
-    pub fn render(&self, colours: &Colours,
+    pub fn render(self, colours: &Colours,
                          tz: &Option<TimeZone>,
                          style: &TimeFormat) -> TextCell {
 
         if let Some(ref tz) = *tz {
-            let datestamp = style.format_zoned(self.0 as i64, tz);
+            let datestamp = style.format_zoned(self, tz);
             TextCell::paint(colours.date, datestamp)
         }
         else {
-            let datestamp = style.format_local(self.0 as i64);
+            let datestamp = style.format_local(self);
             TextCell::paint(colours.date, datestamp)
         }
     }

+ 219 - 19
src/output/table.rs

@@ -1,4 +1,5 @@
 use std::cmp::max;
+use std::fmt;
 use std::ops::Deref;
 use std::sync::{Mutex, MutexGuard};
 
@@ -11,10 +12,214 @@ use users::UsersCache;
 
 use output::cell::TextCell;
 use output::colours::Colours;
-use output::column::{Alignment, Column};
 use output::time::TimeFormat;
 
-use fs::{File, fields as f};
+use fs::{File, Dir, fields as f};
+
+
+
+/// Options for displaying a table.
+pub struct Options {
+    pub env: Environment,
+    pub size_format: SizeFormat,
+    pub time_format: TimeFormat,
+    pub time_types: TimeTypes,
+    pub inode: bool,
+    pub links: bool,
+    pub blocks: bool,
+    pub group: bool,
+    pub git: bool
+}
+
+impl fmt::Debug for Options {
+    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
+        // I had to make other types derive Debug,
+        // and Mutex<UsersCache> is not that!
+        writeln!(f, "<table options>")
+    }
+}
+
+impl Options {
+    pub fn should_scan_for_git(&self) -> bool {
+        self.git
+    }
+
+    pub fn for_dir(&self, dir: Option<&Dir>) -> Vec<Column> {
+        let mut columns = vec![];
+
+        if self.inode {
+            columns.push(Column::Inode);
+        }
+
+        columns.push(Column::Permissions);
+
+        if self.links {
+            columns.push(Column::HardLinks);
+        }
+
+        columns.push(Column::FileSize(self.size_format));
+
+        if self.blocks {
+            columns.push(Column::Blocks);
+        }
+
+        columns.push(Column::User);
+
+        if self.group {
+            columns.push(Column::Group);
+        }
+
+        if self.time_types.modified {
+            columns.push(Column::Timestamp(TimeType::Modified));
+        }
+
+        if self.time_types.created {
+            columns.push(Column::Timestamp(TimeType::Created));
+        }
+
+        if self.time_types.accessed {
+            columns.push(Column::Timestamp(TimeType::Accessed));
+        }
+
+        if cfg!(feature="git") {
+            if let Some(d) = dir {
+                if self.should_scan_for_git() && d.has_git_repo() {
+                    columns.push(Column::GitStatus);
+                }
+            }
+        }
+
+        columns
+    }
+}
+
+
+/// A table contains these.
+#[derive(Debug)]
+pub enum Column {
+    Permissions,
+    FileSize(SizeFormat),
+    Timestamp(TimeType),
+    Blocks,
+    User,
+    Group,
+    HardLinks,
+    Inode,
+    GitStatus,
+}
+
+/// Each column can pick its own **Alignment**. Usually, numbers are
+/// right-aligned, and text is left-aligned.
+#[derive(Copy, Clone)]
+pub enum Alignment {
+    Left, Right,
+}
+
+impl Column {
+
+    /// Get the alignment this column should use.
+    pub fn alignment(&self) -> Alignment {
+        match *self {
+            Column::FileSize(_)
+            | Column::HardLinks
+            | Column::Inode
+            | Column::Blocks
+            | Column::GitStatus => Alignment::Right,
+            _                   => Alignment::Left,
+        }
+    }
+
+    /// Get the text that should be printed at the top, when the user elects
+    /// to have a header row printed.
+    pub fn header(&self) -> &'static str {
+        match *self {
+            Column::Permissions   => "Permissions",
+            Column::FileSize(_)   => "Size",
+            Column::Timestamp(t)  => t.header(),
+            Column::Blocks        => "Blocks",
+            Column::User          => "User",
+            Column::Group         => "Group",
+            Column::HardLinks     => "Links",
+            Column::Inode         => "inode",
+            Column::GitStatus     => "Git",
+        }
+    }
+}
+
+
+/// Formatting options for file sizes.
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub enum SizeFormat {
+
+    /// Format the file size using **decimal** prefixes, such as “kilo”,
+    /// “mega”, or “giga”.
+    DecimalBytes,
+
+    /// Format the file size using **binary** prefixes, such as “kibi”,
+    /// “mebi”, or “gibi”.
+    BinaryBytes,
+
+    /// Do no formatting and just display the size as a number of bytes.
+    JustBytes,
+}
+
+impl Default for SizeFormat {
+    fn default() -> SizeFormat {
+        SizeFormat::DecimalBytes
+    }
+}
+
+
+/// The types of a file’s time fields. These three fields are standard
+/// across most (all?) operating systems.
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub enum TimeType {
+
+    /// The file’s accessed time (`st_atime`).
+    Accessed,
+
+    /// The file’s modified time (`st_mtime`).
+    Modified,
+
+    /// The file’s creation time (`st_ctime`).
+    Created,
+}
+
+impl TimeType {
+
+    /// Returns the text to use for a column’s heading in the columns output.
+    pub fn header(&self) -> &'static str {
+        match *self {
+            TimeType::Accessed  => "Date Accessed",
+            TimeType::Modified  => "Date Modified",
+            TimeType::Created   => "Date Created",
+        }
+    }
+}
+
+
+/// Fields for which of a file’s time fields should be displayed in the
+/// columns output.
+///
+/// There should always be at least one of these--there's no way to disable
+/// the time columns entirely (yet).
+#[derive(PartialEq, Debug, Copy, Clone)]
+pub struct TimeTypes {
+    pub accessed: bool,
+    pub modified: bool,
+    pub created:  bool,
+}
+
+impl Default for TimeTypes {
+
+    /// By default, display just the ‘modified’ time. This is the most
+    /// common option, which is why it has this shorthand.
+    fn default() -> TimeTypes {
+        TimeTypes { accessed: false, modified: true, created: false }
+    }
+}
+
+
 
 
 /// The **environment** struct contains any data that could change between
@@ -26,9 +231,6 @@ pub struct Environment {
     /// Localisation rules for formatting numbers.
     numeric: locale::Numeric,
 
-    /// Rules for formatting timestamps.
-    time_format: TimeFormat,
-
     /// The computer's current time zone. This gets used to determine how to
     /// offset files' timestamps.
     tz: Option<TimeZone>,
@@ -41,10 +243,8 @@ impl Environment {
     pub fn lock_users(&self) -> MutexGuard<UsersCache> {
         self.users.lock().unwrap()
     }
-}
 
-impl Default for Environment {
-    fn default() -> Self {
+    pub fn load_all() -> Self {
         let tz = match determine_time_zone() {
             Ok(t) => Some(t),
             Err(ref e) => {
@@ -53,14 +253,12 @@ impl Default for Environment {
             }
         };
 
-        let time_format = TimeFormat::deduce();
-
         let numeric = locale::Numeric::load_user_locale()
                           .unwrap_or_else(|_| locale::Numeric::english());
 
         let users = Mutex::new(UsersCache::new());
 
-        Environment { tz, time_format, numeric, users }
+        Environment { tz, numeric, users }
     }
 }
 
@@ -73,10 +271,11 @@ fn determine_time_zone() -> TZResult<TimeZone> {
 
 
 pub struct Table<'a> {
-    columns: &'a [Column],
+    columns: Vec<Column>,
     colours: &'a Colours,
     env: &'a Environment,
     widths: TableWidths,
+    time_format: &'a TimeFormat,
 }
 
 #[derive(Clone)]
@@ -85,9 +284,10 @@ pub struct Row {
 }
 
 impl<'a, 'f> Table<'a> {
-    pub fn new(columns: &'a [Column], colours: &'a Colours, env: &'a Environment) -> Table<'a> {
-        let widths = TableWidths::zero(columns.len());
-        Table { columns, colours, env, widths }
+    pub fn new(options: &'a Options, dir: Option<&'a Dir>, colours: &'a Colours) -> Table<'a> {
+        let colz = options.for_dir(dir);
+        let widths = TableWidths::zero(colz.len());
+        Table { columns: colz, colours, env: &options.env, widths, time_format: &options.time_format }
     }
 
     pub fn widths(&self) -> &TableWidths {
@@ -123,7 +323,7 @@ impl<'a, 'f> Table<'a> {
     }
 
     fn display(&self, file: &File, column: &Column, xattrs: bool) -> TextCell {
-        use output::column::TimeType::*;
+        use output::table::TimeType::*;
 
         match *column {
             Column::Permissions    => self.permissions_plus(file, xattrs).render(&self.colours),
@@ -135,9 +335,9 @@ impl<'a, 'f> Table<'a> {
             Column::Group          => file.group().render(&self.colours, &*self.env.lock_users()),
             Column::GitStatus      => file.git_status().render(&self.colours),
 
-            Column::Timestamp(Modified)  => file.modified_time().render(&self.colours, &self.env.tz, &self.env.time_format),
-            Column::Timestamp(Created)   => file.created_time().render( &self.colours, &self.env.tz, &self.env.time_format),
-            Column::Timestamp(Accessed)  => file.accessed_time().render(&self.colours, &self.env.tz, &self.env.time_format),
+            Column::Timestamp(Modified)  => file.modified_time().render(&self.colours, &self.env.tz, &self.time_format),
+            Column::Timestamp(Created)   => file.created_time().render( &self.colours, &self.env.tz, &self.time_format),
+            Column::Timestamp(Accessed)  => file.accessed_time().render(&self.colours, &self.env.tz, &self.time_format),
         }
     }
 

+ 144 - 26
src/output/time.rs

@@ -1,12 +1,40 @@
-use datetime::{LocalDateTime, TimeZone, DatePiece};
+use datetime::{LocalDateTime, TimeZone, DatePiece, TimePiece};
 use datetime::fmt::DateFormat;
 use locale;
 
-use fs::fields::time_t;
+use fs::fields::Time;
+
+
+pub enum TimeFormat {
+    DefaultFormat(DefaultFormat),
+    ISOFormat(ISOFormat),
+    LongISO,
+    FullISO,
+}
+
+impl TimeFormat {
+    pub fn format_local(&self, time: Time) -> String {
+        match *self {
+            TimeFormat::DefaultFormat(ref fmt) => fmt.format_local(time),
+            TimeFormat::ISOFormat(ref iso)     => iso.format_local(time),
+            TimeFormat::LongISO                => long_local(time),
+            TimeFormat::FullISO                => full_local(time),
+        }
+    }
+
+    pub fn format_zoned(&self, time: Time, zone: &TimeZone) -> String {
+        match *self {
+            TimeFormat::DefaultFormat(ref fmt) => fmt.format_zoned(time, zone),
+            TimeFormat::ISOFormat(ref iso)     => iso.format_zoned(time, zone),
+            TimeFormat::LongISO                => long_zoned(time, zone),
+            TimeFormat::FullISO                => full_zoned(time, zone),
+        }
+    }
+}
 
 
 #[derive(Debug, Clone)]
-pub struct TimeFormat {
+pub struct DefaultFormat {
 
     /// The year of the current time. This gets used to determine which date
     /// format to use.
@@ -22,14 +50,40 @@ pub struct TimeFormat {
     pub date_and_year: DateFormat<'static>,
 }
 
-impl TimeFormat {
+impl DefaultFormat {
+    pub fn new() -> DefaultFormat {
+        use unicode_width::UnicodeWidthStr;
+
+        let locale = locale::Time::load_user_locale()
+                       .unwrap_or_else(|_| locale::Time::english());
+
+        let current_year = LocalDateTime::now().year();
+
+        // Some locales use a three-character wide month name (Jan to Dec);
+        // others vary between three and four (1月 to 12月). We assume that
+        // December is the month with the maximum width, and use the width of
+        // that to determine how to pad the other months.
+        let december_width = UnicodeWidthStr::width(&*locale.short_month_name(11));
+        let date_and_time = match december_width {
+            4  => DateFormat::parse("{2>:D} {4>:M} {2>:h}:{02>:m}").unwrap(),
+            _  => DateFormat::parse("{2>:D} {:M} {2>:h}:{02>:m}").unwrap(),
+        };
+
+        let date_and_year = match december_width {
+            4 => DateFormat::parse("{2>:D} {4>:M} {5>:Y}").unwrap(),
+            _ => DateFormat::parse("{2>:D} {:M} {5>:Y}").unwrap()
+        };
+
+        DefaultFormat { current_year, locale, date_and_time, date_and_year }
+    }
+
     fn is_recent(&self, date: LocalDateTime) -> bool {
         date.year() == self.current_year
     }
 
     #[allow(trivial_numeric_casts)]
-    pub fn format_local(&self, time: time_t) -> String {
-        let date = LocalDateTime::at(time as i64);
+    fn format_local(&self, time: Time) -> String {
+        let date = LocalDateTime::at(time.seconds as i64);
 
         if self.is_recent(date) {
             self.date_and_time.format(&date, &self.locale)
@@ -40,8 +94,8 @@ impl TimeFormat {
     }
 
     #[allow(trivial_numeric_casts)]
-    pub fn format_zoned(&self, time: time_t, zone: &TimeZone) -> String {
-        let date = zone.to_zoned(LocalDateTime::at(time as i64));
+    fn format_zoned(&self, time: Time, zone: &TimeZone) -> String {
+        let date = zone.to_zoned(LocalDateTime::at(time.seconds as i64));
 
         if self.is_recent(date) {
             self.date_and_time.format(&date, &self.locale)
@@ -50,30 +104,94 @@ impl TimeFormat {
             self.date_and_year.format(&date, &self.locale)
         }
     }
+}
 
-    pub fn deduce() -> TimeFormat {
-        use unicode_width::UnicodeWidthStr;
 
-        let locale = locale::Time::load_user_locale()
-                       .unwrap_or_else(|_| locale::Time::english());
+#[allow(trivial_numeric_casts)]
+fn long_local(time: Time) -> String {
+    let date = LocalDateTime::at(time.seconds as i64);
+    format!("{:04}-{:02}-{:02} {:02}:{:02}",
+            date.year(), date.month() as usize, date.day(),
+            date.hour(), date.minute())
+}
+
+#[allow(trivial_numeric_casts)]
+fn long_zoned(time: Time, zone: &TimeZone) -> String {
+    let date = zone.to_zoned(LocalDateTime::at(time.seconds as i64));
+    format!("{:04}-{:02}-{:02} {:02}:{:02}",
+            date.year(), date.month() as usize, date.day(),
+            date.hour(), date.minute())
+}
+
 
+#[allow(trivial_numeric_casts)]
+fn full_local(time: Time) -> String {
+    let date = LocalDateTime::at(time.seconds as i64);
+    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09}",
+            date.year(), date.month() as usize, date.day(),
+            date.hour(), date.minute(), date.second(), time.nanoseconds)
+}
+
+#[allow(trivial_numeric_casts)]
+fn full_zoned(time: Time, zone: &TimeZone) -> String {
+    use datetime::Offset;
+
+    let local = LocalDateTime::at(time.seconds as i64);
+    let date = zone.to_zoned(local);
+    let offset = Offset::of_seconds(zone.offset(local) as i32).expect("Offset out of range");
+    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:09} {:+03}{:02}",
+            date.year(), date.month() as usize, date.day(),
+            date.hour(), date.minute(), date.second(), time.nanoseconds,
+            offset.hours(), offset.minutes().abs())
+}
+
+
+
+#[derive(Debug, Clone)]
+pub struct ISOFormat {
+
+    /// The year of the current time. This gets used to determine which date
+    /// format to use.
+    pub current_year: i64,
+}
+
+impl ISOFormat {
+    pub fn new() -> Self {
         let current_year = LocalDateTime::now().year();
+        ISOFormat { current_year }
+    }
 
-        // Some locales use a three-character wide month name (Jan to Dec);
-        // others vary between three and four (1月 to 12月). We assume that
-        // December is the month with the maximum width, and use the width of
-        // that to determine how to pad the other months.
-        let december_width = UnicodeWidthStr::width(&*locale.short_month_name(11));
-        let date_and_time = match december_width {
-            4  => DateFormat::parse("{2>:D} {4>:M} {2>:h}:{02>:m}").unwrap(),
-            _  => DateFormat::parse("{2>:D} {:M} {2>:h}:{02>:m}").unwrap(),
-        };
+    fn is_recent(&self, date: LocalDateTime) -> bool {
+        date.year() == self.current_year
+    }
 
-        let date_and_year = match december_width {
-            4 => DateFormat::parse("{2>:D} {4>:M} {5>:Y}").unwrap(),
-            _ => DateFormat::parse("{2>:D} {:M} {5>:Y}").unwrap()
-        };
+    #[allow(trivial_numeric_casts)]
+    fn format_local(&self, time: Time) -> String {
+        let date = LocalDateTime::at(time.seconds as i64);
 
-        TimeFormat { current_year, locale, date_and_time, date_and_year }
+        if self.is_recent(date) {
+            format!("{:04}-{:02}-{:02}",
+                    date.year(), date.month() as usize, date.day())
+        }
+        else {
+            format!("{:02}-{:02} {:02}:{:02}",
+                    date.month() as usize, date.day(),
+                    date.hour(), date.minute())
+        }
+    }
+
+    #[allow(trivial_numeric_casts)]
+    fn format_zoned(&self, time: Time, zone: &TimeZone) -> String {
+        let date = zone.to_zoned(LocalDateTime::at(time.seconds as i64));
+
+        if self.is_recent(date) {
+            format!("{:04}-{:02}-{:02}",
+                    date.year(), date.month() as usize, date.day())
+        }
+        else {
+            format!("{:02}-{:02} {:02}:{:02}",
+                    date.month() as usize, date.day(),
+                    date.hour(), date.minute())
+        }
     }
 }

+ 4 - 0
xtests/dates_accessed

@@ -0,0 +1,4 @@
+Permissions Size User      Date Accessed Name
+.rw-rw-r--     0 cassowary  3 Mar  2003  plum
+.rw-rw-r--     0 cassowary 15 Jun  2006  pear
+.rw-rw-r--     0 cassowary 22 Jul  2009  peach

+ 3 - 0
xtests/dates_full_iso

@@ -0,0 +1,3 @@
+.rw-rw-r-- 0 cassowary 2006-06-15 23:14:29.000000000 +0000 peach
+.rw-rw-r-- 0 cassowary 2003-03-03 00:00:00.000000000 +0000 pear
+.rw-rw-r-- 0 cassowary 2009-07-22 10:38:53.000000000 +0000 plum

+ 3 - 0
xtests/dates_iso

@@ -0,0 +1,3 @@
+.rw-rw-r-- 0 cassowary 06-15 23:14 peach
+.rw-rw-r-- 0 cassowary 03-03 00:00 pear
+.rw-rw-r-- 0 cassowary 07-22 10:38 plum

+ 3 - 0
xtests/dates_long_iso

@@ -0,0 +1,3 @@
+.rw-rw-r-- 0 cassowary 2006-06-15 23:14 peach
+.rw-rw-r-- 0 cassowary 2003-03-03 00:00 pear
+.rw-rw-r-- 0 cassowary 2009-07-22 10:38 plum

+ 4 - 0
xtests/dates_modified

@@ -0,0 +1,4 @@
+Permissions Size User      Date Modified Name
+.rw-rw-r--     0 cassowary  3 Mar  2003  pear
+.rw-rw-r--     0 cassowary 15 Jun  2006  peach
+.rw-rw-r--     0 cassowary 22 Jul  2009  plum

+ 1 - 0
xtests/help

@@ -38,5 +38,6 @@ LONG VIEW OPTIONS
   -t, --time FIELD   which timestamp field to list (modified, accessed, created)
   -u, --accessed     use the accessed timestamp field
   -U, --created      use the created timestamp field
+  --time-style       how to format timestamps (default, iso, long-iso, full-iso)
   --git              list each file's Git status, if tracked
   -@, --extended     list each file's extended attributes and sizes

+ 1 - 0
xtests/help_long

@@ -14,5 +14,6 @@ LONG VIEW OPTIONS
   -t, --time FIELD   which timestamp field to list (modified, accessed, created)
   -u, --accessed     use the accessed timestamp field
   -U, --created      use the created timestamp field
+  --time-style       how to format timestamps (default, iso, long-iso, full-iso)
   --git              list each file's Git status, if tracked
   -@, --extended     list each file's extended attributes and sizes

+ 8 - 0
xtests/run.sh

@@ -107,6 +107,14 @@ $exa $testcases/file-names-exts/music.* -I "*.OGG"       -1 2>&1 | diff -q - $re
 $exa $testcases/file-names-exts/music.* -I "*.OGG|*.mp3" -1 2>&1 | diff -q - $results/empty        || exit 1
 
 
+# Dates and times
+$exa $testcases/dates -lh --accessed --sort=accessed 2>&1 | diff -q - $results/dates_accessed  || exit 1
+$exa $testcases/dates -lh            --sort=modified 2>&1 | diff -q - $results/dates_modified  || exit 1
+$exa $testcases/dates -l       --time-style=long-iso 2>&1 | diff -q - $results/dates_long_iso  || exit 1
+$exa $testcases/dates -l       --time-style=full-iso 2>&1 | diff -q - $results/dates_full_iso  || exit 1
+$exa $testcases/dates -l            --time-style=iso 2>&1 | diff -q - $results/dates_iso       || exit 1
+
+
 # Paths and directories
 # These directories are created in the VM user’s home directory (the default
 # location) when a Cargo build is done.