Skip to main content

atuin_common/string/
non_blank.rs

1//! A string proven to hold something other than whitespace.
2
3use std::borrow::Cow;
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6use serde::{Deserialize, Deserializer, Serialize};
7
8/// A string with at least one non-whitespace character, held verbatim. `T` is any string type:
9/// `String`, `&str`, `Cow<str>`, `Box<str>`, ...
10#[derive(
11    Clone,
12    PartialEq,
13    Eq,
14    Hash,
15    PartialOrd,
16    Ord,
17    Serialize,
18    derive_more::AsRef,
19    derive_more::Debug,
20    derive_more::Deref,
21    derive_more::Display,
22)]
23#[as_ref(forward)]
24#[debug("{_0:?}")]
25#[deref(forward)]
26#[display("{_0}")]
27pub struct NonBlank<T = String>(T);
28
29pub type NonBlankString = NonBlank<String>;
30
31/// The error returned when a string is empty or all whitespace.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
33#[error("string is blank")]
34pub struct Blank;
35
36impl<T: AsRef<str>> NonBlank<T> {
37    /// Wrap `inner`, or fail if it is empty or all whitespace.
38    pub fn new(inner: T) -> Result<Self, Blank> {
39        if inner.as_ref().trim().is_empty() {
40            Err(Blank)
41        } else {
42            Ok(Self(inner))
43        }
44    }
45
46    /// The wrapped string as a slice.
47    pub fn as_str(&self) -> &str {
48        self.0.as_ref()
49    }
50
51    pub fn into_inner(self) -> T {
52        self.0
53    }
54}
55
56impl<'de, T: AsRef<str> + Deserialize<'de>> Deserialize<'de> for NonBlank<T> {
57    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
58        Self::new(T::deserialize(deserializer)?).map_err(serde::de::Error::custom)
59    }
60}
61
62impl<T> JsonSchema for NonBlank<T> {
63    fn schema_name() -> Cow<'static, str> {
64        "NonBlank".into()
65    }
66
67    fn inline_schema() -> bool {
68        true
69    }
70
71    fn json_schema(_: &mut SchemaGenerator) -> Schema {
72        json_schema!({ "type": "string", "minLength": 1 })
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use rstest::rstest;
79    use schemars::JsonSchema;
80    use serde::Deserialize;
81    use serde_json::json;
82
83    use super::*;
84
85    #[derive(Deserialize, JsonSchema)]
86    struct Params {
87        query: NonBlankString,
88    }
89
90    #[rstest]
91    #[case::plain("disk")]
92    #[case::padded("  disk full \n")]
93    fn holds_the_text_verbatim(#[case] input: &str) {
94        let borrowed: NonBlank<&str> = NonBlank::new(input).unwrap();
95        assert_eq!(borrowed.as_str(), input);
96        assert_eq!(&*borrowed, input);
97        assert_eq!(borrowed.to_string(), input);
98        assert_eq!(NonBlankString::new(input.to_owned()).unwrap().as_str(), input);
99        let params: Params = serde_json::from_value(json!({"query": input})).unwrap();
100        assert_eq!(params.query.as_str(), input);
101        assert_eq!(serde_json::to_value(&params.query).unwrap(), json!(input));
102        assert_eq!(params.query.into_inner(), input);
103    }
104
105    #[rstest]
106    #[case::empty("")]
107    #[case::whitespace(" \t\n")]
108    fn rejects_blank_text(#[case] input: &str) {
109        assert_eq!(NonBlank::new(input), Err(Blank));
110        assert!(serde_json::from_value::<Params>(json!({"query": input})).is_err());
111    }
112
113    #[rstest]
114    fn json_schema_requires_a_character() {
115        let schema = schemars::schema_for!(Params);
116        let query = &schema.as_value()["properties"]["query"];
117        assert_eq!(query["type"], "string");
118        assert_eq!(query["minLength"], 1);
119        assert_eq!(schema.as_value()["required"], json!(["query"]));
120    }
121}