Repository: uutils/coreutils (language: rust)

Issue:
stat: --printf option does not support precision in certain format strings
The `--printf` argument to `stat` does not seem to correctly support precision specifiers in format strings.

For example, after creating a file with `touch f`...

GNU stat:
```
$ stat --printf='%.1Y\n' f
1646795086.4
```

uutils stat:
```
$ ./target/release/stat --printf='%.1Y\n' f
1
```

From the documentation
> The ‘%W’, ‘%X’, ‘%Y’, and ‘%Z’ formats accept a precision preceded by a period to specify the number of digits to print after the decimal point. For example, ‘%.3X’ outputs the access timestamp to millisecond precision. If a period is given but no precision, stat uses 9 digits, so ‘%.X’ is equivalent to ‘%.9X’. When discarding excess precision, timestamps are truncated toward minus infinity. 

-- https://www.gnu.org/software/coreutils/manual/html_node/stat-invocation.html

*Edit:* this is causing a test failure in the GNU test file `tests/stat/stat-nanoseconds.sh`.


Discussion / hints:
@jfinkels, err… Where did you find `stat` in this package?
That's what I see on Windows 7 x64 upon launch of _coreutils 0.0.16_
```console
$ coreutils.exe
coreutils 0.0.16 (multi-call binary)

Usage: coreutils [function [arguments...]]                                                      
                                                                                                
Currently defined functions:                                                                    
                                                                                                
    [, arch, b2sum, b3sum, base32, base64, basename, basenc, cat, cksum, comm, cp, csplit, cut, 
    date, dd, df, dir, dircolors, dirname, du, echo, env, expand, expr, factor, false, fmt,     
    fold, hashsum, head, hostname, join, link, ln, ls, md5sum, mkdir, mktemp, more, mv, nl,     
    nproc, numfmt, od, paste, pr, printenv, printf, ptx, pwd, readlink, realpath, relpath, rm,  
    rmdir, seq, sha1sum, sha224sum, sha256sum, sha3-224sum, sha3-256sum, sha3-384sum, sha3-     
    512sum, sha384sum, sha3sum, sha512sum, shake128sum, shake256sum, shred, shuf, sleep, sort,  
    split, sum, sync, tac, tail, tee, test, touch, tr, true, truncate, tsort, unexpand, uniq,   
    unlink, vdir, wc, whoami, yes

$ coreutils.exe stat
stat: function/utility not found                                                             
```
@sergeevabc It's not available on Windows. If you're on unix, you can compile with `--features unix` or `--features stat` to access it.
Even though it was supposed to be only a refactor, https://github.com/uutils/coreutils/pull/4150 solves part of this:
```shell
$ cargo run --quiet -- stat --printf='%.1Y\n' f
1668645806
```
At least it shows some useful info and not just `1` 😄 I think the problem is that the precision is misinterpreted as the length of the string instead of the digits after the decimal point or something like that.
I've dug into this issue and found that the implementation of `stat` does not care about type `float`. Furthermore, the function used to get meta info of files `fs::symlink_metadata` or `fs::metadata` returns values no more than type `integer` or `unsigned` in the first place. So I suppose fixing this would require another dependency such as filetime for higher precision. @tertsdiepraam

Fix patch:
diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs
index 5e617e7a31a..a6220267314 100644
--- a/src/uu/stat/src/stat.rs
+++ b/src/uu/stat/src/stat.rs
@@ -94,6 +94,7 @@ pub enum OutputType {
     Unsigned(u64),
     UnsignedHex(u64),
     UnsignedOct(u32),
+    Float(f64),
     Unknown,
 }
 
@@ -120,6 +121,13 @@ impl std::str::FromStr for QuotingStyle {
     }
 }
 
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+enum Precision {
+    NotSpecified,
+    NoNumber,
+    Number(usize),
+}
+
 #[derive(Debug, PartialEq, Eq)]
 enum Token {
     Char(char),
@@ -127,7 +135,7 @@ enum Token {
     Directive {
         flag: Flags,
         width: usize,
-        precision: Option<usize>,
+        precision: Precision,
         format: char,
     },
 }
@@ -238,10 +246,10 @@ struct Stater {
 /// * `output` - A reference to the OutputType enum containing the value to be printed.
 /// * `flags` - A Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed output.
-/// * `precision` - An Option containing the precision value.
+/// * `precision` - How many digits of precision, if any.
 ///
 /// This function delegates the printing process to more specialized functions depending on the output type.
-fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Option<usize>) {
+fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Precision) {
     // If the precision is given as just '.', the precision is taken to be zero.
     // A negative precision is taken as if the precision were omitted.
     // This gives the minimum number of digits to appear for d, i, o, u, x, and X conversions,
@@ -271,7 +279,7 @@ fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Option<u
     // A sign (+ or -) should always be placed before a number produced by a signed conversion.
     // By default, a sign  is  used only for negative numbers.
     // A + overrides a space if both are used.
-    let padding_char = determine_padding_char(&flags, &precision);
+    let padding_char = determine_padding_char(&flags);
 
     match output {
         OutputType::Str(s) => print_str(s, &flags, width, precision),
@@ -283,6 +291,9 @@ fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Option<u
         OutputType::UnsignedHex(num) => {
             print_unsigned_hex(*num, &flags, width, precision, padding_char);
         }
+        OutputType::Float(num) => {
+            print_float(*num, &flags, width, precision, padding_char);
+        }
         OutputType::Unknown => print!("?"),
     }
 }
@@ -292,13 +303,12 @@ fn print_it(output: &OutputType, flags: Flags, width: usize, precision: Option<u
 /// # Arguments
 ///
 /// * `flags` - A reference to the Flags struct containing formatting flags.
-/// * `precision` - An Option containing the precision value.
 ///
 /// # Returns
 ///
 /// * Padding - An instance of the Padding enum representing the padding character.
-fn determine_padding_char(flags: &Flags, precision: &Option<usize>) -> Padding {
-    if flags.zero && !flags.left && precision.is_none() {
+fn determine_padding_char(flags: &Flags) -> Padding {
+    if flags.zero && !flags.left {
         Padding::Zero
     } else {
         Padding::Space
@@ -312,10 +322,10 @@ fn determine_padding_char(flags: &Flags, precision: &Option<usize>) -> Padding {
 /// * `s` - The string to be printed.
 /// * `flags` - A reference to the Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed string.
-/// * `precision` - An Option containing the precision value.
-fn print_str(s: &str, flags: &Flags, width: usize, precision: Option<usize>) {
+/// * `precision` - How many digits of precision, if any.
+fn print_str(s: &str, flags: &Flags, width: usize, precision: Precision) {
     let s = match precision {
-        Some(p) if p < s.len() => &s[..p],
+        Precision::Number(p) if p < s.len() => &s[..p],
         _ => s,
     };
     pad_and_print(s, flags.left, width, Padding::Space);
@@ -415,13 +425,13 @@ fn process_token_filesystem(t: &Token, meta: StatFs, display_name: &str) {
 /// * `num` - The integer value to be printed.
 /// * `flags` - A reference to the Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed integer.
-/// * `precision` - An Option containing the precision value.
+/// * `precision` - How many digits of precision, if any.
 /// * `padding_char` - The padding character as determined by `determine_padding_char`.
 fn print_integer(
     num: i64,
     flags: &Flags,
     width: usize,
-    precision: Option<usize>,
+    precision: Precision,
     padding_char: Padding,
 ) {
     let num = num.to_string();
@@ -437,13 +447,66 @@ fn print_integer(
     } else {
         ""
     };
-    let extended = format!(
-        "{prefix}{arg:0>precision$}",
-        precision = precision.unwrap_or(0)
-    );
+    let extended = match precision {
+        Precision::NotSpecified => format!("{prefix}{arg}"),
+        Precision::NoNumber => format!("{prefix}{arg}"),
+        Precision::Number(p) => format!("{prefix}{arg:0>precision$}", precision = p),
+    };
     pad_and_print(&extended, flags.left, width, padding_char);
 }
 
+/// Truncate a float to the given number of digits after the decimal point.
+fn precision_trunc(num: f64, precision: Precision) -> String {
+    // GNU `stat` doesn't round, it just seems to truncate to the
+    // given precision:
+    //
+    //     $ stat -c "%.5Y" /dev/pts/ptmx
+    //     1736344012.76399
+    //     $ stat -c "%.4Y" /dev/pts/ptmx
+    //     1736344012.7639
+    //     $ stat -c "%.3Y" /dev/pts/ptmx
+    //     1736344012.763
+    //
+    // Contrast this with `printf`, which seems to round the
+    // numbers:
+    //
+    //     $ printf "%.5f\n" 1736344012.76399
+    //     1736344012.76399
+    //     $ printf "%.4f\n" 1736344012.76399
+    //     1736344012.7640
+    //     $ printf "%.3f\n" 1736344012.76399
+    //     1736344012.764
+    //
+    let num_str = num.to_string();
+    let n = num_str.len();
+    match (num_str.find('.'), precision) {
+        (None, Precision::NotSpecified) => num_str,
+        (None, Precision::NoNumber) => num_str,
+        (None, Precision::Number(0)) => num_str,
+        (None, Precision::Number(p)) => format!("{num_str}.{zeros}", zeros = "0".repeat(p)),
+        (Some(i), Precision::NotSpecified) => num_str[..i].to_string(),
+        (Some(_), Precision::NoNumber) => num_str,
+        (Some(i), Precision::Number(0)) => num_str[..i].to_string(),
+        (Some(i), Precision::Number(p)) if p < n - i => num_str[..i + 1 + p].to_string(),
+        (Some(i), Precision::Number(p)) => {
+            format!("{num_str}{zeros}", zeros = "0".repeat(p - (n - i - 1)))
+        }
+    }
+}
+
+fn print_float(num: f64, flags: &Flags, width: usize, precision: Precision, padding_char: Padding) {
+    let prefix = if flags.sign {
+        "+"
+    } else if flags.space {
+        " "
+    } else {
+        ""
+    };
+    let num_str = precision_trunc(num, precision);
+    let extended = format!("{prefix}{num_str}");
+    pad_and_print(&extended, flags.left, width, padding_char)
+}
+
 /// Prints an unsigned integer value based on the provided flags, width, and precision.
 ///
 /// # Arguments
@@ -451,13 +514,13 @@ fn print_integer(
 /// * `num` - The unsigned integer value to be printed.
 /// * `flags` - A reference to the Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed unsigned integer.
-/// * `precision` - An Option containing the precision value.
+/// * `precision` - How many digits of precision, if any.
 /// * `padding_char` - The padding character as determined by `determine_padding_char`.
 fn print_unsigned(
     num: u64,
     flags: &Flags,
     width: usize,
-    precision: Option<usize>,
+    precision: Precision,
     padding_char: Padding,
 ) {
     let num = num.to_string();
@@ -466,7 +529,11 @@ fn print_unsigned(
     } else {
         Cow::Borrowed(num.as_str())
     };
-    let s = format!("{s:0>precision$}", precision = precision.unwrap_or(0));
+    let s = match precision {
+        Precision::NotSpecified => s,
+        Precision::NoNumber => s,
+        Precision::Number(p) => format!("{s:0>precision$}", precision = p).into(),
+    };
     pad_and_print(&s, flags.left, width, padding_char);
 }
 
@@ -477,20 +544,21 @@ fn print_unsigned(
 /// * `num` - The unsigned octal integer value to be printed.
 /// * `flags` - A reference to the Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed unsigned octal integer.
-/// * `precision` - An Option containing the precision value.
+/// * `precision` - How many digits of precision, if any.
 /// * `padding_char` - The padding character as determined by `determine_padding_char`.
 fn print_unsigned_oct(
     num: u32,
     flags: &Flags,
     width: usize,
-    precision: Option<usize>,
+    precision: Precision,
     padding_char: Padding,
 ) {
     let prefix = if flags.alter { "0" } else { "" };
-    let s = format!(
-        "{prefix}{num:0>precision$o}",
-        precision = precision.unwrap_or(0)
-    );
+    let s = match precision {
+        Precision::NotSpecified => format!("{prefix}{num:o}"),
+        Precision::NoNumber => format!("{prefix}{num:o}"),
+        Precision::Number(p) => format!("{prefix}{num:0>precision$o}", precision = p),
+    };
     pad_and_print(&s, flags.left, width, padding_char);
 }
 
@@ -501,20 +569,21 @@ fn print_unsigned_oct(
 /// * `num` - The unsigned hexadecimal integer value to be printed.
 /// * `flags` - A reference to the Flags struct containing formatting flags.
 /// * `width` - The width of the field for the printed unsigned hexadecimal integer.
-/// * `precision` - An Option containing the precision value.
+/// * `precision` - How many digits of precision, if any.
 /// * `padding_char` - The padding character as determined by `determine_padding_char`.
 fn print_unsigned_hex(
     num: u64,
     flags: &Flags,
     width: usize,
-    precision: Option<usize>,
+    precision: Precision,
     padding_char: Padding,
 ) {
     let prefix = if flags.alter { "0x" } else { "" };
-    let s = format!(
-        "{prefix}{num:0>precision$x}",
-        precision = precision.unwrap_or(0)
-    );
+    let s = match precision {
+        Precision::NotSpecified => format!("{prefix}{num:x}"),
+        Precision::NoNumber => format!("{prefix}{num:x}"),
+        Precision::Number(p) => format!("{prefix}{num:0>precision$x}", precision = p),
+    };
     pad_and_print(&s, flags.left, width, padding_char);
 }
 
@@ -530,6 +599,10 @@ impl Stater {
                 '0' => flag.zero = true,
                 '-' => flag.left = true,
                 ' ' => flag.space = true,
+                // This is not documented but the behavior seems to be
+                // the same as a space. For example `stat -c "%I5s" f`
+                // prints "    0".
+                'I' => flag.space = true,
                 '+' => flag.sign = true,
                 '\'' => flag.group = true,
                 _ => break,
@@ -560,7 +633,7 @@ impl Stater {
         Self::process_flags(chars, i, bound, &mut flag);
 
         let mut width = 0;
-        let mut precision = None;
+        let mut precision = Precision::NotSpecified;
         let mut j = *i;
 
         if let Some((field_width, offset)) = format_str[j..].scan_num::<usize>() {
@@ -585,11 +658,11 @@ impl Stater {
             match format_str[j..].scan_num::<i32>() {
                 Some((value, offset)) => {
                     if value >= 0 {
-                        precision = Some(value as usize);
+                        precision = Precision::Number(value as usize);
                     }
                     j += offset;
                 }
-                None => precision = Some(0),
+                None => precision = Precision::NoNumber,
             }
             check_bound(format_str, bound, old, j)?;
         }
@@ -898,7 +971,24 @@ impl Stater {
                     // time of last data modification, human-readable
                     'y' => OutputType::Str(pretty_time(meta.mtime(), meta.mtime_nsec())),
                     // time of last data modification, seconds since Epoch
-                    'Y' => OutputType::Integer(meta.mtime()),
+                    'Y' => {
+                        let sec = meta.mtime();
+                        let nsec = meta.mtime_nsec();
+                        let tm =
+                            chrono::DateTime::from_timestamp(sec, nsec as u32).unwrap_or_default();
+                        let tm: DateTime<Local> = tm.into();
+                        match tm.timestamp_nanos_opt() {
+                            None => {
+                                let micros = tm.timestamp_micros();
+                                let secs = micros as f64 / 1_000_000.0;
+                                OutputType::Float(secs)
+                            }
+                            Some(ns) => {
+                                let secs = ns as f64 / 1_000_000_000.0;
+                                OutputType::Float(secs)
+                            }
+                        }
+                    }
                     // time of last status change, human-readable
                     'z' => OutputType::Str(pretty_time(meta.ctime(), meta.ctime_nsec())),
                     // time of last status change, seconds since Epoch
@@ -1107,7 +1197,7 @@ fn pretty_time(sec: i64, nsec: i64) -> String {
 
 #[cfg(test)]
 mod tests {
-    use super::{group_num, Flags, ScanUtil, Stater, Token};
+    use super::{group_num, precision_trunc, Flags, Precision, ScanUtil, Stater, Token};
 
     #[test]
     fn test_scanners() {
@@ -1155,7 +1245,7 @@ mod tests {
                     ..Default::default()
                 },
                 width: 10,
-                precision: Some(2),
+                precision: Precision::Number(2),
                 format: 'a',
             },
             Token::Char('c'),
@@ -1166,7 +1256,7 @@ mod tests {
                     ..Default::default()
                 },
                 width: 5,
-                precision: Some(0),
+                precision: Precision::NoNumber,
                 format: 'w',
             },
             Token::Char('\n'),
@@ -1186,7 +1276,7 @@ mod tests {
                     ..Default::default()
                 },
                 width: 15,
-                precision: None,
+                precision: Precision::NotSpecified,
                 format: 'a',
             },
             Token::Byte(b'\t'),
@@ -1205,7 +1295,7 @@ mod tests {
                     ..Default::default()
                 },
                 width: 20,
-                precision: None,
+                precision: Precision::NotSpecified,
                 format: 'w',
             },
             Token::Byte(b'\x12'),
@@ -1216,4 +1306,16 @@ mod tests {
         ];
         assert_eq!(&expected, &Stater::generate_tokens(s, true).unwrap());
     }
+
+    #[test]
+    fn test_precision_trunc() {
+        assert_eq!(precision_trunc(123.456, Precision::NotSpecified), "123");
+        assert_eq!(precision_trunc(123.456, Precision::NoNumber), "123.456");
+        assert_eq!(precision_trunc(123.456, Precision::Number(0)), "123");
+        assert_eq!(precision_trunc(123.456, Precision::Number(1)), "123.4");
+        assert_eq!(precision_trunc(123.456, Precision::Number(2)), "123.45");
+        assert_eq!(precision_trunc(123.456, Precision::Number(3)), "123.456");
+        assert_eq!(precision_trunc(123.456, Precision::Number(4)), "123.4560");
+        assert_eq!(precision_trunc(123.456, Precision::Number(5)), "123.45600");
+    }
 }


Test patch:
diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs
index cbd36832f48..cd74767283a 100644
--- a/tests/by-util/test_stat.rs
+++ b/tests/by-util/test_stat.rs
@@ -184,9 +184,74 @@ fn test_char() {
     ];
     let ts = TestScenario::new(util_name!());
     let expected_stdout = unwrap_or_return!(expected_result(&ts, &args)).stdout_move_str();
+    eprintln!("{expected_stdout}");
     ts.ucmd().args(&args).succeeds().stdout_is(expected_stdout);
 }
 
+#[cfg(target_os = "linux")]
+#[test]
+fn test_printf_mtime_precision() {
+    // TODO Higher precision numbers (`%.3Y`, `%.4Y`, etc.) are
+    // formatted correctly, but we are not precise enough when we do
+    // some `mtime` computations, so we get `.7640` instead of
+    // `.7639`. This can be fixed by being more careful when
+    // transforming the number from `Metadata::mtime_nsec()` to the form
+    // used in rendering.
+    let args = ["-c", "%.0Y %.1Y %.2Y", "/dev/pts/ptmx"];
+    let ts = TestScenario::new(util_name!());
+    let expected_stdout = unwrap_or_return!(expected_result(&ts, &args)).stdout_move_str();
+    eprintln!("{expected_stdout}");
+    ts.ucmd().args(&args).succeeds().stdout_is(expected_stdout);
+}
+
+#[cfg(feature = "touch")]
+#[test]
+fn test_timestamp_format() {
+    let ts = TestScenario::new(util_name!());
+
+    // Create a file with a specific timestamp for testing
+    ts.ccmd("touch")
+        .args(&["-d", "1970-01-01 18:43:33.023456789", "k"])
+        .succeeds()
+        .no_stderr();
+
+    let test_cases = vec![
+        // Basic timestamp formats
+        ("%Y", "67413"),
+        ("%.Y", "67413.023456789"),
+        ("%.1Y", "67413.0"),
+        ("%.3Y", "67413.023"),
+        ("%.6Y", "67413.023456"),
+        ("%.9Y", "67413.023456789"),
+        // Width and padding tests
+        ("%13.6Y", " 67413.023456"),
+        ("%013.6Y", "067413.023456"),
+        ("%-13.6Y", "67413.023456 "),
+        // Longer width/precision combinations
+        ("%18.10Y", "  67413.0234567890"),
+        ("%I18.10Y", "  67413.0234567890"),
+        ("%018.10Y", "0067413.0234567890"),
+        ("%-18.10Y", "67413.0234567890  "),
+    ];
+
+    for (format_str, expected) in test_cases {
+        let result = ts
+            .ucmd()
+            .args(&["-c", format_str, "k"])
+            .succeeds()
+            .stdout_move_str();
+
+        assert_eq!(
+            result,
+            format!("{expected}\n"),
+            "Format '{}' failed.\nExpected: '{}'\nGot: '{}'",
+            format_str,
+            expected,
+            result,
+        );
+    }
+}
+
 #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
 #[test]
 fn test_date() {