Skip to main content

copybook_safe_text/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Panic-safe parsing and string helper functions.
4//!
5//! This crate isolates text-oriented "fallible" operations so numeric logic can
6//! be delegated to arithmetic-focused crates.
7
8use copybook_error::{Error, ErrorCode};
9
10/// Result type alias using `copybook-error`.
11pub type Result<T> = std::result::Result<T, Error>;
12
13/// Safely convert a string to `usize`, returning an error on failure.
14///
15/// # Errors
16///
17/// Returns `CBKP001_SYNTAX` if `s` cannot be parsed as `usize`.
18#[inline]
19#[must_use = "Handle the Result or propagate the error"]
20pub fn parse_usize(s: &str, context: &str) -> Result<usize> {
21    s.parse().map_err(|_| {
22        Error::new(
23            ErrorCode::CBKP001_SYNTAX,
24            format!("Invalid numeric value '{s}' in {context}"),
25        )
26    })
27}
28
29/// Safely convert a string to `isize`, returning an error on failure.
30///
31/// # Errors
32///
33/// Returns `CBKP001_SYNTAX` if `s` cannot be parsed as `isize`.
34#[inline]
35#[must_use = "Handle the Result or propagate the error"]
36pub fn parse_isize(s: &str, context: &str) -> Result<isize> {
37    s.parse().map_err(|_| {
38        Error::new(
39            ErrorCode::CBKP001_SYNTAX,
40            format!("Invalid signed numeric value '{s}' in {context}"),
41        )
42    })
43}
44
45/// Safely convert `u16` with parse error handling.
46///
47/// # Errors
48///
49/// Returns `CBKP001_SYNTAX` if `s` cannot be parsed as `u16`.
50#[inline]
51#[must_use = "Handle the Result or propagate the error"]
52pub fn safe_parse_u16(s: &str, context: &str) -> Result<u16> {
53    s.parse().map_err(|_| {
54        Error::new(
55            ErrorCode::CBKP001_SYNTAX,
56            format!("Invalid u16 value '{s}' in {context}"),
57        )
58    })
59}
60
61/// Safely access a string character with bounds checking.
62///
63/// # Errors
64///
65/// Returns `CBKP001_SYNTAX` if `index` is out of bounds.
66#[inline]
67#[must_use = "Handle the Result or propagate the error"]
68pub fn safe_string_char_at(s: &str, index: usize, context: &str) -> Result<char> {
69    s.chars().nth(index).ok_or_else(|| {
70        Error::new(
71            ErrorCode::CBKP001_SYNTAX,
72            format!(
73                "String character access out of bounds in {context}: index {index} >= length {}",
74                s.len()
75            ),
76        )
77    })
78}
79
80/// Safely format data into a string buffer for JSON generation.
81///
82/// # Errors
83///
84/// Returns `CBKD101_INVALID_FIELD_TYPE` if formatting fails.
85#[inline]
86#[must_use = "Handle the Result or propagate the error"]
87pub fn safe_write(buffer: &mut String, args: std::fmt::Arguments<'_>) -> Result<()> {
88    use std::fmt::Write;
89    buffer.write_fmt(args).map_err(|e| {
90        Error::new(
91            ErrorCode::CBKD101_INVALID_FIELD_TYPE,
92            format!("String formatting error: {e}"),
93        )
94    })
95}
96
97/// Safely append a string slice to a buffer for JSON field construction.
98///
99/// # Errors
100///
101/// Returns `CBKD101_INVALID_FIELD_TYPE` if the write fails.
102#[inline]
103#[must_use = "Handle the Result or propagate the error"]
104pub fn safe_write_str(buffer: &mut String, s: &str) -> Result<()> {
105    use std::fmt::Write;
106    buffer.write_str(s).map_err(|e| {
107        Error::new(
108            ErrorCode::CBKD101_INVALID_FIELD_TYPE,
109            format!("String write error: {e}"),
110        )
111    })
112}
113
114#[cfg(test)]
115#[allow(clippy::expect_used, clippy::unwrap_used)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parse_usize_ok() {
121        assert_eq!(parse_usize("123", "test").expect("parse usize"), 123);
122    }
123
124    #[test]
125    fn parse_usize_err() {
126        assert!(matches!(
127            parse_usize("invalid", "test"),
128            Err(error) if error.code == ErrorCode::CBKP001_SYNTAX
129        ));
130    }
131
132    #[test]
133    fn parse_isize_ok() {
134        assert_eq!(parse_isize("-42", "test").expect("parse isize"), -42);
135    }
136
137    #[test]
138    fn safe_parse_u16_ok_and_err() {
139        assert_eq!(safe_parse_u16("42", "test").expect("parse u16"), 42);
140        assert!(matches!(
141            safe_parse_u16("99999", "test"),
142            Err(error) if error.code == ErrorCode::CBKP001_SYNTAX
143        ));
144    }
145
146    #[test]
147    fn safe_string_char_at_ok() {
148        assert_eq!(
149            safe_string_char_at("abc", 1, "test").expect("char index"),
150            'b'
151        );
152    }
153
154    #[test]
155    fn safe_string_char_at_err() {
156        assert!(matches!(
157            safe_string_char_at("abc", 3, "test"),
158            Err(error) if error.code == ErrorCode::CBKP001_SYNTAX
159        ));
160    }
161
162    // --- safe_write tests ---
163
164    #[test]
165    fn test_safe_write_basic() {
166        let mut buf = String::new();
167        safe_write(&mut buf, format_args!("hello {}", 42)).unwrap();
168        assert_eq!(buf, "hello 42");
169    }
170
171    #[test]
172    fn test_safe_write_empty_format() {
173        let mut buf = String::new();
174        safe_write(&mut buf, format_args!("")).unwrap();
175        assert_eq!(buf, "");
176    }
177
178    #[test]
179    fn test_safe_write_append() {
180        let mut buf = String::from("prefix:");
181        safe_write(&mut buf, format_args!("value")).unwrap();
182        assert_eq!(buf, "prefix:value");
183    }
184
185    // --- safe_write_str tests ---
186
187    #[test]
188    fn test_safe_write_str_basic() {
189        let mut buf = String::new();
190        safe_write_str(&mut buf, "hello").unwrap();
191        assert_eq!(buf, "hello");
192    }
193
194    #[test]
195    fn test_safe_write_str_empty() {
196        let mut buf = String::new();
197        safe_write_str(&mut buf, "").unwrap();
198        assert_eq!(buf, "");
199    }
200
201    #[test]
202    fn test_safe_write_str_append() {
203        let mut buf = String::from("first");
204        safe_write_str(&mut buf, " second").unwrap();
205        assert_eq!(buf, "first second");
206    }
207
208    #[test]
209    fn test_safe_write_str_unicode() {
210        let mut buf = String::new();
211        safe_write_str(&mut buf, "日本語").unwrap();
212        assert_eq!(buf, "日本語");
213    }
214
215    // --- parse edge cases ---
216
217    #[test]
218    fn parse_usize_zero() {
219        assert_eq!(parse_usize("0", "test").unwrap(), 0);
220    }
221
222    #[test]
223    fn parse_usize_whitespace_err() {
224        assert!(parse_usize(" 123", "test").is_err());
225    }
226
227    #[test]
228    fn parse_usize_negative_err() {
229        assert!(parse_usize("-1", "test").is_err());
230    }
231
232    #[test]
233    fn parse_isize_zero() {
234        assert_eq!(parse_isize("0", "test").unwrap(), 0);
235    }
236
237    #[test]
238    fn parse_isize_positive() {
239        assert_eq!(parse_isize("42", "test").unwrap(), 42);
240    }
241
242    #[test]
243    fn parse_isize_empty_err() {
244        assert!(parse_isize("", "test").is_err());
245    }
246
247    #[test]
248    fn safe_parse_u16_zero() {
249        assert_eq!(safe_parse_u16("0", "test").unwrap(), 0);
250    }
251
252    #[test]
253    fn safe_parse_u16_max() {
254        assert_eq!(safe_parse_u16("65535", "test").unwrap(), u16::MAX);
255    }
256
257    #[test]
258    fn safe_parse_u16_negative_err() {
259        assert!(safe_parse_u16("-1", "test").is_err());
260    }
261
262    #[test]
263    fn safe_string_char_at_empty_string() {
264        assert!(safe_string_char_at("", 0, "test").is_err());
265    }
266
267    #[test]
268    fn safe_string_char_at_first_char() {
269        assert_eq!(safe_string_char_at("x", 0, "test").unwrap(), 'x');
270    }
271
272    #[test]
273    fn safe_string_char_at_unicode() {
274        assert_eq!(safe_string_char_at("日本", 1, "test").unwrap(), '本');
275    }
276}