Skip to main content

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 bullet definition indices.
204pub struct BulletMarker;
205/// Phantom marker for tab property indices.
206pub struct TabMarker;
207
208/// Index into a character shape collection.
209pub type CharShapeIndex = Index<CharShapeMarker>;
210/// Index into a paragraph shape collection.
211pub type ParaShapeIndex = Index<ParaShapeMarker>;
212/// Index into a font collection.
213pub type FontIndex = Index<FontMarker>;
214/// Index into a border/fill collection.
215pub type BorderFillIndex = Index<BorderFillMarker>;
216/// Index into a style collection.
217pub type StyleIndex = Index<StyleMarker>;
218/// Index into the numbering definition list.
219pub type NumberingIndex = Index<NumberingMarker>;
220/// Index into the bullet definition list.
221pub type BulletIndex = Index<BulletMarker>;
222/// Index into the tab properties list.
223pub type TabIndex = Index<TabMarker>;
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    // ===================================================================
230    // Index<T> edge cases (10+)
231    // ===================================================================
232
233    // Edge Case 1: Index 0 is valid
234    #[test]
235    fn index_zero_is_valid() {
236        let idx = CharShapeIndex::new(0);
237        assert_eq!(idx.get(), 0);
238        assert!(idx.checked_get(1).is_ok());
239    }
240
241    // Edge Case 2: In-range checked_get
242    #[test]
243    fn index_in_range() {
244        let idx = CharShapeIndex::new(5);
245        assert_eq!(idx.checked_get(10).unwrap(), 5);
246    }
247
248    // Edge Case 3: Out-of-range checked_get
249    #[test]
250    fn index_out_of_range() {
251        let idx = CharShapeIndex::new(10);
252        let err = idx.checked_get(5).unwrap_err();
253        match err {
254            FoundationError::IndexOutOfBounds { index, max, type_name } => {
255                assert_eq!(index, 10);
256                assert_eq!(max, 5);
257                assert!(type_name.contains("CharShape"), "type_name: {type_name}");
258            }
259            other => panic!("unexpected error: {other}"),
260        }
261    }
262
263    // Edge Case 4: checked_get at exact boundary -> error (>= max)
264    #[test]
265    fn index_at_exact_boundary_is_error() {
266        let idx = CharShapeIndex::new(5);
267        assert!(idx.checked_get(5).is_err());
268    }
269
270    // Edge Case 5: checked_get just below boundary -> ok
271    #[test]
272    fn index_just_below_boundary() {
273        let idx = CharShapeIndex::new(4);
274        assert_eq!(idx.checked_get(5).unwrap(), 4);
275    }
276
277    // Edge Case 6: usize::MAX
278    #[test]
279    fn index_usize_max() {
280        let idx = CharShapeIndex::new(usize::MAX);
281        assert_eq!(idx.get(), usize::MAX);
282        assert!(idx.checked_get(usize::MAX).is_err()); // >= max
283    }
284
285    // Edge Case 7: Type safety (different phantom types are distinct)
286    #[test]
287    fn index_type_safety() {
288        fn accept_char_shape(_: CharShapeIndex) {}
289        fn accept_para_shape(_: ParaShapeIndex) {}
290
291        let cs = CharShapeIndex::new(0);
292        let ps = ParaShapeIndex::new(0);
293
294        accept_char_shape(cs);
295        accept_para_shape(ps);
296        // accept_char_shape(ps); // Would not compile!
297    }
298
299    // Edge Case 8: PartialEq, Eq
300    #[test]
301    fn index_equality() {
302        let a = CharShapeIndex::new(5);
303        let b = CharShapeIndex::new(5);
304        let c = CharShapeIndex::new(6);
305        assert_eq!(a, b);
306        assert_ne!(a, c);
307    }
308
309    // Edge Case 9: Hash (can be used as HashMap key)
310    #[test]
311    fn index_hash() {
312        use std::collections::HashMap;
313        let mut map = HashMap::new();
314        map.insert(FontIndex::new(0), "Batang");
315        map.insert(FontIndex::new(1), "Dotum");
316        assert_eq!(map[&FontIndex::new(0)], "Batang");
317    }
318
319    // Edge Case 10: Ord
320    #[test]
321    fn index_ord() {
322        let a = CharShapeIndex::new(3);
323        let b = CharShapeIndex::new(7);
324        assert!(a < b);
325    }
326
327    // Edge Case 11: Display format
328    #[test]
329    fn index_display() {
330        let idx = CharShapeIndex::new(3);
331        let s = idx.to_string();
332        assert!(s.contains("CharShape"), "display: {s}");
333        assert!(s.contains("[3]"), "display: {s}");
334    }
335
336    // Edge Case 12: Debug format
337    #[test]
338    fn index_debug() {
339        let idx = FontIndex::new(42);
340        let s = format!("{idx:?}");
341        assert!(s.contains("Font"), "debug: {s}");
342        assert!(s.contains("42"), "debug: {s}");
343    }
344
345    // Edge Case 13: Serialize as plain usize
346    #[test]
347    fn index_serde_as_usize() {
348        let idx = CharShapeIndex::new(7);
349        let json = serde_json::to_string(&idx).unwrap();
350        assert_eq!(json, "7");
351        let back: CharShapeIndex = serde_json::from_str(&json).unwrap();
352        assert_eq!(back, idx);
353    }
354
355    // Edge Case 14: Copy semantics
356    #[test]
357    fn index_is_copy() {
358        let a = CharShapeIndex::new(1);
359        let b = a; // Copy
360        assert_eq!(a, b); // both still usable
361    }
362
363    // Edge Case 15: checked_get with max=0 -> always error
364    #[test]
365    fn index_checked_get_empty_collection() {
366        let idx = CharShapeIndex::new(0);
367        assert!(idx.checked_get(0).is_err());
368    }
369
370    // ===================================================================
371    // proptest
372    // ===================================================================
373
374    use proptest::prelude::*;
375
376    proptest! {
377        #[test]
378        fn prop_index_in_bounds(idx in 0usize..1000, max in 1usize..2000) {
379            let index = CharShapeIndex::new(idx);
380            if idx < max {
381                prop_assert_eq!(index.checked_get(max).unwrap(), idx);
382            } else {
383                prop_assert!(index.checked_get(max).is_err());
384            }
385        }
386
387        #[test]
388        fn prop_index_serde_roundtrip(val in 0usize..100_000) {
389            let idx = FontIndex::new(val);
390            let json = serde_json::to_string(&idx).unwrap();
391            let back: FontIndex = serde_json::from_str(&json).unwrap();
392            prop_assert_eq!(idx, back);
393        }
394    }
395}