Skip to main content

hadris_iso/
types.rs

1use core::marker::PhantomData;
2pub use hadris_common::types::{endian::*, number::*};
3
4#[cfg(feature = "std")]
5use std::time::SystemTime;
6
7#[cfg(feature = "alloc")]
8use alloc::vec::Vec;
9
10/// Error type for `IsoStr::from_str()` conversion failures.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum IsoStrError {
13    /// The input string exceeds the maximum length.
14    TooLong {
15        /// Maximum permitted byte length.
16        max: usize,
17        /// Actual byte length of the input.
18        got: usize,
19    },
20    /// The input contains characters not valid in the target charset.
21    InvalidCharset,
22}
23
24impl core::fmt::Display for IsoStrError {
25    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
26        match self {
27            Self::TooLong { max, got } => {
28                write!(f, "string too long: max {max} bytes, got {got}")
29            }
30            Self::InvalidCharset => write!(f, "string contains invalid charset characters"),
31        }
32    }
33}
34
35/// Defines behavior for Charset.
36pub trait Charset: Copy {
37    /// Performs the `is_valid` operation.
38    fn is_valid<'a>(bytes: impl Iterator<Item = &'a u8>) -> bool;
39    /// Performs the `substitute_invalid` operation.
40    fn substitute_invalid<'a>(bytes: impl Iterator<Item = &'a mut u8>);
41}
42
43/// The `a-characters` character set.
44/// This supports `A-Z`, `0-9` and `!"%$'()*+,-./:;<=>?`.
45#[derive(Copy, Clone, PartialEq, Eq)]
46pub struct CharsetA;
47
48#[derive(Clone, Copy, PartialEq, Eq)]
49/// Represents CharsetD.
50pub struct CharsetD;
51
52#[derive(Clone, Copy, PartialEq, Eq)]
53/// Represents CharsetD1.
54pub struct CharsetD1;
55
56impl CharsetA {
57    const VALID_SYMBOLS: &[u8] = b" 0123456789_!\"%$'()*+,-./:;<=>?";
58
59    fn valid_byte(b: u8) -> bool {
60        b.is_ascii_uppercase() || Self::VALID_SYMBOLS.contains(&b)
61    }
62}
63
64impl CharsetD {
65    const SPECIAL_CHARS: &[u8] = b"0123456789_";
66
67    fn valid_byte(b: u8) -> bool {
68        b.is_ascii_uppercase() || Self::SPECIAL_CHARS.contains(&b)
69    }
70}
71
72impl CharsetD1 {
73    const SPECIAL_CHARS: &[u8] = CharsetD::SPECIAL_CHARS;
74
75    fn valid_byte(b: u8) -> bool {
76        b.is_ascii_alphabetic() || Self::SPECIAL_CHARS.contains(&b)
77    }
78}
79
80impl Charset for CharsetA {
81    fn is_valid<'a>(mut bytes: impl Iterator<Item = &'a u8>) -> bool {
82        bytes.all(|b| Self::valid_byte(*b))
83    }
84
85    fn substitute_invalid<'a>(bytes: impl Iterator<Item = &'a mut u8>) {
86        for byte in bytes {
87            if byte.is_ascii_lowercase() {
88                *byte = byte.to_ascii_uppercase();
89                continue;
90            }
91
92            if !Self::valid_byte(*byte) {
93                *byte = b'_';
94            }
95        }
96    }
97}
98
99impl Charset for CharsetD {
100    fn is_valid<'a>(mut bytes: impl Iterator<Item = &'a u8>) -> bool {
101        bytes.all(|b| Self::valid_byte(*b))
102    }
103
104    fn substitute_invalid<'a>(bytes: impl Iterator<Item = &'a mut u8>) {
105        for byte in bytes {
106            if byte.is_ascii_lowercase() {
107                *byte = byte.to_ascii_uppercase();
108                continue;
109            }
110
111            if !Self::valid_byte(*byte) {
112                *byte = b'_';
113            }
114        }
115    }
116}
117
118impl Charset for CharsetD1 {
119    fn is_valid<'a>(mut bytes: impl Iterator<Item = &'a u8>) -> bool {
120        bytes.all(|b| Self::valid_byte(*b))
121    }
122
123    fn substitute_invalid<'a>(bytes: impl Iterator<Item = &'a mut u8>) {
124        for byte in bytes {
125            if !Self::valid_byte(*byte) {
126                *byte = b'_';
127            }
128        }
129    }
130}
131
132/// A space padded string with a fixed length.
133#[derive(Clone, Copy, PartialEq, Eq)]
134pub struct IsoStr<C: Charset, const N: usize> {
135    chars: [u8; N],
136    _marker: PhantomData<C>,
137}
138
139unsafe impl<C: Charset, const N: usize> bytemuck::Zeroable for IsoStr<C, N> {}
140unsafe impl<C: Charset + 'static, const N: usize> bytemuck::Pod for IsoStr<C, N> {}
141
142impl<C: Charset, const N: usize> IsoStr<C, N> {
143    /// Performs the `empty` operation.
144    pub fn empty() -> Self {
145        Self {
146            chars: [b' '; N],
147            _marker: core::marker::PhantomData,
148        }
149    }
150
151    /// Performs the `max_len` operation.
152    pub fn max_len() -> usize {
153        N
154    }
155
156    /// Performs the `len` operation.
157    pub fn len(&self) -> usize {
158        match self.chars.iter().rposition(|&c| c != b' ' && c != 0) {
159            Some(pos) => pos + 1,
160            None => 0,
161        }
162    }
163
164    /// Performs the `is_empty` operation.
165    pub fn is_empty(&self) -> bool {
166        self.len() == 0
167    }
168
169    /// Performs the `as_bytes` operation.
170    pub fn as_bytes(&self) -> &[u8; N] {
171        &self.chars
172    }
173
174    /// Performs the `from_bytes_exact` operation.
175    pub const fn from_bytes_exact(bytes: [u8; N]) -> Self {
176        Self {
177            chars: bytes,
178            _marker: core::marker::PhantomData,
179        }
180    }
181
182    #[allow(clippy::should_implement_trait)]
183    /// Performs the `from_str` operation.
184    pub fn from_str(s: &str) -> Result<Self, IsoStrError> {
185        let mut chars = [b' '; N];
186        if s.len() > N {
187            return Err(IsoStrError::TooLong {
188                max: N,
189                got: s.len(),
190            });
191        }
192
193        if !C::is_valid(s.as_bytes().iter()) {
194            return Err(IsoStrError::InvalidCharset);
195        }
196
197        for (i, c) in s.bytes().enumerate() {
198            chars[i] = c;
199        }
200        Ok(Self {
201            chars,
202            _marker: core::marker::PhantomData,
203        })
204    }
205
206    /// Like `from_str`, but auto-converts lowercase to uppercase and substitutes
207    /// other invalid characters instead of rejecting them.
208    pub fn from_str_lossy(s: &str) -> Result<Self, IsoStrError> {
209        if s.len() > N {
210            return Err(IsoStrError::TooLong {
211                max: N,
212                got: s.len(),
213            });
214        }
215        let mut chars = [b' '; N];
216        for (i, c) in s.bytes().enumerate() {
217            chars[i] = c;
218        }
219        C::substitute_invalid(chars[..s.len()].iter_mut());
220        Ok(Self {
221            chars,
222            _marker: core::marker::PhantomData,
223        })
224    }
225
226    /// Like `from_str`, but skips charset validation entirely.
227    /// Stores the bytes as-is, only checking length.
228    pub fn from_str_unchecked(s: &str) -> Result<Self, IsoStrError> {
229        if s.len() > N {
230            return Err(IsoStrError::TooLong {
231                max: N,
232                got: s.len(),
233            });
234        }
235        let mut chars = [b' '; N];
236        for (i, c) in s.bytes().enumerate() {
237            chars[i] = c;
238        }
239        Ok(Self {
240            chars,
241            _marker: core::marker::PhantomData,
242        })
243    }
244
245    /// Borrow the contents as a `&str`.
246    ///
247    /// # Panics
248    ///
249    /// Panics if the bytes are not valid UTF-8. Because `IsoStr` is
250    /// `bytemuck::Pod`, instances can be constructed by transmuting raw bytes
251    /// from a disk image — those bytes are not guaranteed to be UTF-8 even
252    /// when the format requires it. Use [`Self::try_to_str`] for a fallible
253    /// variant.
254    pub fn to_str(&self) -> &str {
255        if self.chars.len() == 1 {
256            match self.chars[0] {
257                b'\x00' => return "\\x00",
258                b'\x01' => return "\\x01",
259                _ => {}
260            }
261        }
262        core::str::from_utf8(&self.chars[..self.len()]).expect("IsoStr contains invalid UTF-8")
263    }
264
265    /// Borrow the contents as a `&str` if they are valid UTF-8.
266    pub fn try_to_str(&self) -> Result<&str, core::str::Utf8Error> {
267        if self.chars.len() == 1 {
268            match self.chars[0] {
269                b'\x00' => return Ok("\\x00"),
270                b'\x01' => return Ok("\\x01"),
271                _ => {}
272            }
273        }
274        core::str::from_utf8(&self.chars[..self.len()])
275    }
276}
277
278impl<C: Charset, const N: usize> core::fmt::Display for IsoStr<C, N> {
279    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        match self.try_to_str() {
281            Ok(s) => f.write_str(s),
282            Err(_) => write!(f, "{:?}", &self.chars[..self.len()]),
283        }
284    }
285}
286
287impl<C: Charset, const N: usize> core::fmt::Debug for IsoStr<C, N> {
288    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
289        match self.try_to_str() {
290            Ok(s) => write!(f, "\"{s}\""),
291            Err(_) => write!(f, "{:?}", &self.chars[..self.len()]),
292        }
293    }
294}
295
296/// A dynamically-sized ISO string (requires alloc feature)
297#[cfg(feature = "alloc")]
298#[derive(Clone, PartialEq, Eq)]
299pub struct IsoString<C: Charset> {
300    chars: Vec<u8>,
301    _marker: PhantomData<C>,
302}
303
304#[cfg(feature = "alloc")]
305impl<C: Charset> From<Vec<u8>> for IsoString<C> {
306    fn from(value: Vec<u8>) -> Self {
307        // Single-byte values \x00 and \x01 are valid ISO 9660 directory identifiers
308        // representing "." (current) and ".." (parent) respectively.
309        if !(value.len() == 1 && (value[0] == 0x00 || value[0] == 0x01)) {
310            debug_assert!(
311                C::is_valid(value.iter()),
312                "IsoString contains invalid charset characters: {value:?}"
313            );
314        }
315        Self {
316            chars: value,
317            _marker: PhantomData,
318        }
319    }
320}
321
322#[cfg(feature = "alloc")]
323impl<C: Charset> IsoString<C> {
324    /// Performs the `empty` operation.
325    pub const fn empty() -> Self {
326        Self {
327            chars: Vec::new(),
328            _marker: PhantomData,
329        }
330    }
331
332    /// Performs the `with_size` operation.
333    pub fn with_size(size: usize) -> Self {
334        use alloc::vec;
335        Self {
336            // ECMA-119 7.4.4: a-characters and d-characters are padded with spaces (0x20)
337            chars: vec![b' '; size],
338            _marker: PhantomData,
339        }
340    }
341
342    /// Performs the `with_capacity` operation.
343    pub fn with_capacity(capacity: usize) -> Self {
344        Self {
345            chars: Vec::with_capacity(capacity),
346            _marker: PhantomData,
347        }
348    }
349
350    /// Performs the `from_bytes` operation.
351    pub fn from_bytes(bytes: &[u8]) -> Self {
352        Self {
353            chars: bytes.to_vec(),
354            _marker: PhantomData,
355        }
356    }
357
358    /// Performs the `from_utf8` operation.
359    pub fn from_utf8(str: &str) -> Self {
360        Self {
361            chars: str.as_bytes().to_vec(),
362            _marker: PhantomData,
363        }
364    }
365
366    /// Performs the `len` operation.
367    pub fn len(&self) -> usize {
368        self.chars
369            .iter()
370            .position(|&c| c == b' ')
371            .unwrap_or(self.chars.len())
372    }
373
374    /// Performs the `is_empty` operation.
375    pub fn is_empty(&self) -> bool {
376        self.len() == 0
377    }
378
379    /// Performs the `size` operation.
380    pub fn size(&self) -> usize {
381        self.chars.len()
382    }
383
384    /// Performs the `bytes` operation.
385    pub fn bytes(&self) -> &[u8] {
386        &self.chars
387    }
388
389    /// Borrow the contents as a `&str`.
390    ///
391    /// # Panics
392    ///
393    /// Panics if the bytes are not valid UTF-8. `IsoString` accepts arbitrary
394    /// bytes via [`Self::from_bytes`] / `From<Vec<u8>>`, so the buffer is not
395    /// guaranteed to be UTF-8. Use [`Self::try_as_str`] for a fallible
396    /// variant.
397    pub fn as_str(&self) -> &str {
398        if self.chars.len() == 1 {
399            match self.chars[0] {
400                b'\x00' => return "\\x00",
401                b'\x01' => return "\\x01",
402                _ => {}
403            }
404        }
405        core::str::from_utf8(&self.chars[..self.len()]).expect("IsoString contains invalid UTF-8")
406    }
407
408    /// Borrow the contents as a `&str` if they are valid UTF-8.
409    pub fn try_as_str(&self) -> Result<&str, core::str::Utf8Error> {
410        if self.chars.len() == 1 {
411            match self.chars[0] {
412                b'\x00' => return Ok("\\x00"),
413                b'\x01' => return Ok("\\x01"),
414                _ => {}
415            }
416        }
417        core::str::from_utf8(&self.chars[..self.len()])
418    }
419}
420
421#[cfg(feature = "alloc")]
422impl<C: Charset> core::fmt::Display for IsoString<C> {
423    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
424        match self.try_as_str() {
425            Ok(s) => f.write_str(s),
426            Err(_) => write!(f, "{:?}", &self.chars[..self.len()]),
427        }
428    }
429}
430
431#[cfg(feature = "alloc")]
432impl<C: Charset> core::fmt::Debug for IsoString<C> {
433    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
434        match self.try_as_str() {
435            Ok(s) => write!(f, "\"{s}\""),
436            Err(_) => write!(f, "{:?}", &self.chars[..self.len()]),
437        }
438    }
439}
440
441/// Type alias for IsoStrA.
442pub type IsoStrA<const N: usize> = IsoStr<CharsetA, N>;
443/// Type alias for IsoStrD.
444pub type IsoStrD<const N: usize> = IsoStr<CharsetD, N>;
445#[cfg(feature = "alloc")]
446/// Type alias for IsoStringA.
447pub type IsoStringA = IsoString<CharsetA>;
448#[cfg(feature = "alloc")]
449/// Type alias for IsoStringD.
450pub type IsoStringD = IsoString<CharsetD>;
451
452#[cfg(test)]
453mod iso_str_safety_tests {
454    use super::*;
455
456    /// `IsoStr` is `bytemuck::Pod`, so an attacker (or a malformed disk image)
457    /// can construct one from arbitrary bytes. Previously `to_str` used
458    /// `from_utf8_unchecked` under the assumption that contents were always
459    /// ASCII — that assumption only holds for strings built via `from_str`,
460    /// not for ones loaded from disk. `try_to_str` must reject invalid UTF-8
461    /// safely.
462    #[test]
463    fn try_to_str_rejects_non_utf8_bytes() {
464        let mut bytes = [b' '; 16];
465        bytes[0] = 0xFF; // invalid as UTF-8 lead byte
466        let s: IsoStrA<16> = IsoStrA::from_bytes_exact(bytes);
467        assert!(s.try_to_str().is_err());
468    }
469
470    #[test]
471    #[should_panic(expected = "invalid UTF-8")]
472    fn to_str_panics_on_invalid_utf8() {
473        let mut bytes = [b' '; 16];
474        bytes[0] = 0xFF;
475        let s: IsoStrA<16> = IsoStrA::from_bytes_exact(bytes);
476        let _ = s.to_str();
477    }
478
479    #[test]
480    fn debug_does_not_panic_on_invalid_utf8() {
481        use core::fmt::Write as _;
482        struct Sink;
483        impl core::fmt::Write for Sink {
484            fn write_str(&mut self, _: &str) -> core::fmt::Result {
485                Ok(())
486            }
487        }
488        let mut bytes = [b' '; 16];
489        bytes[0] = 0xFF;
490        let s: IsoStrA<16> = IsoStrA::from_bytes_exact(bytes);
491        write!(Sink, "{s:?}").unwrap();
492        write!(Sink, "{s}").unwrap();
493    }
494
495    #[cfg(feature = "alloc")]
496    #[test]
497    fn iso_string_try_as_str_rejects_non_utf8() {
498        let s = IsoStringA::from_bytes(&[0xFFu8, 0xFE, b'a']);
499        assert!(s.try_as_str().is_err());
500    }
501
502    #[cfg(feature = "alloc")]
503    #[test]
504    fn iso_string_round_trips_valid_utf8() {
505        let s = IsoStringA::from_utf8("HELLO");
506        assert_eq!(s.try_as_str().unwrap(), "HELLO");
507    }
508}
509
510/// Defines behavior for StdNum.
511pub trait StdNum: Copy {
512    /// The `LsbType` associated type.
513    type LsbType: bytemuck::Pod + bytemuck::Zeroable + Endian<Output = Self>;
514    /// The `MsbType` associated type.
515    type MsbType: bytemuck::Pod + bytemuck::Zeroable + Endian<Output = Self>;
516}
517
518impl StdNum for u16 {
519    type LsbType = U16<LittleEndian>;
520    type MsbType = U16<BigEndian>;
521}
522
523impl StdNum for u32 {
524    type LsbType = U32<LittleEndian>;
525    type MsbType = U32<BigEndian>;
526}
527
528#[repr(C)]
529#[derive(Clone, Copy)]
530/// Represents LsbMsb.
531pub struct LsbMsb<T: StdNum> {
532    lsb: T::LsbType,
533    msb: T::MsbType,
534}
535
536impl<T> core::fmt::Debug for LsbMsb<T>
537where
538    T: StdNum + core::fmt::Debug,
539{
540    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
541        core::fmt::Debug::fmt(&self.read(), f)
542    }
543}
544
545unsafe impl<T: StdNum> bytemuck::Zeroable for LsbMsb<T> {}
546unsafe impl<T: StdNum + Copy + 'static> bytemuck::Pod for LsbMsb<T> {}
547
548impl<T: StdNum> LsbMsb<T> {
549    /// Performs the `new` operation.
550    pub fn new(value: T) -> Self {
551        Self {
552            lsb: Endian::new(value),
553            msb: Endian::new(value),
554        }
555    }
556
557    /// Performs the `read` operation.
558    pub fn read(&self) -> T {
559        #[cfg(target_endian = "little")]
560        {
561            self.lsb.get()
562        }
563        #[cfg(target_endian = "big")]
564        {
565            self.msb.get()
566        }
567    }
568
569    /// Returns whether the little- and big-endian copies encode the same value.
570    pub fn is_consistent(&self) -> bool
571    where
572        T: PartialEq,
573    {
574        self.lsb.get() == self.msb.get()
575    }
576
577    /// Performs the `write` operation.
578    pub fn write(&mut self, value: T) {
579        self.lsb.set(value);
580        self.msb.set(value);
581    }
582}
583
584/// Type alias for U16LsbMsb.
585pub type U16LsbMsb = LsbMsb<u16>;
586/// Type alias for U32LsbMsb.
587pub type U32LsbMsb = LsbMsb<u32>;
588
589#[repr(C, packed)]
590#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
591/// Represents DecDateTime.
592pub struct DecDateTime {
593    /// The `year` field.
594    pub year: IsoStrD<4>,
595    /// The `month` field.
596    pub month: IsoStrD<2>,
597    /// The `day` field.
598    pub day: IsoStrD<2>,
599    /// The `hour` field.
600    pub hour: IsoStrD<2>,
601    /// The `minute` field.
602    pub minute: IsoStrD<2>,
603    /// The `second` field.
604    pub second: IsoStrD<2>,
605    /// The `hundredths` field.
606    pub hundredths: IsoStrD<2>,
607    /// The `timezone` field.
608    pub timezone: u8,
609}
610
611impl core::fmt::Debug for DecDateTime {
612    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
613        f.debug_struct("DecDateTime")
614            .field("year", &self.year)
615            .field("month", &self.month)
616            .field("day", &self.day)
617            .field("hour", &self.hour)
618            .field("minute", &self.minute)
619            .field("second", &self.second)
620            .field("hundredths", &self.hundredths)
621            .field("timezone", &self.timezone)
622            .finish_non_exhaustive()
623    }
624}
625
626impl Default for DecDateTime {
627    fn default() -> Self {
628        Self {
629            year: IsoStrD::from_bytes_exact(*b"0000"),
630            month: IsoStrD::from_bytes_exact(*b"00"),
631            day: IsoStrD::from_bytes_exact(*b"00"),
632            hour: IsoStrD::from_bytes_exact(*b"00"),
633            minute: IsoStrD::from_bytes_exact(*b"00"),
634            second: IsoStrD::from_bytes_exact(*b"00"),
635            hundredths: IsoStrD::from_bytes_exact(*b"00"),
636            timezone: 0,
637        }
638    }
639}
640
641impl DecDateTime {
642    #[cfg(feature = "std")]
643    fn decimal<const N: usize>(mut value: u32) -> IsoStrD<N> {
644        let mut bytes = [b'0'; N];
645        for byte in bytes.iter_mut().rev() {
646            *byte = b'0' + (value % 10) as u8;
647            value /= 10;
648        }
649        IsoStrD::from_bytes_exact(bytes)
650    }
651
652    #[cfg(feature = "std")]
653    /// Performs the `now` operation.
654    pub fn now() -> Self {
655        use chrono::{DateTime, Datelike, Timelike, Utc};
656        let now: DateTime<Utc> = SystemTime::now().into();
657        Self {
658            year: Self::decimal(now.year() as u32),
659            month: Self::decimal(now.month()),
660            day: Self::decimal(now.day()),
661            hour: Self::decimal(now.hour()),
662            minute: Self::decimal(now.minute()),
663            second: Self::decimal(now.second()),
664            hundredths: Self::decimal(now.nanosecond() / 10_000_000),
665            timezone: 0,
666        }
667    }
668
669    /// Creates a default datetime for no-std environments
670    #[cfg(not(feature = "std"))]
671    pub fn now() -> Self {
672        Self::default()
673    }
674}
675
676#[cfg(all(test, feature = "std"))]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn decimal_datetime_uses_zero_padded_digits() {
682        let date = DecDateTime::now();
683        for field in [
684            date.year.as_bytes().as_slice(),
685            date.month.as_bytes().as_slice(),
686            date.day.as_bytes().as_slice(),
687            date.hour.as_bytes().as_slice(),
688            date.minute.as_bytes().as_slice(),
689            date.second.as_bytes().as_slice(),
690            date.hundredths.as_bytes().as_slice(),
691        ] {
692            assert!(field.iter().all(u8::is_ascii_digit));
693        }
694    }
695
696    #[test]
697    fn test_charset_a_substitute() {
698        let original = b"thisisatest\\";
699        let mut new = original.to_vec();
700        CharsetA::substitute_invalid(new.iter_mut());
701        assert_eq!(new, b"THISISATEST_");
702    }
703
704    #[test]
705    fn test_charset_d_substitute() {
706        let original = b"thisisatest?new";
707        let mut new = original.to_vec();
708        CharsetD::substitute_invalid(new.iter_mut());
709        assert_eq!(new, b"THISISATEST_NEW");
710    }
711
712    #[test]
713    fn test_iso_str_len_trailing_spaces() {
714        // "HELLO WORLD" followed by trailing spaces (using from_bytes_exact
715        // since space is the padding char, not in charset validation)
716        let mut bytes = [b' '; 20];
717        bytes[..11].copy_from_slice(b"HELLO WORLD");
718        let s = IsoStrA::<20>::from_bytes_exact(bytes);
719        assert_eq!(s.len(), 11);
720        assert_eq!(s.to_str(), "HELLO WORLD");
721    }
722
723    #[test]
724    fn test_iso_str_len_all_spaces() {
725        let s = IsoStrA::<10>::empty();
726        assert_eq!(s.len(), 0);
727        assert!(s.is_empty());
728    }
729
730    #[test]
731    fn test_iso_str_len_no_trailing_spaces() {
732        let s = IsoStrA::<5>::from_str("ABCDE").unwrap();
733        assert_eq!(s.len(), 5);
734    }
735
736    #[test]
737    fn test_iso_str_from_str_lossy() {
738        // Lowercase should be auto-uppercased
739        let s = IsoStrA::<20>::from_str_lossy("hello world").unwrap();
740        assert_eq!(s.to_str(), "HELLO WORLD");
741
742        // Invalid chars should be substituted with '_'
743        let s = IsoStrD::<10>::from_str_lossy("test\\new").unwrap();
744        assert_eq!(s.to_str(), "TEST_NEW");
745
746        // Too long should still error
747        let err = IsoStrA::<3>::from_str_lossy("toolong");
748        assert!(matches!(err, Err(IsoStrError::TooLong { max: 3, got: 7 })));
749    }
750
751    #[test]
752    fn test_iso_str_from_str_unchecked() {
753        // Lowercase should be preserved as-is
754        let s = IsoStrA::<20>::from_str_unchecked("hello world").unwrap();
755        assert_eq!(s.to_str(), "hello world");
756
757        // Non-compliant chars should be preserved as-is
758        let s = IsoStrD::<10>::from_str_unchecked("test\\new").unwrap();
759        assert_eq!(s.to_str(), "test\\new");
760
761        // Too long should still error
762        let err = IsoStrA::<3>::from_str_unchecked("toolong");
763        assert!(matches!(err, Err(IsoStrError::TooLong { max: 3, got: 7 })));
764    }
765
766    #[test]
767    fn test_iso_str_len_embedded_spaces() {
768        // "A B C" should preserve embedded spaces in length
769        let mut bytes = [b' '; 10];
770        bytes[..5].copy_from_slice(b"A B C");
771        let s = IsoStrA::<10>::from_bytes_exact(bytes);
772        assert_eq!(s.len(), 5);
773        assert_eq!(s.to_str(), "A B C");
774    }
775}