hwpforge_foundation/
index.rs

1//! Branded (phantom-typed) index types for type-safe collection access.
2//!
3//! [`Index<T>`] wraps a `usize` with a phantom type parameter `T` so that
4//! indices into different collections cannot be accidentally mixed at
5//! compile time.
6//!
7//! # Why No `Default`?
8//!
9//! Index 0 is valid data (the first element), not a sentinel value.
10//! Providing `Default` would invite bugs where an "uninitialized" index
11//! silently points at element 0.
12//!
13//! # Examples
14//!
15//! ```
16//! use hwpforge_foundation::CharShapeIndex;
17//!
18//! let idx = CharShapeIndex::new(3);
19//! assert_eq!(idx.get(), 3);
20//!
21//! // Bounds checking
22//! assert!(idx.checked_get(10).is_ok());
23//! assert!(idx.checked_get(2).is_err());
24//! ```
25
26use std::fmt;
27use std::hash::{Hash, Hasher};
28use std::marker::PhantomData;
29
30use serde::{Deserialize, Deserializer, Serialize, Serializer};
31
32use crate::error::{FoundationError, FoundationResult};
33
34/// A branded index into a typed collection.
35///
36/// The phantom type `T` prevents mixing indices of different domains
37/// (e.g. you cannot use a `CharShapeIndex` where a `ParaShapeIndex`
38/// is expected).
39///
40/// Serializes as a plain `usize`, not as a struct.
41///
42/// # Examples
43///
44/// ```
45/// use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
46///
47/// let cs = CharShapeIndex::new(5);
48/// let ps = ParaShapeIndex::new(5);
49///
50/// // cs and ps have the same numeric value but are different types.
51/// assert_eq!(cs.get(), ps.get());
52/// // cs == ps; // Would not compile -- different phantom types!
53/// ```
54pub struct Index<T> {
55    value: usize,
56    _phantom: PhantomData<T>,
57}
58
59// Compile-time size guarantee: usize + ZST = usize
60const _: () = assert!(std::mem::size_of::<Index<()>>() == std::mem::size_of::<usize>());
61
62// Manual trait impls because derive would require T: Trait bounds we don't want.
63
64impl<T> Clone for Index<T> {
65    fn clone(&self) -> Self {
66        *self
67    }
68}
69
70impl<T> Copy for Index<T> {}
71
72impl<T> PartialEq for Index<T> {
73    fn eq(&self, other: &Self) -> bool {
74        self.value == other.value
75    }
76}
77
78impl<T> Eq for Index<T> {}
79
80impl<T> PartialOrd for Index<T> {
81    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
82        Some(self.cmp(other))
83    }
84}
85
86impl<T> Ord for Index<T> {
87    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
88        self.value.cmp(&other.value)
89    }
90}
91
92impl<T> Hash for Index<T> {
93    fn hash<H: Hasher>(&self, state: &mut H) {
94        self.value.hash(state);
95    }
96}
97
98impl<T> Index<T> {
99    /// Creates a new index with the given value.
100    ///
101    /// No bounds checking is performed here; use [`checked_get`](Self::checked_get)
102    /// when accessing a collection.
103    pub const fn new(value: usize) -> Self {
104        Self { value, _phantom: PhantomData }
105    }
106
107    /// Returns the raw `usize` value.
108    ///
109    /// # Note
110    ///
111    /// The caller is responsible for ensuring this index is within
112    /// the bounds of the target collection. Prefer [`checked_get`](Self::checked_get)
113    /// for safe access.
114    pub const fn get(self) -> usize {
115        self.value
116    }
117
118    /// Returns the raw value after verifying it is less than `max`.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`FoundationError::IndexOutOfBounds`] when
123    /// `self.value >= max`.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use hwpforge_foundation::FontIndex;
129    ///
130    /// let idx = FontIndex::new(3);
131    /// assert_eq!(idx.checked_get(10).unwrap(), 3);
132    /// assert!(idx.checked_get(2).is_err());
133    /// ```
134    pub fn checked_get(self, max: usize) -> FoundationResult<usize> {
135        if self.value >= max {
136            return Err(FoundationError::IndexOutOfBounds {
137                index: self.value,
138                max,
139                type_name: std::any::type_name::<T>(),
140            });
141        }
142        Ok(self.value)
143    }
144}
145
146impl<T> fmt::Debug for Index<T> {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        // Extract the short type name from the full path
149        let full = std::any::type_name::<T>();
150        let short = full.rsplit("::").next().unwrap_or(full);
151        write!(f, "Index<{short}>({})", self.value)
152    }
153}
154
155impl<T> fmt::Display for Index<T> {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        let full = std::any::type_name::<T>();
158        let short = full.rsplit("::").next().unwrap_or(full);
159        write!(f, "{short}[{}]", self.value)
160    }
161}
162
163// Serialize as plain usize (not as a struct with phantom)
164impl<T> Serialize for Index<T> {
165    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
166        self.value.serialize(serializer)
167    }
168}
169
170impl<'de, T> Deserialize<'de> for Index<T> {
171    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172        let value = usize::deserialize(deserializer)?;
173        Ok(Self::new(value))
174    }
175}
176
177impl<T> schemars::JsonSchema for Index<T> {
178    fn schema_name() -> std::borrow::Cow<'static, str> {
179        "Index".into()
180    }
181
182    fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
183        gen.subschema_for::<usize>()
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Marker types and type aliases
189// ---------------------------------------------------------------------------
190
191/// Phantom marker for character shape indices.
192pub struct CharShapeMarker;
193/// Phantom marker for paragraph shape indices.
194pub struct ParaShapeMarker;
195/// Phantom marker for font indices.
196pub struct FontMarker;
197/// Phantom marker for border/fill indices.
198pub struct BorderFillMarker;
199/// Phantom marker for style indices.
200pub struct StyleMarker;
201/// Phantom marker for numbering definition indices.
202pub struct NumberingMarker;
203/// Phantom marker for tab property indices.
204pub struct TabMarker;
205
206/// Index into a character shape collection.
207pub type CharShapeIndex = Index<CharShapeMarker>;
208/// Index into a paragraph shape collection.
209pub type ParaShapeIndex = Index<ParaShapeMarker>;
210/// Index into a font collection.
211pub type FontIndex = Index<FontMarker>;
212/// Index into a border/fill collection.
213pub type BorderFillIndex = Index<BorderFillMarker>;
214/// Index into a style collection.
215pub type StyleIndex = Index<StyleMarker>;
216/// Index into the numbering definition list.
217pub type NumberingIndex = Index<NumberingMarker>;
218/// Index into the tab properties list.
219pub type TabIndex = Index<TabMarker>;
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // ===================================================================
226    // Index<T> edge cases (10+)
227    // ===================================================================
228
229    // Edge Case 1: Index 0 is valid
230    #[test]
231    fn index_zero_is_valid() {
232        let idx = CharShapeIndex::new(0);
233        assert_eq!(idx.get(), 0);
234        assert!(idx.checked_get(1).is_ok());
235    }
236
237    // Edge Case 2: In-range checked_get
238    #[test]
239    fn index_in_range() {
240        let idx = CharShapeIndex::new(5);
241        assert_eq!(idx.checked_get(10).unwrap(), 5);
242    }
243
244    // Edge Case 3: Out-of-range checked_get
245    #[test]
246    fn index_out_of_range() {
247        let idx = CharShapeIndex::new(10);
248        let err = idx.checked_get(5).unwrap_err();
249        match err {
250            FoundationError::IndexOutOfBounds { index, max, type_name } => {
251                assert_eq!(index, 10);
252                assert_eq!(max, 5);
253                assert!(type_name.contains("CharShape"), "type_name: {type_name}");
254            }
255            other => panic!("unexpected error: {other}"),
256        }
257    }
258
259    // Edge Case 4: checked_get at exact boundary -> error (>= max)
260    #[test]
261    fn index_at_exact_boundary_is_error() {
262        let idx = CharShapeIndex::new(5);
263        assert!(idx.checked_get(5).is_err());
264    }
265
266    // Edge Case 5: checked_get just below boundary -> ok
267    #[test]
268    fn index_just_below_boundary() {
269        let idx = CharShapeIndex::new(4);
270        assert_eq!(idx.checked_get(5).unwrap(), 4);
271    }
272
273    // Edge Case 6: usize::MAX
274    #[test]
275    fn index_usize_max() {
276        let idx = CharShapeIndex::new(usize::MAX);
277        assert_eq!(idx.get(), usize::MAX);
278        assert!(idx.checked_get(usize::MAX).is_err()); // >= max
279    }
280
281    // Edge Case 7: Type safety (different phantom types are distinct)
282    #[test]
283    fn index_type_safety() {
284        fn accept_char_shape(_: CharShapeIndex) {}
285        fn accept_para_shape(_: ParaShapeIndex) {}
286
287        let cs = CharShapeIndex::new(0);
288        let ps = ParaShapeIndex::new(0);
289
290        accept_char_shape(cs);
291        accept_para_shape(ps);
292        // accept_char_shape(ps); // Would not compile!
293    }
294
295    // Edge Case 8: PartialEq, Eq
296    #[test]
297    fn index_equality() {
298        let a = CharShapeIndex::new(5);
299        let b = CharShapeIndex::new(5);
300        let c = CharShapeIndex::new(6);
301        assert_eq!(a, b);
302        assert_ne!(a, c);
303    }
304
305    // Edge Case 9: Hash (can be used as HashMap key)
306    #[test]
307    fn index_hash() {
308        use std::collections::HashMap;
309        let mut map = HashMap::new();
310        map.insert(FontIndex::new(0), "Batang");
311        map.insert(FontIndex::new(1), "Dotum");
312        assert_eq!(map[&FontIndex::new(0)], "Batang");
313    }
314
315    // Edge Case 10: Ord
316    #[test]
317    fn index_ord() {
318        let a = CharShapeIndex::new(3);
319        let b = CharShapeIndex::new(7);
320        assert!(a < b);
321    }
322
323    // Edge Case 11: Display format
324    #[test]
325    fn index_display() {
326        let idx = CharShapeIndex::new(3);
327        let s = idx.to_string();
328        assert!(s.contains("CharShape"), "display: {s}");
329        assert!(s.contains("[3]"), "display: {s}");
330    }
331
332    // Edge Case 12: Debug format
333    #[test]
334    fn index_debug() {
335        let idx = FontIndex::new(42);
336        let s = format!("{idx:?}");
337        assert!(s.contains("Font"), "debug: {s}");
338        assert!(s.contains("42"), "debug: {s}");
339    }
340
341    // Edge Case 13: Serialize as plain usize
342    #[test]
343    fn index_serde_as_usize() {
344        let idx = CharShapeIndex::new(7);
345        let json = serde_json::to_string(&idx).unwrap();
346        assert_eq!(json, "7");
347        let back: CharShapeIndex = serde_json::from_str(&json).unwrap();
348        assert_eq!(back, idx);
349    }
350
351    // Edge Case 14: Copy semantics
352    #[test]
353    fn index_is_copy() {
354        let a = CharShapeIndex::new(1);
355        let b = a; // Copy
356        assert_eq!(a, b); // both still usable
357    }
358
359    // Edge Case 15: checked_get with max=0 -> always error
360    #[test]
361    fn index_checked_get_empty_collection() {
362        let idx = CharShapeIndex::new(0);
363        assert!(idx.checked_get(0).is_err());
364    }
365
366    // ===================================================================
367    // proptest
368    // ===================================================================
369
370    use proptest::prelude::*;
371
372    proptest! {
373        #[test]
374        fn prop_index_in_bounds(idx in 0usize..1000, max in 1usize..2000) {
375            let index = CharShapeIndex::new(idx);
376            if idx < max {
377                prop_assert_eq!(index.checked_get(max).unwrap(), idx);
378            } else {
379                prop_assert!(index.checked_get(max).is_err());
380            }
381        }
382
383        #[test]
384        fn prop_index_serde_roundtrip(val in 0usize..100_000) {
385            let idx = FontIndex::new(val);
386            let json = serde_json::to_string(&idx).unwrap();
387            let back: FontIndex = serde_json::from_str(&json).unwrap();
388            prop_assert_eq!(idx, back);
389        }
390    }
391}