Skip to main content

cobre_core/
entity_id.rs

1//! Strongly-typed entity identifier used across all entity collections.
2//!
3//! [`EntityId`] wraps an `i32` (the type used in JSON input schemas) and
4//! prevents accidental confusion between entity IDs and collection indices.
5
6use core::fmt;
7
8/// Strongly-typed entity identifier.
9///
10/// Wraps the `i32` identifier from JSON input files. The newtype pattern prevents
11/// accidental confusion between entity IDs and collection indices (`usize`), which
12/// is a common source of bugs in systems with both ID-based lookup and index-based
13/// access. `EntityId` is used as the key in `HashMap<EntityId, usize>` lookup tables
14/// and as the value in cross-reference fields (e.g., `Hydro::bus_id`, `Line::source_bus_id`).
15///
16/// Why `i32` and not `String`: All JSON entity schemas use integer IDs (`i32`). Integer
17/// keys are cheaper to hash, compare, and copy than strings — important because
18/// `EntityId` appears in every lookup table and cross-reference field. If a future
19/// input format requires string IDs, the newtype boundary isolates the change to
20/// `EntityId`'s internal representation and its `From`/`Into` impls.
21///
22/// # Examples
23///
24/// ```
25/// use cobre_core::EntityId;
26///
27/// let id: EntityId = EntityId::from(42);
28/// assert_eq!(id.to_string(), "42");
29///
30/// let raw: i32 = i32::from(id);
31/// assert_eq!(raw, 42);
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct EntityId(pub i32);
36
37impl fmt::Display for EntityId {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "{}", self.0)
40    }
41}
42
43impl From<i32> for EntityId {
44    fn from(value: i32) -> Self {
45        Self(value)
46    }
47}
48
49impl From<EntityId> for i32 {
50    fn from(id: EntityId) -> Self {
51        id.0
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use core::hash::{Hash, Hasher};
58    use std::collections::hash_map::DefaultHasher;
59
60    use super::EntityId;
61
62    #[test]
63    fn test_equality() {
64        let a = EntityId(1);
65        let b = EntityId(1);
66        let c = EntityId(2);
67        assert_eq!(a, b);
68        assert_ne!(a, c);
69    }
70
71    #[test]
72    fn test_copy() {
73        let a = EntityId(10);
74        let b = a;
75        assert_eq!(a, b);
76        assert_eq!(a.0, 10);
77    }
78
79    #[test]
80    fn test_hash_consistency() {
81        let mut hasher_a = DefaultHasher::new();
82        EntityId(99).hash(&mut hasher_a);
83        let hash_a = hasher_a.finish();
84
85        let mut hasher_b = DefaultHasher::new();
86        EntityId(99).hash(&mut hasher_b);
87        let hash_b = hasher_b.finish();
88
89        assert_eq!(hash_a, hash_b);
90    }
91
92    #[test]
93    fn test_display() {
94        assert_eq!(EntityId(42).to_string(), "42");
95        assert_eq!(EntityId(0).to_string(), "0");
96        assert_eq!(EntityId(-1).to_string(), "-1");
97    }
98
99    #[test]
100    fn test_from_i32() {
101        let id = EntityId::from(5);
102        assert_eq!(id, EntityId(5));
103    }
104
105    #[test]
106    fn test_into_i32() {
107        let raw: i32 = i32::from(EntityId(7));
108        assert_eq!(raw, 7);
109    }
110
111    #[cfg(feature = "serde")]
112    #[test]
113    fn test_entity_id_serde_roundtrip() {
114        let id = EntityId(42);
115        let json = serde_json::to_string(&id).unwrap();
116        let deserialized: EntityId = serde_json::from_str(&json).unwrap();
117        assert_eq!(id, deserialized);
118    }
119}