diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a3f310e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +Changelog +========= + +All notable changes to this project will be documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] + +### Added + +- `Truncated<'_>` error type, returned by the `WriteBuf::finish*` family. Carries the portion of the output that was + successfully written so callers don't have to track it separately. +- `WriteBuf::written()` returns the written portion as a `&str` (companion to the existing `written_bytes()` accessor). + +### Changed + +- **Breaking:** `WriteBuf::finish`, `WriteBuf::finish_with`, and `WriteBuf::finish_with_or` now return + `Result<&str, Truncated<'_>>` instead of `Result`. The new shape gives callers the validated + string slice directly without a separate `from_utf8` step on the underlying buffer. +- **Breaking:** `finish_with` and `finish_with_or` now require `impl AsRef` for the suffix arguments + (previously `impl AsRef<[u8]>`). This keeps the UTF-8 invariant at the type level rather than relying on a + runtime check. +- **Breaking:** Minimum supported Rust version raised to 1.81 (for stable `core::error::Error`); edition is now 2021. + +### Removed + +- **Breaking:** `rfind_utf8_end` is no longer re-exported from the crate root. It remains as a crate-internal helper. + +### Migration from 0.1.x + +- Replace `let n = writer.finish().unwrap();` with `let s = writer.finish().unwrap();` -- the success value is now + `&str` rather than the byte count. +- For the error path, use `e.get()` to access the partially-written `&str`: + `writer.finish().unwrap_or_else(|e| e.get())`. +- If a `finish_with*` call passed a non-`str` buffer, convert via `core::str::from_utf8` first. + +## [0.1.2] β€” 2025-09-24 + +- Documentation additions describing the lack of semantic Unicode support on truncation. + +## [0.1.1] + +- Added `WriteBuf` accessors to query and alter the reserve byte count. + +## [0.1.0] + +- Initial release. diff --git a/Cargo.toml b/Cargo.toml index ad1dbff..cf6547e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "fmtbuf" -version = "0.1.2" -edition = "2018" -rust-version = "1.31" +version = "0.2.0" +edition = "2021" +rust-version = "1.81" authors = ["Travis Gockel "] license = "Apache-2.0" description = "Utilities for formatting to a fixed-size buffer" -keywords = ["string", "format"] +keywords = ["buffer", "truncation", "utf-8", "ffi", "no-std"] categories = ["development-tools::ffi", "no-std"] readme = "README.md" repository = "https://github.com/tgockel/fmtbuf" @@ -21,4 +21,4 @@ std = [] clap = { version = "4.0.0", features = ["derive"] } [badges] -maintenance = { status = "actively-developed" } +maintenance = { status = "experimental" } # this line is currently experimental diff --git a/README.md b/README.md index a42b340..f4ca985 100644 --- a/README.md +++ b/README.md @@ -5,40 +5,44 @@ Write a formatted string into a fixed buffer. This is useful when you have a user-provided buffer you want to write into, which frequently arises when writing foreign function interfaces for C, where strings are expected to have a null terminator. -Usage ------ - ```rust use fmtbuf::WriteBuf; -use std::fmt::Write; +use core::fmt::Write; -fn main() { - let mut buf: [u8; 10] = [0; 10]; - let mut writer = WriteBuf::new(&mut buf); - if let Err(e) = write!(&mut writer, "πŸš€πŸš€πŸš€") { - println!("write error: {e:?}"); - } - let written_len = match writer.finish_with("\0") { - Ok(len) => len, // <- won't be hit since πŸš€πŸš€πŸš€ is 12 bytes - Err(len) => { - println!("writing was truncated"); - len - } - }; - let written = &buf[..written_len]; - println!("wrote {written_len} bytes: {written:?}"); - println!("result: {:?}", std::str::from_utf8(written)); +let mut buf: [u8; 10] = [0; 10]; +let mut writer = WriteBuf::new(&mut buf); +if let Err(e) = write!(&mut writer, "πŸš€πŸš€πŸš€") { + println!("write error: {e:?}"); } +let written = match writer.finish_with_or("!", "…") { + Ok(s) => s, // <- won't be hit since πŸš€πŸš€πŸš€ is 12 bytes + Err(e) => { + println!("writing was truncated"); + e.get() + } +}; +assert_eq!("πŸš€β€¦", written); ``` -πŸš€πŸš€ +A few things happened in that example: + +1. We started with a 10 byte buffer +2. Tried to write `"πŸš€πŸš€πŸš€"` to it, which is encoded as 3 `b"\xf0\x9f\x9a\x80"`s (12 bytes) +3. This can't fit into 10 bytes, so only `"πŸš€πŸš€"` is stored and the `writer` is noted as having truncated writes +4. We finish the buffer with `"!"` on success or `"…"` (a.k.a. `b"\xe2\x80\xa6"`) on truncation +5. Since we noted truncation in step #3, we try to write `"…"`, but this can not fit into the buffer either, since + 8 (`"πŸš€πŸš€".len()`) + 3 (`"…".len()`) > 10 (`buf.len()`) +6. Roll the buffer back to the end of the first πŸš€, then add …, leaving us with `"πŸš€β€¦"` + +Usage +----- The primary use case is for implementing APIs like [`strerror_r`](https://linux.die.net/man/3/strerror_r), where the user provides the buffer. ```rust use std::{ffi, fmt::Write, io::Error}; -use fmtbuf::WriteBuf; +use fmtbuf::{TruncatedResultExt, WriteBuf}; #[no_mangle] pub unsafe extern "C" fn mylib_strerror( @@ -47,7 +51,7 @@ pub unsafe extern "C" fn mylib_strerror( buf_len: usize ) { let mut buf = unsafe { - // Buffer provided by a users + // Buffer provided by user std::slice::from_raw_parts_mut(buf as *mut u8, buf_len) }; // Reserve at least 1 byte at the end because we will always @@ -56,14 +60,17 @@ pub unsafe extern "C" fn mylib_strerror( // Use the standard `write!` macro (no error handling for // brevity) -- note that an error here might only indicate - // write truncation, which is handled gracefully be this + // write truncation, which is handled gracefully by this // library's finish___ functions let _ = write!(writer, "{}", err.as_ref().unwrap()); - // null-terminate buffer or add "..." if it was truncated - let _written_len = writer.finish_with_or(b"\0", b"...\0") - // Err value is also number of bytes written - .unwrap_or_else(|e| e); + // null-terminate buffer or add "..." if it was truncated. + // `TruncatedResultExt` extracts the written `&str` (and its + // byte length) regardless of whether truncation occurred -- + // useful for setting an FFI out-param. + let result = writer.finish_with_or("\0", "...\0"); + let _written = result.written(); + let _written_len = result.written_len(); } ``` @@ -75,7 +82,7 @@ Features Support for `!#[no_std]` is enabled by disabling the default features and not re-enabling the `"std"` feature. ```toml -fmtbuf = { version = "*", default_features = false } +fmtbuf = { version = "*", default-features = false } ``` F.A.Q. @@ -173,3 +180,10 @@ face a similar issue. Where should the cutoff be? This library does not know the difference between "π“ͺπ“Œπ“ƒ»" and "π“ͺπ“Œ". Figuring that out is the responsibility of a higher-level construct. + +#### οΏ½ + +This library implements [`core::fmt::Write`](https://doc.rust-lang.org/stable/core/fmt/trait.Write.html), which only +accepts UTF-8-encoded data. +There is no place for οΏ½ in this library. +However, the result of a truncated run might be replaced by οΏ½ for presentation at a higher level. diff --git a/examples/writebuf.rs b/examples/writebuf.rs index 950f361..2da59aa 100644 --- a/examples/writebuf.rs +++ b/examples/writebuf.rs @@ -62,26 +62,17 @@ fn main() { (None, Some(truncate)) => writer.finish_with_or("", truncate), (Some(finish), Some(truncate)) => writer.finish_with_or(finish, truncate), }; - let (written_len, truncated) = match result { - Ok(len) => (len, false), - Err(len) => (len, true), + let (contents, truncated) = match result { + Ok(s) => (s, false), + Err(e) => (e.get(), true), }; + println!("{contents}"); - let contents = match std::str::from_utf8(&buf[..written_len]) { - Ok(contents) => { - println!("{contents}"); - contents.as_bytes() - }, - Err(e) => { - println!("! error: {e:?}"); - &buf[..written_len] - }, - }; if !cli.quiet { println!("+ version: {}", env!("CARGO_PKG_VERSION")); - println!("+ written_len: {written_len}"); + println!("+ written_len: {}", contents.len()); println!("+ truncated: {truncated}"); - println!("+ output_bytes: {contents:?}"); + println!("+ output_bytes: {:?}", contents.as_bytes()); println!("+ input: {}", cli.input); println!("+ input_bytes: {:?}", cli.input.as_bytes()); } diff --git a/src/lib.rs b/src/lib.rs index 4035b2a..eb5c183 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,50 +1,20 @@ -//! # `fmtbuf` -//! This library is intended to help write formatted text to fixed buffers. -//! -//! ``` -//! use fmtbuf::WriteBuf; -//! use std::fmt::Write; -//! -//! let mut buf: [u8; 10] = [0; 10]; -//! let mut writer = WriteBuf::new(&mut buf); -//! if let Err(e) = write!(&mut writer, "πŸš€πŸš€πŸš€") { -//! println!("write error: {e:?}"); -//! } -//! let written_len = match writer.finish_with_or("!", "…") { -//! Ok(len) => len, // <- won't be hit since πŸš€πŸš€πŸš€ is 12 bytes -//! Err(len) => { -//! println!("writing was truncated"); -//! len -//! } -//! }; -//! let written = &buf[..written_len]; -//! assert_eq!("πŸš€β€¦", std::str::from_utf8(written).unwrap()); -//! ``` -//! -//! A few things happened in that example: -//! -//! 1. We stared with a 10 byte buffer -//! 2. Tried to write `"πŸš€πŸš€πŸš€"` to it, which is encoded as 3 `b"\xf0\x9f\x9a\x80"`s (12 bytes) -//! 3. This can't fit into 10 bytes, so only `"πŸš€πŸš€"` is stored and the `writer` is noted as having truncated writes -//! 4. We finish the buffer with `"!"` on success or `"…"` (a.k.a. `b"\xe2\x80\xa6"`) on truncation -//! 5. Since we noted truncation in step #3, we try to write `"…"`, but this can not fit into the buffer either, since -//! 8 (`"πŸš€πŸš€".len()`) + 3 (`"…".len()`) > 12 (`buf.len()`) -//! 6. Roll the buffer back to the end of the first πŸš€, then add …, leaving us with `"πŸš€β€¦"` - +#![doc = include_str!("../README.md")] #![cfg_attr(not(feature = "std"), no_std)] +mod truncated; mod utf8; use core::fmt; -#[deprecated] -pub use utf8::rfind_utf8_end; +pub use truncated::{Truncated, TruncatedResultExt}; + +use utf8::rfind_utf8_end; /// A write buffer pointing to a `&mut [u8]`. /// /// ``` /// use fmtbuf::WriteBuf; -/// use std::fmt::Write; +/// use core::fmt::Write; /// /// // The buffer to write into. The contents can be uninitialized, but using a /// // bogus `\xff` sigil for demonstration. @@ -55,8 +25,7 @@ pub use utf8::rfind_utf8_end; /// write!(writer, "some data: {}", 0x01a4).unwrap(); /// /// // Finish writing: -/// let write_len = writer.finish().unwrap(); -/// let written = std::str::from_utf8(&buf[..write_len]).unwrap(); +/// let written = writer.finish().unwrap(); /// assert_eq!(written, "some data: 420"); /// ``` pub struct WriteBuf<'a> { @@ -86,6 +55,20 @@ impl<'a> WriteBuf<'a> { /// useful when you know that you will always `finish_with` a null terminator or other character. /// /// It is allowed to have `target.len() < reserve`, but this can never be written to. + /// + /// ``` + /// use fmtbuf::WriteBuf; + /// use core::fmt::Write; + /// + /// // Reserve one byte at the end for a null terminator. + /// let mut buf: [u8; 8] = [0xff; 8]; + /// let mut writer = WriteBuf::with_reserve(&mut buf, 1); + /// + /// // Only 7 bytes are writable; the rest is held for the suffix. + /// let _ = write!(writer, "abcdefgh"); + /// let written = writer.finish_with("\0").unwrap_err().get(); + /// assert_eq!(written, "abcdefg\0"); + /// ``` pub fn with_reserve(target: &'a mut [u8], reserve: usize) -> Self { Self { target, @@ -125,37 +108,37 @@ impl<'a> WriteBuf<'a> { /// Get the contents that have been written so far. pub fn written(&self) -> &str { - #[cfg(debug_assertions)] - return core::str::from_utf8(self.written_bytes()).expect("contents of buffer should have been UTF-8 encoded"); - // safety: The only way to write into the buffer is with valid UTF-8, so there is no reason to check the - // contents for validity. They're still checked in debug builds just in case, though. - #[cfg(not(debug_assertions))] - unsafe { - core::str::from_utf8_unchecked(self.written_bytes()) - } + // contents for validity. + unsafe { from_utf8_expect(self.written_bytes()) } } /// Finish writing to the buffer. This returns control of the target buffer to the caller (it is no longer mutably - /// borrowed) and returns the number of bytes written. + /// borrowed). /// /// # Returns /// - /// In both the `Ok` and `Err` cases, the [`WriteBuf::position`] is returned. The `Ok` case indicates the truncation - /// did not occur, while `Err` indicates that it did. - pub fn finish(self) -> Result { - if self.truncated() { - Err(self.position()) + /// In both the `Ok` and `Err` cases, the successfully-written portion of the output is returned as a `&str`. The + /// `Ok` case indicates the truncation did not occur, while `Err` indicates that it did. + pub fn finish(self) -> Result<&'a str, Truncated<'a>> { + self.into_result() + } + + fn into_result(self) -> Result<&'a str, Truncated<'a>> { + // safety: The only way to write into the buffer is with valid UTF-8 + let written = unsafe { from_utf8_expect(&self.target[..self.position]) }; + if self.truncated { + Err(Truncated(written)) } else { - Ok(self.position()) + Ok(written) } } /// Finish the buffer, adding the `suffix` to the end. A common use case for this is to add a null terminator. /// /// This operates slightly differently than the normal format writing function `write_str` in that the `suffix` is - /// always put at the end. The only case where this will not happen is when `suffix.len()` is less than the size of - /// the buffer originally provided. In this case, the last bit of `suffix` will be copied (starting at a valid UTF-8 + /// always put at the end. The only case where this will not happen is when `suffix.len()` exceeds the size of the + /// buffer originally provided. In this case, the last bit of `suffix` will be copied (starting at a valid UTF-8 /// sequence start; e.g.: writing `"πŸš€..."` to a 5 byte buffer will leave you with just `"..."`, no matter what was /// written before). /// @@ -163,34 +146,49 @@ impl<'a> WriteBuf<'a> { /// use fmtbuf::WriteBuf; /// /// let mut buf: [u8; 4] = [0xff; 4]; - /// let mut writer = WriteBuf::new(&mut buf); + /// let writer = WriteBuf::new(&mut buf); /// /// // Finish writing with too many bytes: - /// let write_len = writer.finish_with("12345").unwrap_err(); - /// assert_eq!(write_len, 4); - /// let buf_str = std::str::from_utf8(&buf).unwrap(); - /// assert_eq!(buf_str, "2345"); + /// let written = writer.finish_with("12345").unwrap_err().get(); + /// assert_eq!(written, "2345"); /// ``` /// /// # Returns /// /// The returned value has the same meaning as [`WriteBuf::finish`]. - pub fn finish_with(self, suffix: impl AsRef<[u8]>) -> Result { + pub fn finish_with(self, suffix: impl AsRef) -> Result<&'a str, Truncated<'a>> { let suffix = suffix.as_ref(); self._finish_with(suffix, suffix) } /// Finish the buffer by adding `normal_suffix` if not truncated or `truncated_suffix` if the buffer will be /// truncated. This operates the same as [`WriteBuf::finish_with`] in every other way. + /// + /// ``` + /// use fmtbuf::WriteBuf; + /// use core::fmt::Write; + /// + /// // Plenty of room: the normal suffix is appended. + /// let mut buf: [u8; 8] = [0xff; 8]; + /// let mut writer = WriteBuf::new(&mut buf); + /// write!(writer, "abc").unwrap(); + /// assert_eq!(writer.finish_with_or("!", "...").unwrap(), "abc!"); + /// + /// // Doesn't fit: the truncated suffix is used instead. + /// let mut buf: [u8; 4] = [0xff; 4]; + /// let mut writer = WriteBuf::new(&mut buf); + /// let _ = write!(writer, "abcdef"); + /// assert_eq!(writer.finish_with_or("!", "...").unwrap_err().get(), "a..."); + /// ``` pub fn finish_with_or( self, - normal_suffix: impl AsRef<[u8]>, - truncated_suffix: impl AsRef<[u8]>, - ) -> Result { + normal_suffix: impl AsRef, + truncated_suffix: impl AsRef, + ) -> Result<&'a str, Truncated<'a>> { self._finish_with(normal_suffix.as_ref(), truncated_suffix.as_ref()) } - fn _finish_with(mut self, normal: &[u8], truncated: &[u8]) -> Result { + fn _finish_with(mut self, normal: &str, truncated: &str) -> Result<&'a str, Truncated<'a>> { let remaining = self.target.len() - self.position(); // If the truncated case is shorter than the normal case, then writing it might still work @@ -201,13 +199,9 @@ impl<'a> WriteBuf<'a> { // enough room in the buffer to write entire suffix, so just write it if suffix.len() <= remaining { - self.target[self.position..self.position + suffix.len()].copy_from_slice(suffix); + self.target[self.position..self.position + suffix.len()].copy_from_slice(suffix.as_bytes()); self.position += suffix.len(); - return if self.truncated() { - Err(self.position()) - } else { - Ok(self.position()) - }; + return self.into_result(); } // we attempted to perform a write, but rejected it @@ -218,25 +212,32 @@ impl<'a> WriteBuf<'a> { // if the suffix is larger than the entire target buffer, copy the last N if self.target.len() < suffix.len() { - let copyable_suffix = &suffix[suffix.len() - self.target.len()..]; + let suffix_bytes = suffix.as_bytes(); + let copyable_suffix = &suffix_bytes[suffix.len() - self.target.len()..]; let Some(valid_utf8_idx) = copyable_suffix .iter() .enumerate() .find(|(_, cu)| utf8::utf8_char_width(**cu).is_some()) .map(|(idx, _)| idx) else { - return Err(0); + self.position = 0; + self.truncated = true; + return self.into_result(); }; let copyable_suffix = ©able_suffix[valid_utf8_idx..]; self.target[..copyable_suffix.len()].copy_from_slice(copyable_suffix); - return Err(copyable_suffix.len()); + self.position = copyable_suffix.len(); + self.truncated = true; + return self.into_result(); } // Scan backwards to find the position we should write to (can't interrupt a UTF-8 multibyte sequence) let potential_end_idx = self.target.len() - suffix.len(); let write_idx = rfind_utf8_end(&self.target[..potential_end_idx]); - self.target[write_idx..write_idx + suffix.len()].copy_from_slice(suffix); - Err(write_idx + suffix.len()) + self.target[write_idx..write_idx + suffix.len()].copy_from_slice(suffix.as_bytes()); + self.position = write_idx + suffix.len(); + self.truncated = true; + self.into_result() } fn _write(&mut self, input: &[u8]) -> fmt::Result { @@ -286,203 +287,23 @@ impl<'a> fmt::Write for WriteBuf<'a> { } } -#[cfg(test)] -mod test { - use super::*; - use core::fmt::Write; - - /// * `.0`: Input string - /// * `.1`: The end position if the last byte was chopped off - static TEST_CASES: &[(&str, usize)] = &[ - ("", 0), - ("James", 4), - ("_ΓΈ", 1), - ("磨", 0), - ("here: 见/見", 10), - ("π¨‰Ÿε‘γ—‚θΆŠ", 10), - ("πŸš€", 0), - ("πŸš€πŸš€πŸš€", 8), - ("rocket: πŸš€", 8), - ]; - - #[test] - fn rfind_utf8_end_test() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - let result = rfind_utf8_end(input.as_bytes()); - assert_eq!(result, input.len(), "input=\"{input}\""); - if input.len() == 0 { - continue; - } - let input_truncated = &input.as_bytes()[..input.len() - 1]; - let result = rfind_utf8_end(input_truncated); - assert_eq!( - result, *last_valid_idx_after_cut, - "input=\"{input}\" truncated={input_truncated:?}" - ); - } - } - - #[test] - fn format_enough_space() { - for (input, _) in TEST_CASES.iter() { - let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::new(&mut buf); - - writer.write_str(input).unwrap(); - assert_eq!(input.len(), writer.position()); - let last_idx = writer.finish().unwrap(); - assert_eq!(input.len(), last_idx); - } - } - - #[test] - fn format_enough_space_just_enough_reserved() { - for (input, _) in TEST_CASES.iter() { - let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::with_reserve(&mut buf[..input.len() + 1], 1); - - writer.write_str(input).unwrap(); - assert_eq!(input.len(), writer.position()); - let last_idx = writer.finish().unwrap(); - assert_eq!(input.len(), last_idx); - } - } - - #[test] - fn format_truncation() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - if input.len() == 0 { - continue; - } - - let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::new(&mut buf[..input.len() - 1]); - - writer.write_str(input).unwrap_err(); - assert_eq!(*last_valid_idx_after_cut, writer.position()); - assert!(writer.truncated()); - write!(writer, "!!!").expect_err("writes should fail here"); - - let last_idx = writer.finish().unwrap_err(); - assert_eq!(*last_valid_idx_after_cut, last_idx); - } - } - - struct SimpleString { - storage: [u8; 128], - size: usize, - } - - impl SimpleString { - pub fn from_segments(segments: &[&str]) -> Self { - let mut out = Self { - storage: [0; 128], - size: 0, - }; - for segment in segments { - out.append(segment); - } - out - } - - pub fn append(&mut self, value: &str) { - let value = value.as_bytes(); - self.storage[self.size..self.size + value.len()].copy_from_slice(value); - self.size += value.len(); - } - - pub fn as_str(&self) -> &str { - core::str::from_utf8(&self.storage[..self.size]).unwrap() - } - } - - impl From<&str> for SimpleString { - fn from(value: &str) -> Self { - Self::from_segments(&[value]) - } - } - - #[test] - fn finish_with_enough_space() { - for (input, _) in TEST_CASES.iter() { - let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::new(&mut buf); - - writer.write_str(input).unwrap(); - let position = writer.finish_with(b".123").unwrap(); - assert_eq!(position, input.len() + 4); - let expected_written = SimpleString::from_segments(&[input, ".123"]); - let actually_wriiten = core::str::from_utf8(&buf[..position]).unwrap(); - assert_eq!(expected_written.as_str(), actually_wriiten); - } - } - - #[test] - fn finish_with_overwrite() { - for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { - if input.len() == 0 { - continue; - } - - let mut buf: [u8; 128] = [0xff; 128]; - let mut writer = WriteBuf::new(&mut buf[..input.len()]); - - writer.write_str(input).unwrap(); - let position = writer.finish_with("?").unwrap_err(); - assert_eq!(position, last_valid_idx_after_cut + 1); - let expected_written = SimpleString::from_segments(&[ - core::str::from_utf8(&input.as_bytes()[..*last_valid_idx_after_cut]).unwrap(), - "?", - ]); - let actually_wriiten = core::str::from_utf8(&buf[..position]).unwrap(); - assert_eq!(expected_written.as_str(), actually_wriiten); - } - } - - #[test] - fn finish_with_or_with_longer_normal_closer() { - let mut buf: [u8; 4] = [0xff; 4]; - let writer = WriteBuf::new(&mut buf); - - let written = writer.finish_with_or("0123456789", "abc").unwrap_err(); - assert_eq!(written, 3); - assert_eq!("abc", core::str::from_utf8(&buf[..written]).unwrap()); - } - - #[test] - fn finish_with_full_overwrite_utf8() { - let mut buf: [u8; 4] = [0xff; 4]; - let writer = WriteBuf::new(&mut buf); - - let written = writer.finish_with("πŸš€12").unwrap_err(); - assert_eq!(written, 2); - assert_eq!("12", core::str::from_utf8(&buf[..written]).unwrap()); - } - - #[test] - fn set_reserve_should_not_change_written() { - let mut buf: [u8; 10] = [0xff; 10]; - let mut writer = WriteBuf::new(&mut buf); - - write!(writer, "0123456789").unwrap(); - assert_eq!("0123456789", writer.written()); - - writer.set_reserve(4); - assert_eq!("0123456789", writer.written()); - - writer.finish_with_or("", "!").unwrap(); - assert_eq!("0123456789", core::str::from_utf8(&buf).unwrap()); +/// Extract a `&str` from source `&[u8]`, expecting it to be valid UTF-8. +/// +/// # Safety +/// +/// Under the covers, this calls `str::from_utf8` if debug assertions are enabled, otherwise it calls +/// `str::from_utf8_unchecked`. This means `src` should always be valid UTF-8. +unsafe fn from_utf8_expect(src: &[u8]) -> &str { + #[cfg(debug_assertions)] + return core::str::from_utf8(src).expect("buffer should have been valid UTF-8"); + + // safety: The contents are valid UTF-8 by construction; debug builds verify the invariant via the + // `cfg(debug_assertions)` branch above. + #[cfg(not(debug_assertions))] + unsafe { + core::str::from_utf8_unchecked(src) } } -#[cfg(doctest)] -mod test_readme { - macro_rules! external_doc_test { - ($x:expr) => { - #[doc = $x] - extern "C" {} - }; - } - - external_doc_test!(include_str!("../README.md")); -} +#[cfg(test)] +mod test; diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..c8642d7 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,274 @@ +use crate::{utf8::rfind_utf8_end, TruncatedResultExt, WriteBuf}; +use core::fmt::Write; + +/// * `.0`: Input string +/// * `.1`: The end position if the last byte was chopped off +static TEST_CASES: &[(&str, usize)] = &[ + ("", 0), + ("James", 4), + ("_ΓΈ", 1), + ("磨", 0), + ("here: 见/見", 10), + ("π¨‰Ÿε‘γ—‚θΆŠ", 10), + ("πŸš€", 0), + ("πŸš€πŸš€πŸš€", 8), + ("rocket: πŸš€", 8), +]; + +#[test] +fn rfind_utf8_end_test() { + for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + let result = rfind_utf8_end(input.as_bytes()); + assert_eq!(result, input.len(), "input=\"{input}\""); + if input.len() == 0 { + continue; + } + let input_truncated = &input.as_bytes()[..input.len() - 1]; + let result = rfind_utf8_end(input_truncated); + assert_eq!( + result, *last_valid_idx_after_cut, + "input=\"{input}\" truncated={input_truncated:?}" + ); + } +} + +#[test] +fn format_enough_space() { + for (input, _) in TEST_CASES.iter() { + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf); + + writer.write_str(input).unwrap(); + assert_eq!(input.len(), writer.position()); + let written = writer.finish().unwrap(); + assert_eq!(input.len(), written.len()); + assert_eq!(*input, written); + } +} + +#[test] +fn format_enough_space_just_enough_reserved() { + for (input, _) in TEST_CASES.iter() { + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::with_reserve(&mut buf[..input.len() + 1], 1); + + writer.write_str(input).unwrap(); + assert_eq!(input.len(), writer.position()); + let written = writer.finish().unwrap(); + assert_eq!(input.len(), written.len()); + assert_eq!(*input, written); + } +} + +#[test] +fn format_truncation() { + for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + if input.len() == 0 { + continue; + } + + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf[..input.len() - 1]); + + writer.write_str(input).unwrap_err(); + assert_eq!(*last_valid_idx_after_cut, writer.position()); + assert!(writer.truncated()); + write!(writer, "!!!").expect_err("writes should fail here"); + + let written = writer.finish().unwrap_err().get(); + assert_eq!(*last_valid_idx_after_cut, written.len()); + } +} + +struct SimpleString { + storage: [u8; 128], + size: usize, +} + +impl SimpleString { + pub fn from_segments(segments: &[&str]) -> Self { + let mut out = Self { + storage: [0; 128], + size: 0, + }; + for segment in segments { + out.append(segment); + } + out + } + + pub fn append(&mut self, value: &str) { + let value = value.as_bytes(); + self.storage[self.size..self.size + value.len()].copy_from_slice(value); + self.size += value.len(); + } + + pub fn as_str(&self) -> &str { + core::str::from_utf8(&self.storage[..self.size]).unwrap() + } +} + +impl From<&str> for SimpleString { + fn from(value: &str) -> Self { + Self::from_segments(&[value]) + } +} + +#[test] +fn finish_with_enough_space() { + for (input, _) in TEST_CASES.iter() { + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf); + + writer.write_str(input).unwrap(); + let written = writer.finish_with(".123").unwrap(); + assert_eq!(written.len(), input.len() + 4); + let expected_written = SimpleString::from_segments(&[input, ".123"]); + assert_eq!(expected_written.as_str(), written); + } +} + +#[test] +fn finish_with_overwrite() { + for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + if input.len() == 0 { + continue; + } + + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf[..input.len()]); + + writer.write_str(input).unwrap(); + let written = writer.finish_with("?").unwrap_err().get(); + assert_eq!(written.len(), last_valid_idx_after_cut + 1); + let expected_written = SimpleString::from_segments(&[ + core::str::from_utf8(&input.as_bytes()[..*last_valid_idx_after_cut]).unwrap(), + "?", + ]); + assert_eq!(expected_written.as_str(), written); + } +} + +#[test] +fn finish_with_or_with_longer_normal_closer() { + let mut buf: [u8; 4] = [0xff; 4]; + let writer = WriteBuf::new(&mut buf); + + let written = writer.finish_with_or("0123456789", "abc").unwrap_err().get(); + assert_eq!(written.len(), 3); + assert_eq!("abc", written); +} + +#[test] +fn finish_with_full_overwrite_utf8() { + let mut buf: [u8; 4] = [0xff; 4]; + let writer = WriteBuf::new(&mut buf); + + let written = writer.finish_with("πŸš€12").unwrap_err().get(); + assert_eq!(written.len(), 2); + assert_eq!("12", written); +} + +#[test] +fn finish_with_all_continuation_bytes() { + // The 2-byte tail of "πŸš€" (b"\xf0\x9f\x9a\x80") is b"\x9a\x80" β€” both UTF-8 + // continuation bytes, so no valid character start can be found. The buffer + // is cleared and the result is reported as truncated. + let mut buf: [u8; 2] = [0xff; 2]; + let writer = WriteBuf::new(&mut buf); + + let written = writer.finish_with("πŸš€").unwrap_err().get(); + assert_eq!(written, ""); +} + +#[test] +fn write_rejected_when_remaining_below_reserve() { + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::with_reserve(&mut buf, 10); + + writer.write_str("a").unwrap_err(); + assert!(writer.truncated()); + assert_eq!(writer.position(), 0); + + let written = writer.finish().unwrap_err().get(); + assert_eq!(written, ""); +} + +#[test] +fn set_reserve_should_not_change_written() { + let mut buf: [u8; 10] = [0xff; 10]; + let mut writer = WriteBuf::new(&mut buf); + + write!(writer, "0123456789").unwrap(); + assert_eq!("0123456789", writer.written()); + + writer.set_reserve(4); + assert_eq!("0123456789", writer.written()); + + let written = writer.finish_with_or("", "!").unwrap(); + assert_eq!("0123456789", written); +} + +#[test] +fn truncated_result_ext_ok() { + for (input, _) in TEST_CASES.iter() { + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf); + + writer.write_str(input).unwrap(); + let result = writer.finish(); + assert!(result.is_ok(), "input=\"{input}\""); + assert_eq!(result.written(), *input, "input=\"{input}\""); + assert_eq!(result.written_len(), input.len(), "input=\"{input}\""); + assert!(!result.is_truncated(), "input=\"{input}\""); + } +} + +#[test] +fn truncated_result_ext_err() { + for (input, last_valid_idx_after_cut) in TEST_CASES.iter() { + if input.len() == 0 { + continue; + } + + let mut buf: [u8; 128] = [0xff; 128]; + let mut writer = WriteBuf::new(&mut buf[..input.len() - 1]); + + let _ = writer.write_str(input); + let result = writer.finish(); + assert!(result.is_err(), "input=\"{input}\""); + assert_eq!(result.written_len(), *last_valid_idx_after_cut, "input=\"{input}\""); + assert_eq!( + result.written(), + &input[..*last_valid_idx_after_cut], + "input=\"{input}\"" + ); + assert!(result.is_truncated(), "input=\"{input}\""); + } +} + +#[test] +fn truncated_result_ext_empty_err() { + // Mirrors `finish_with_all_continuation_bytes`: the buffer is too small to + // hold any valid UTF-8 prefix of the suffix, so `Err` carries an empty `&str`. + let mut buf: [u8; 2] = [0xff; 2]; + let writer = WriteBuf::new(&mut buf); + + let result = writer.finish_with("πŸš€"); + assert!(result.is_err()); + assert_eq!(result.written(), ""); + assert_eq!(result.written_len(), 0); + assert!(result.is_truncated()); +} + +#[test] +fn truncated_result_ext_no_unwrap() { + // The trait dispatches directly on the raw `Result` from the finish family, + // without needing an intermediate `.unwrap()` or `.get()`. + let mut buf: [u8; 4] = [0xff; 4]; + let mut writer = WriteBuf::new(&mut buf); + let _ = write!(writer, "abcdef"); + + let written: &str = writer.finish_with_or("!", "...").written(); + assert_eq!(written, "a..."); +} diff --git a/src/truncated.rs b/src/truncated.rs new file mode 100644 index 0000000..de39978 --- /dev/null +++ b/src/truncated.rs @@ -0,0 +1,92 @@ +//! Contains the [`Truncated`] error type and the [`TruncatedResultExt`] helper trait. + +use core::fmt; + +/// An error type indicating that a result was truncated. +/// +/// This is used from [`WriteBuf`] functions in the `Result::Err` case. It only provides access to the inner value; +/// for the `WriteBuf` finish family, `Truncated<'_>` records the part of the string slice that was validly +/// written before truncation. +pub struct Truncated<'a>(pub(crate) &'a str); + +impl<'a> Truncated<'a> { + /// Get the inner string slice. + pub fn get(&self) -> &'a str { + self.0 + } +} + +impl fmt::Debug for Truncated<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Truncated({:?})", self.0) + } +} + +impl fmt::Display for Truncated<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.0.fmt(f) + } +} + +impl core::error::Error for Truncated<'_> {} + +mod sealed { + pub trait Sealed {} +} + +/// Extension trait for [`Result<&str, Truncated>`] providing uniform access to the written content +/// regardless of whether truncation occurred. +/// +/// The [`WriteBuf`](crate::WriteBuf) `finish` family returns `Result<&'a str, Truncated<'a>>` where both arms +/// carry the successfully-written `&str`. This trait exposes that content directly, plus convenience accessors +/// for the byte length and the truncation flag. +/// +/// ``` +/// use fmtbuf::{TruncatedResultExt, WriteBuf}; +/// use core::fmt::Write; +/// +/// // Ok arm: plenty of space. +/// let mut buf = [0u8; 16]; +/// let mut writer = WriteBuf::new(&mut buf); +/// write!(writer, "hi").unwrap(); +/// let result = writer.finish(); +/// assert_eq!(result.written(), "hi"); +/// assert_eq!(result.written_len(), 2); +/// assert!(!result.is_truncated()); +/// +/// // Err arm: truncated write. +/// let mut buf = [0u8; 4]; +/// let mut writer = WriteBuf::new(&mut buf); +/// let _ = write!(writer, "hello"); +/// let result = writer.finish(); +/// assert_eq!(result.written(), "hell"); +/// assert_eq!(result.written_len(), 4); +/// assert!(result.is_truncated()); +/// ``` +pub trait TruncatedResultExt<'a>: sealed::Sealed { + /// Get the successfully-written content, regardless of whether truncation occurred. + fn written(&self) -> &'a str; + + /// Get the byte length of the successfully-written content. + fn written_len(&self) -> usize { + self.written().len() + } + + /// Get whether truncation occurred during the underlying write. + fn is_truncated(&self) -> bool; +} + +impl<'a> sealed::Sealed for Result<&'a str, Truncated<'a>> {} + +impl<'a> TruncatedResultExt<'a> for Result<&'a str, Truncated<'a>> { + fn written(&self) -> &'a str { + match self { + Ok(s) => s, + Err(t) => t.get(), + } + } + + fn is_truncated(&self) -> bool { + self.is_err() + } +} diff --git a/src/utf8.rs b/src/utf8.rs index 574fa2d..2f728f1 100644 --- a/src/utf8.rs +++ b/src/utf8.rs @@ -31,21 +31,9 @@ pub const fn utf8_char_width(code_unit: u8) -> Option { } } -/// Find the end of the last valid UTF-8 code point. -/// -/// # Deprecated -/// -/// This function will not be part of the public API in a future release. -/// -/// # Parameters -/// -/// * `buf`: This should be an almost-valid UTF-8 encoded sequence. The final bytes can be a UTF-8 multi-byte sequence -/// which is incomplete. -/// -/// # Returns -/// -/// The number of code units which are valid UTF-8 (assuming `buf` adheres to the above specification). -pub fn rfind_utf8_end(buf: &[u8]) -> usize { +/// Find the byte index of the end of the last complete UTF-8 code point in `buf`. Trailing bytes that form an +/// incomplete multi-byte sequence are excluded. +pub(crate) fn rfind_utf8_end(buf: &[u8]) -> usize { let mut position = buf.len(); // If the end of the string is middle of writing a UTF-8 multibyte sequence, we need to reverse to before the // code units for this incomplete code point.