Repository: BurntSushi/jiff (language: rust)

Issue:
question: ASCII is currently required when format timestamp
Hi It's fine to see this good project. I'm trying to test jiff format.

```rust
use jiff::{civil::date, fmt::strtime::BrokenDownTime};

fn main() {
    let zdt = date(2024, 7, 9)
        .at(16, 24, 0, 0)
        .intz("America/New_York")
        .unwrap();
    let tm = BrokenDownTime::from(&zdt);

    let mut buf = String::new();
    tm.format("%aの%b %e %I:%M:%S %p %Z %Y", &mut buf).unwrap();
    println!("{}", buf);

    assert_eq!(buf, "Tue Jul  9 04:24:00 PM EDT 2024");
}

```

It will be panic:

```shell
➜  jiff-example git:(master) ✗ cargo run      
   Compiling jiff-example v0.1.0 (/data1/eason/jiff-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/jiff-example`
thread 'main' panicked at src/main.rs:8:52:
called `Result::unwrap()` on an `Err` value: Error { inner: ErrorInner { kind: Adhoc(strftime formatting failed), cause: Some(Error { inner: ErrorInner { kind: Adhoc(found non-ASCII byte "\xE3" in format string (ASCII is currently required)), cause: None } }) } }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

```

Is there any other way to format timestamp?


Discussion / hints:
Looks like it's by design. https://github.com/BurntSushi/jiff/blob/9d2b8a6963a975cf7ab2f95a310fde82ad833b7f/src/fmt/strtime/format.rs#L31
Yeah, that's what the error message says. :-) I can take a look at this. I don't remember off the top of my head why I required ASCII. It might be perf related. Otherwise, I think the main idea here is that if you're looking to use non-ASCII, then my assumption was that locale-unware datetime formatting is probably not what you want.

Can you say more about what you're trying to do?
Fine. 

https://github.com/databendlabs/databend/issues/16508 

Now I try to use jiff replace Chrono. In Databend we support a function named date_format(timestamp, format) return a string value. It support convert ts to string with the specific format.

e.g. 

```
query T
select date_format('2022-02-04T03:58:59', '%Y年%m月%d日，%H时%M分%S秒');
----
2022年02月04日，03时58分59秒
```



Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8fbd0d6..311c9a3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,11 @@ Enhancements:
 * [#130](https://github.com/BurntSushi/jiff/issues/130):
 Document value ranges for methods like `year`, `day`, `hour` and so on.
 
+Bug fixes:
+
+* [#155](https://github.com/BurntSushi/jiff/issues/155):
+Relax `strftime` format strings from ASCII-only to all of UTF-8.
+
 
 0.1.18 (2024-12-31)
 ===================
diff --git a/src/fmt/strtime/format.rs b/src/fmt/strtime/format.rs
index 26a88920..2fe8a6ff 100644
--- a/src/fmt/strtime/format.rs
+++ b/src/fmt/strtime/format.rs
@@ -9,7 +9,7 @@ use crate::{
         Write, WriteExt,
     },
     tz::Offset,
-    util::escape,
+    util::{escape, utf8},
     Error,
 };
 
@@ -23,15 +23,13 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
     pub(super) fn format(&mut self) -> Result<(), Error> {
         while !self.fmt.is_empty() {
             if self.f() != b'%' {
-                if !self.f().is_ascii() {
-                    return Err(err!(
-                        "found non-ASCII byte {byte:?} in format \
-                         string (ASCII is currently required)",
-                        byte = escape::Byte(self.f()),
-                    ));
+                if self.f().is_ascii() {
+                    self.wtr.write_char(char::from(self.f()))?;
+                    self.bump_fmt();
+                } else {
+                    let ch = self.utf8_decode_and_bump()?;
+                    self.wtr.write_char(ch)?;
                 }
-                self.wtr.write_char(char::from(self.f()))?;
-                self.bump_fmt();
                 continue;
             }
             if !self.bump_fmt() {
@@ -141,6 +139,35 @@ impl<'f, 't, 'w, W: Write> Formatter<'f, 't, 'w, W> {
         !self.fmt.is_empty()
     }
 
+    /// Decodes a Unicode scalar value from the beginning of `fmt` and advances
+    /// the parser accordingly.
+    ///
+    /// If a Unicode scalar value could not be decoded, then an error is
+    /// returned.
+    ///
+    /// It would be nice to just pass through bytes as-is instead of doing
+    /// actual UTF-8 decoding, but since the `Write` trait only represents
+    /// Unicode-accepting buffers, we need to actually do decoding here.
+    ///
+    /// # Panics
+    ///
+    /// When `self.fmt` is empty. i.e., Only call this when you know there is
+    /// some remaining bytes to parse.
+    #[inline(never)]
+    fn utf8_decode_and_bump(&mut self) -> Result<char, Error> {
+        match utf8::decode(self.fmt).expect("non-empty fmt") {
+            Ok(ch) => {
+                self.fmt = &self.fmt[ch.len_utf8()..];
+                return Ok(ch);
+            }
+            Err(invalid) => Err(err!(
+                "found invalid UTF-8 byte {byte:?} in format \
+                 string (format strings must be valid UTF-8)",
+                byte = escape::Byte(invalid),
+            )),
+        }
+    }
+
     /// Parses optional extensions before a specifier directive. That is, right
     /// after the `%`. If any extensions are parsed, the parser is bumped
     /// to the next byte. (If no next byte exists, then an error is returned.)
diff --git a/src/fmt/strtime/mod.rs b/src/fmt/strtime/mod.rs
index f824fabf..e7ada240 100644
--- a/src/fmt/strtime/mod.rs
+++ b/src/fmt/strtime/mod.rs
@@ -2168,4 +2168,26 @@ mod tests {
             @"2024-07-30T00:56:25+04:00[+04:00]",
         );
     }
+
+    // Regression test for format strings with non-ASCII in them.
+    //
+    // We initially didn't support non-ASCII because I had thought it wouldn't
+    // be used. i.e., If someone wanted to do something with non-ASCII, then
+    // I thought they'd want to be using something more sophisticated that took
+    // locale into account. But apparently not.
+    //
+    // See: https://github.com/BurntSushi/jiff/issues/155
+    #[test]
+    fn ok_non_ascii() {
+        let fmt = "%Y年%m月%d日，%H时%M分%S秒";
+        let dt = crate::civil::date(2022, 2, 4).at(3, 58, 59, 0);
+        insta::assert_snapshot!(
+            dt.strftime(fmt),
+            @"2022年02月04日，03时58分59秒",
+        );
+        insta::assert_debug_snapshot!(
+            DateTime::strptime(fmt, "2022年02月04日，03时58分59秒").unwrap(),
+            @"2022-02-04T03:58:59",
+        );
+    }
 }


Test patch: