From 17c86651b8d5a7d060337be10d0dc14dfd77ca75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:54:03 +0000 Subject: [PATCH 1/6] Initial plan From 5eebb3ac0bd10ba0fd43abe061477ccbc522b110 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:02:51 +0000 Subject: [PATCH 2/6] Add non-empty string and collection types with serde support - Add nonempty dependency (v0.10.0) with serialize feature - Create non_empty module with NonEmptyString, TrimmedNonEmptyString - Re-export NonEmptyVec type alias for nonempty::NonEmpty - Add comprehensive unit tests for all types - Add rustdoc examples and usage documentation - Export types from lib.rs under non_empty feature flag Co-authored-by: emgrav <614975+emgrav@users.noreply.github.com> --- Cargo.lock | 10 + Cargo.toml | 2 + src/lib.rs | 7 + src/non_empty.rs | 560 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 src/non_empty.rs diff --git a/Cargo.lock b/Cargo.lock index 3402479..5e7f421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,6 +218,7 @@ version = "1.1.0" dependencies = [ "dedent", "figment", + "nonempty", "paste", "reqwest", "schemars", @@ -716,6 +717,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nonempty" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "303e8749c804ccd6ca3b428de7fe0d86cb86bc7606bc15291f100fd487960bb8" +dependencies = [ + "serde", +] + [[package]] name = "num-conv" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f8237ef..6d0e958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ figment = { version = "0.10.0", features = [ "test", "yaml", ], optional = true } +nonempty = { version = "0.10.0", features = ["serialize"], optional = true } paste = { version = "1.0.0", optional = true } reqwest = { version = "0.13.0", optional = true } schemars = { version = "1.2.0", optional = true } @@ -44,6 +45,7 @@ time = ["dep:time"] schemars = ["dep:schemars", "schemars/url2"] serde = ["dep:serde", "dep:paste"] base_url = ["dep:url", "dep:thiserror", "dep:serde"] +non_empty = ["dep:nonempty", "dep:serde", "dep:thiserror"] [lints.rust] dead_code = "warn" diff --git a/src/lib.rs b/src/lib.rs index bb5cb79..298cef9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,9 @@ //! See [`LevelFilter`], [`BaseUrl`] and [`duration`] for useful wrapper types //! to use in your `serde`-based configs. //! +//! See [`NonEmptyString`], [`TrimmedNonEmptyString`], and [`NonEmptyVec`] for +//! non-empty types that enforce invariants at deserialization time. +//! //! Enable `schemars` feature to get [`schemars::JsonSchema`] impls for //! "config-helper" types to generate config schemas (for documentation and //! validation purposes). @@ -35,6 +38,8 @@ pub mod config; pub mod duration; #[cfg(feature = "level_filter")] mod level_filter; +#[cfg(feature = "non_empty")] +pub mod non_empty; #[cfg(feature = "reqwest")] pub mod reqwest; @@ -42,6 +47,8 @@ pub mod reqwest; pub use base_url::{BaseUrl, BaseUrlParseError}; #[cfg(feature = "level_filter")] pub use level_filter::LevelFilter; +#[cfg(feature = "non_empty")] +pub use non_empty::{NonEmptyError, NonEmptyString, NonEmptyVec, TrimmedNonEmptyString}; /// Generic combinators on polymorphic unconstrained types that `std` lacks. /// diff --git a/src/non_empty.rs b/src/non_empty.rs new file mode 100644 index 0000000..3664c89 --- /dev/null +++ b/src/non_empty.rs @@ -0,0 +1,560 @@ +// SPDX-FileCopyrightText: 2025 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: Apache-2.0 + +//! Non-empty string and collection types for enforcing invariants at API boundaries. +//! +//! This module provides wrapper types that guarantee non-empty values through +//! deserialization-time validation, helping to fail fast when invalid inputs +//! are provided to API endpoints. +//! +//! # Examples +//! +//! ## Using NonEmptyString +//! +//! ``` +//! # use famedly_rust_utils::NonEmptyString; +//! use serde::Deserialize; +//! +//! #[derive(Deserialize)] +//! struct UserRequest { +//! username: NonEmptyString, +//! } +//! +//! // Valid input deserializes successfully +//! let valid = serde_json::from_str::(r#"{"username": "alice"}"#).unwrap(); +//! assert_eq!(valid.username.as_str(), "alice"); +//! +//! // Empty string is rejected at deserialization time +//! let invalid = serde_json::from_str::(r#"{"username": ""}"#); +//! assert!(invalid.is_err()); +//! ``` +//! +//! ## Using TrimmedNonEmptyString +//! +//! ``` +//! # use famedly_rust_utils::TrimmedNonEmptyString; +//! use serde::Deserialize; +//! +//! #[derive(Deserialize)] +//! struct CommentRequest { +//! text: TrimmedNonEmptyString, +//! } +//! +//! // Whitespace is trimmed automatically +//! let valid = serde_json::from_str::(r#"{"text": " hello "}"#).unwrap(); +//! assert_eq!(valid.text.as_str(), "hello"); +//! +//! // Whitespace-only strings are rejected +//! let invalid = serde_json::from_str::(r#"{"text": " "}"#); +//! assert!(invalid.is_err()); +//! ``` +//! +//! ## Using NonEmptyVec +//! +//! ``` +//! # use famedly_rust_utils::NonEmptyVec; +//! use serde::Deserialize; +//! +//! #[derive(Deserialize)] +//! struct BatchRequest { +//! items: NonEmptyVec, +//! } +//! +//! // Non-empty lists deserialize successfully +//! let valid = serde_json::from_str::(r#"{"items": ["a", "b"]}"#).unwrap(); +//! assert_eq!(valid.items.len(), 2); +//! +//! // Empty lists are rejected at deserialization time +//! let invalid = serde_json::from_str::(r#"{"items": []}"#); +//! assert!(invalid.is_err()); +//! ``` + +use std::ops::Deref; + +#[cfg(feature = "serde")] +use serde::{de::Error, Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +/// Error type for non-empty validation failures. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum NonEmptyError { + /// The string is empty + #[error("string must be non-empty")] + EmptyString, + /// The string contains only whitespace after trimming + #[error("string must contain non-whitespace characters")] + BlankString, + /// The collection is empty + #[error("collection must be non-empty")] + EmptyCollection, +} + +/// A non-empty string wrapper that rejects empty strings during deserialization. +/// +/// This type guarantees that the contained string is not empty, making it +/// suitable for API fields that must reject empty input at the boundary. +/// +/// # Examples +/// +/// ``` +/// # use famedly_rust_utils::NonEmptyString; +/// # use serde::Deserialize; +/// #[derive(Deserialize)] +/// struct Config { +/// api_key: NonEmptyString, +/// } +/// +/// // Valid deserialization +/// let config: Config = serde_json::from_str(r#"{"api_key": "abc123"}"#).unwrap(); +/// assert_eq!(config.api_key.as_str(), "abc123"); +/// +/// // Invalid deserialization fails +/// assert!(serde_json::from_str::(r#"{"api_key": ""}"#).is_err()); +/// ``` +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize))] +#[repr(transparent)] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct NonEmptyString { + value: String, +} + +impl NonEmptyString { + /// Creates a new `NonEmptyString` from a string. + /// + /// Returns an error if the string is empty. + /// + /// # Examples + /// + /// ``` + /// # use famedly_rust_utils::NonEmptyString; + /// let s = NonEmptyString::new("hello".to_string()).unwrap(); + /// assert_eq!(s.as_str(), "hello"); + /// + /// assert!(NonEmptyString::new("".to_string()).is_err()); + /// ``` + #[inline] + pub fn new(s: String) -> Result { + if s.is_empty() { + Err(NonEmptyError::EmptyString) + } else { + Ok(NonEmptyString { value: s }) + } + } + + /// Returns the inner string as a string slice. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } + + /// Consumes the wrapper and returns the inner string. + #[inline] + #[must_use] + pub fn into_inner(self) -> String { + self.value + } +} + +impl std::str::FromStr for NonEmptyString { + type Err = NonEmptyError; + + #[inline] + fn from_str(s: &str) -> Result { + Self::new(s.to_owned()) + } +} + +impl std::fmt::Display for NonEmptyString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} + +impl AsRef for NonEmptyString { + fn as_ref(&self) -> &str { + &self.value + } +} + +impl Deref for NonEmptyString { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for NonEmptyString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(D::Error::custom) + } +} + +/// A non-empty string wrapper that trims whitespace and rejects blank strings +/// during deserialization. +/// +/// This type guarantees that the contained string is not empty after trimming +/// whitespace, making it suitable for API fields that must reject blank input. +/// +/// # Examples +/// +/// ``` +/// # use famedly_rust_utils::TrimmedNonEmptyString; +/// # use serde::Deserialize; +/// #[derive(Deserialize)] +/// struct Comment { +/// text: TrimmedNonEmptyString, +/// } +/// +/// // Whitespace is trimmed +/// let comment: Comment = serde_json::from_str(r#"{"text": " hello "}"#).unwrap(); +/// assert_eq!(comment.text.as_str(), "hello"); +/// +/// // Blank strings are rejected +/// assert!(serde_json::from_str::(r#"{"text": " "}"#).is_err()); +/// ``` +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize))] +#[repr(transparent)] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct TrimmedNonEmptyString { + value: String, +} + +impl TrimmedNonEmptyString { + /// Creates a new `TrimmedNonEmptyString` from a string. + /// + /// The string is trimmed and then validated to be non-empty. + /// + /// # Examples + /// + /// ``` + /// # use famedly_rust_utils::TrimmedNonEmptyString; + /// let s = TrimmedNonEmptyString::new(" hello ".to_string()).unwrap(); + /// assert_eq!(s.as_str(), "hello"); + /// + /// assert!(TrimmedNonEmptyString::new(" ".to_string()).is_err()); + /// ``` + #[inline] + pub fn new(s: String) -> Result { + let trimmed = s.trim().to_owned(); + if trimmed.is_empty() { + Err(NonEmptyError::BlankString) + } else { + Ok(TrimmedNonEmptyString { value: trimmed }) + } + } + + /// Returns the inner string as a string slice. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } + + /// Consumes the wrapper and returns the inner string. + #[inline] + #[must_use] + pub fn into_inner(self) -> String { + self.value + } +} + +impl std::str::FromStr for TrimmedNonEmptyString { + type Err = NonEmptyError; + + #[inline] + fn from_str(s: &str) -> Result { + Self::new(s.to_owned()) + } +} + +impl std::fmt::Display for TrimmedNonEmptyString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} + +impl AsRef for TrimmedNonEmptyString { + fn as_ref(&self) -> &str { + &self.value + } +} + +impl Deref for TrimmedNonEmptyString { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.value + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for TrimmedNonEmptyString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(D::Error::custom) + } +} + +/// A non-empty vector type alias. +/// +/// This is a re-export of [`nonempty::NonEmpty`] specialized for `Vec`, +/// providing compile-time guarantees that a collection contains at least one element. +/// +/// The type has full serde support and will reject empty arrays during deserialization. +/// +/// # Examples +/// +/// ``` +/// # use famedly_rust_utils::NonEmptyVec; +/// # use serde::Deserialize; +/// #[derive(Deserialize)] +/// struct Request { +/// ids: NonEmptyVec, +/// } +/// +/// // Non-empty arrays deserialize successfully +/// let req: Request = serde_json::from_str(r#"{"ids": [1, 2, 3]}"#).unwrap(); +/// assert_eq!(req.ids.len(), 3); +/// +/// // Empty arrays are rejected +/// assert!(serde_json::from_str::(r#"{"ids": []}"#).is_err()); +/// ``` +pub type NonEmptyVec = nonempty::NonEmpty; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_non_empty_string_new_valid() { + let s = NonEmptyString::new("hello".to_owned()).unwrap(); + assert_eq!(s.as_str(), "hello"); + } + + #[test] + fn test_non_empty_string_new_empty() { + let err = NonEmptyString::new("".to_owned()).unwrap_err(); + assert_eq!(err, NonEmptyError::EmptyString); + } + + #[test] + fn test_non_empty_string_from_str_valid() { + let s: NonEmptyString = "hello".parse().unwrap(); + assert_eq!(s.as_str(), "hello"); + } + + #[test] + fn test_non_empty_string_from_str_empty() { + let err: NonEmptyError = "".parse::().unwrap_err(); + assert_eq!(err, NonEmptyError::EmptyString); + } + + #[test] + fn test_non_empty_string_display() { + let s = NonEmptyString::new("hello".to_owned()).unwrap(); + assert_eq!(format!("{}", s), "hello"); + } + + #[test] + fn test_non_empty_string_deref() { + let s = NonEmptyString::new("hello".to_owned()).unwrap(); + assert_eq!(s.len(), 5); + assert!(s.starts_with("he")); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_string_deserialize_valid() { + #[derive(serde::Deserialize)] + struct TestStruct { + field: NonEmptyString, + } + + let json = r#"{"field": "value"}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.field.as_str(), "value"); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_string_deserialize_empty() { + #[derive(Debug, serde::Deserialize)] + struct TestStruct { + field: NonEmptyString, + } + + let json = r#"{"field": ""}"#; + let result = serde_json::from_str::(json); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("non-empty") || err_msg.contains("must")); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_string_serialize() { + #[derive(serde::Serialize)] + struct TestStruct { + field: NonEmptyString, + } + + let s = TestStruct { field: NonEmptyString::new("value".to_owned()).unwrap() }; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#"{"field":"value"}"#); + } + + #[test] + fn test_trimmed_non_empty_string_new_valid() { + let s = TrimmedNonEmptyString::new(" hello ".to_owned()).unwrap(); + assert_eq!(s.as_str(), "hello"); + } + + #[test] + fn test_trimmed_non_empty_string_new_blank() { + let err = TrimmedNonEmptyString::new(" ".to_owned()).unwrap_err(); + assert_eq!(err, NonEmptyError::BlankString); + } + + #[test] + fn test_trimmed_non_empty_string_new_empty() { + let err = TrimmedNonEmptyString::new("".to_owned()).unwrap_err(); + assert_eq!(err, NonEmptyError::BlankString); + } + + #[test] + fn test_trimmed_non_empty_string_from_str_valid() { + let s: TrimmedNonEmptyString = " hello ".parse().unwrap(); + assert_eq!(s.as_str(), "hello"); + } + + #[test] + fn test_trimmed_non_empty_string_from_str_blank() { + let err: NonEmptyError = " ".parse::().unwrap_err(); + assert_eq!(err, NonEmptyError::BlankString); + } + + #[test] + fn test_trimmed_non_empty_string_display() { + let s = TrimmedNonEmptyString::new(" hello ".to_owned()).unwrap(); + assert_eq!(format!("{}", s), "hello"); + } + + #[test] + fn test_trimmed_non_empty_string_deref() { + let s = TrimmedNonEmptyString::new(" hello ".to_owned()).unwrap(); + assert_eq!(s.len(), 5); + assert!(s.starts_with("he")); + } + + #[cfg(feature = "serde")] + #[test] + fn test_trimmed_non_empty_string_deserialize_valid() { + #[derive(serde::Deserialize)] + struct TestStruct { + field: TrimmedNonEmptyString, + } + + let json = r#"{"field": " value "}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.field.as_str(), "value"); + } + + #[cfg(feature = "serde")] + #[test] + fn test_trimmed_non_empty_string_deserialize_blank() { + #[derive(Debug, serde::Deserialize)] + struct TestStruct { + field: TrimmedNonEmptyString, + } + + let json = r#"{"field": " "}"#; + let result = serde_json::from_str::(json); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("whitespace") || err_msg.contains("blank") || err_msg.contains("must")); + } + + #[cfg(feature = "serde")] + #[test] + fn test_trimmed_non_empty_string_serialize() { + #[derive(serde::Serialize)] + struct TestStruct { + field: TrimmedNonEmptyString, + } + + let s = TestStruct { + field: TrimmedNonEmptyString::new(" value ".to_owned()).unwrap(), + }; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#"{"field":"value"}"#); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_vec_deserialize_valid() { + #[derive(serde::Deserialize)] + struct TestStruct { + items: NonEmptyVec, + } + + let json = r#"{"items": ["a", "b", "c"]}"#; + let result: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(result.items.len(), 3); + assert_eq!(result.items.head, "a"); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_vec_deserialize_empty() { + #[derive(Debug, serde::Deserialize)] + struct TestStruct { + items: NonEmptyVec, + } + + let json = r#"{"items": []}"#; + let result = serde_json::from_str::(json); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("empty") || err_msg.contains("must")); + } + + #[cfg(feature = "serde")] + #[test] + fn test_non_empty_vec_serialize() { + #[derive(serde::Serialize)] + struct TestStruct { + items: NonEmptyVec, + } + + let vec = nonempty::nonempty![1, 2, 3]; + let s = TestStruct { items: vec }; + let json = serde_json::to_string(&s).unwrap(); + assert_eq!(json, r#"{"items":[1,2,3]}"#); + } + + #[test] + fn test_non_empty_vec_construction() { + // Test using the nonempty! macro + let vec = nonempty::nonempty![1, 2, 3]; + assert_eq!(vec.len(), 3); + assert_eq!(vec.head, 1); + + // Test using from_vec + let regular_vec = vec![4, 5, 6]; + let non_empty = nonempty::NonEmpty::from_vec(regular_vec).unwrap(); + assert_eq!(non_empty.len(), 3); + assert_eq!(non_empty.head, 4); + + // Test from_vec with empty vec + let empty_vec: Vec = vec![]; + let result = nonempty::NonEmpty::from_vec(empty_vec); + assert!(result.is_none()); + } +} From fecf1d44e74a3ef486771521ff306a25a70c966d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:04:13 +0000 Subject: [PATCH 3/6] Fix removed doc_auto_cfg feature for stable Rust Remove unstable doc_auto_cfg feature that was removed in Rust 1.92.0 Co-authored-by: emgrav <614975+emgrav@users.noreply.github.com> --- src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 298cef9..9e34372 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: Apache-2.0 -#![cfg_attr(all(doc, not(doctest)), feature(doc_auto_cfg))] //! This crate consists of incohesive generic types and functions that are //! needed in almost every crate but are so small that making a separate crate //! for them is too much. From 7c9fb9187ae6a03a8f5c4abef6e40aacb9154d80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:05:50 +0000 Subject: [PATCH 4/6] Remove unused EmptyCollection error variant The EmptyCollection variant was never used since NonEmptyVec uses the nonempty crate's own error handling Co-authored-by: emgrav <614975+emgrav@users.noreply.github.com> --- src/non_empty.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/non_empty.rs b/src/non_empty.rs index 3664c89..3441673 100644 --- a/src/non_empty.rs +++ b/src/non_empty.rs @@ -85,9 +85,6 @@ pub enum NonEmptyError { /// The string contains only whitespace after trimming #[error("string must contain non-whitespace characters")] BlankString, - /// The collection is empty - #[error("collection must be non-empty")] - EmptyCollection, } /// A non-empty string wrapper that rejects empty strings during deserialization. From fc5e36890ad874cade83901d73910539a402a42b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:10:37 +0000 Subject: [PATCH 5/6] Improve error variant documentation style Capitalize first word in error variant docs to follow Rust conventions Co-authored-by: emgrav <614975+emgrav@users.noreply.github.com> --- src/non_empty.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/non_empty.rs b/src/non_empty.rs index 3441673..0cb3b35 100644 --- a/src/non_empty.rs +++ b/src/non_empty.rs @@ -79,10 +79,10 @@ use thiserror::Error; /// Error type for non-empty validation failures. #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum NonEmptyError { - /// The string is empty + /// String is empty #[error("string must be non-empty")] EmptyString, - /// The string contains only whitespace after trimming + /// String contains only whitespace after trimming #[error("string must contain non-whitespace characters")] BlankString, } From c4fa4b291024148a1008e1cecdd017355d667859 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:14:23 +0000 Subject: [PATCH 6/6] Re-export full nonempty API including NonEmpty and nonempty! macro - Export NonEmpty type for full API surface access - Export nonempty! macro for ergonomic construction - Add module-level documentation example for macro usage - Add rustdoc example for macro usage - Keep NonEmptyVec type alias for convenient Vec-like usage Co-authored-by: emgrav <614975+emgrav@users.noreply.github.com> --- src/lib.rs | 4 +++- src/non_empty.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 9e34372..be3c610 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,9 @@ pub use base_url::{BaseUrl, BaseUrlParseError}; #[cfg(feature = "level_filter")] pub use level_filter::LevelFilter; #[cfg(feature = "non_empty")] -pub use non_empty::{NonEmptyError, NonEmptyString, NonEmptyVec, TrimmedNonEmptyString}; +pub use non_empty::{ + nonempty, NonEmpty, NonEmptyError, NonEmptyString, NonEmptyVec, TrimmedNonEmptyString, +}; /// Generic combinators on polymorphic unconstrained types that `std` lacks. /// diff --git a/src/non_empty.rs b/src/non_empty.rs index 0cb3b35..561b1ef 100644 --- a/src/non_empty.rs +++ b/src/non_empty.rs @@ -69,6 +69,21 @@ //! let invalid = serde_json::from_str::(r#"{"items": []}"#); //! assert!(invalid.is_err()); //! ``` +//! +//! ## Using the `nonempty!` macro +//! +//! ``` +//! # use famedly_rust_utils::nonempty; +//! // Construct a non-empty collection directly +//! let numbers = nonempty![1, 2, 3, 4, 5]; +//! assert_eq!(numbers.head, 1); +//! assert_eq!(numbers.tail, vec![2, 3, 4, 5]); +//! +//! // Works with any type +//! let words = nonempty!["hello", "world"]; +//! assert_eq!(words.len(), 2); +//! ``` + use std::ops::Deref; @@ -325,6 +340,27 @@ impl<'de> Deserialize<'de> for TrimmedNonEmptyString { /// ``` pub type NonEmptyVec = nonempty::NonEmpty; +/// Re-export of the full `NonEmpty` type from the `nonempty` crate. +/// +/// This provides the complete API surface of the `nonempty::NonEmpty` type, +/// including constructors, iterators, and all trait implementations. +/// +/// See the [`nonempty` crate documentation](https://docs.rs/nonempty) for full API details. +pub use nonempty::NonEmpty; + +/// Re-export of the `nonempty!` macro for constructing non-empty collections. +/// +/// # Examples +/// +/// ``` +/// # use famedly_rust_utils::nonempty; +/// let vec = nonempty![1, 2, 3]; +/// assert_eq!(vec.len(), 3); +/// assert_eq!(vec.head, 1); +/// ``` +#[doc(inline)] +pub use nonempty::nonempty; + #[cfg(test)] mod tests { use super::*;