1extern crate alloc;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8use core::str::FromStr;
9
10use crate::{EdgeRelation, Header, Id128, Timestamp};
11
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum EntityKind {
20 #[default]
23 Concept,
24 Document,
27 Dataset,
30 Project,
33 Person,
35 Org,
37 Artifact,
39 Service,
41}
42
43impl EntityKind {
44 pub const ALL: [Self; 8] = [
45 Self::Concept,
46 Self::Document,
47 Self::Dataset,
48 Self::Project,
49 Self::Person,
50 Self::Org,
51 Self::Artifact,
52 Self::Service,
53 ];
54
55 pub const fn name(self) -> &'static str {
56 match self {
57 Self::Concept => "concept",
58 Self::Document => "document",
59 Self::Dataset => "dataset",
60 Self::Project => "project",
61 Self::Person => "person",
62 Self::Org => "org",
63 Self::Artifact => "artifact",
64 Self::Service => "service",
65 }
66 }
67}
68
69impl fmt::Display for EntityKind {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 f.write_str(self.name())
72 }
73}
74
75const ENTITY_KIND_VALID: &[&str] = &[
76 "concept", "document", "dataset", "project", "person", "org", "artifact", "service",
77];
78
79impl FromStr for EntityKind {
80 type Err = crate::error::UnknownVariant;
81
82 fn from_str(s: &str) -> Result<Self, Self::Err> {
83 match s.trim().to_ascii_lowercase().as_str() {
84 "concept" => Ok(Self::Concept),
85 "document" | "doc" | "paper" => Ok(Self::Document),
86 "dataset" | "data" | "benchmark" => Ok(Self::Dataset),
87 "project" | "repo" | "crate" | "library" | "lib" => Ok(Self::Project),
88 "person" | "author" | "researcher" => Ok(Self::Person),
89 "org" | "organization" | "organisation" | "lab" | "company" => Ok(Self::Org),
90 "artifact" | "art" => Ok(Self::Artifact),
91 "service" | "svc" => Ok(Self::Service),
92 other => Err(crate::error::UnknownVariant::new(
93 "entity_kind",
94 other,
95 ENTITY_KIND_VALID,
96 )),
97 }
98 }
99}
100
101#[derive(Clone, Debug)]
103#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
104pub struct Entity {
105 #[cfg_attr(feature = "serde", serde(flatten))]
106 pub header: Header,
107 pub kind: EntityKind,
108 pub entity_type: Option<String>,
111 pub name: String,
112 pub description: Option<String>,
113 pub properties: BTreeMap<String, PropertyValue>,
114 pub tags: Vec<String>,
115 pub deleted_at: Option<Timestamp>,
116}
117
118#[derive(Clone, Debug)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct Link {
122 pub id: Id128,
123 pub namespace: String,
124 pub source: Id128,
125 pub target: Id128,
126 pub relation: EdgeRelation,
127 pub properties: BTreeMap<String, PropertyValue>,
128 pub weight: f64,
129 pub created_at: Timestamp,
130 pub updated_at: Timestamp,
131 pub deleted_at: Option<Timestamp>,
132}
133
134#[derive(Clone, Debug, PartialEq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140#[cfg_attr(feature = "serde", serde(untagged))]
141pub enum PropertyValue {
142 String(String),
143 Integer(i64),
144 Float(f64),
145 Boolean(bool),
146 Array(Vec<PropertyValue>),
147 Object(BTreeMap<String, PropertyValue>),
148 Null,
149}
150
151impl fmt::Display for PropertyValue {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::String(s) => f.write_str(s),
155 Self::Integer(n) => write!(f, "{n}"),
156 Self::Float(n) => write!(f, "{n}"),
157 Self::Boolean(b) => write!(f, "{b}"),
158 Self::Array(arr) => write!(f, "[{} items]", arr.len()),
159 Self::Object(obj) => write!(f, "{{{} keys}}", obj.len()),
160 Self::Null => f.write_str("null"),
161 }
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::{Namespace, Timestamp};
169
170 #[test]
171 fn entity_with_properties() {
172 let mut props = BTreeMap::new();
173 props.insert("role".into(), PropertyValue::String("engineer".into()));
174 props.insert("age".into(), PropertyValue::Integer(30));
175
176 let entity = Entity {
177 header: Header::new(
178 Id128::from_u128(1),
179 Namespace::local(),
180 Timestamp::from_secs(1700000000),
181 ),
182 kind: EntityKind::Person,
183 entity_type: Some("researcher".into()),
184 name: "Ocean".into(),
185 description: None,
186 properties: props,
187 tags: alloc::vec![],
188 deleted_at: None,
189 };
190 assert_eq!(entity.kind, EntityKind::Person);
191 assert_eq!(entity.kind.name(), "person");
192 assert_eq!(entity.entity_type.as_deref(), Some("researcher"));
193 assert_eq!(entity.properties.len(), 2);
194 }
195
196 #[test]
197 fn entity_kind_default_is_concept() {
198 assert_eq!(EntityKind::default(), EntityKind::Concept);
199 }
200
201 #[test]
202 fn entity_kind_display_roundtrip() {
203 for kind in EntityKind::ALL {
204 let s = alloc::format!("{kind}");
205 let parsed = EntityKind::from_str(&s).unwrap();
206 assert_eq!(parsed, kind);
207 }
208 }
209
210 #[test]
211 fn entity_kind_from_str_aliases() {
212 assert_eq!(EntityKind::from_str("doc").unwrap(), EntityKind::Document);
213 assert_eq!(EntityKind::from_str("paper").unwrap(), EntityKind::Document);
214 assert_eq!(
215 EntityKind::from_str("benchmark").unwrap(),
216 EntityKind::Dataset
217 );
218 assert_eq!(EntityKind::from_str("repo").unwrap(), EntityKind::Project);
219 assert_eq!(EntityKind::from_str("author").unwrap(), EntityKind::Person);
220 assert_eq!(EntityKind::from_str("lab").unwrap(), EntityKind::Org);
221 assert_eq!(EntityKind::from_str("art").unwrap(), EntityKind::Artifact);
222 assert_eq!(EntityKind::from_str("svc").unwrap(), EntityKind::Service);
223 }
224
225 #[test]
226 fn entity_kind_artifact_and_service_roundtrip() {
227 assert_eq!(EntityKind::Artifact.name(), "artifact");
228 assert_eq!(EntityKind::Service.name(), "service");
229 assert_eq!(
230 EntityKind::from_str("artifact").unwrap(),
231 EntityKind::Artifact
232 );
233 assert_eq!(
234 EntityKind::from_str("service").unwrap(),
235 EntityKind::Service
236 );
237 }
238
239 #[test]
240 fn entity_kind_all_has_eight_variants() {
241 assert_eq!(EntityKind::ALL.len(), 8);
242 assert!(EntityKind::ALL.contains(&EntityKind::Artifact));
243 assert!(EntityKind::ALL.contains(&EntityKind::Service));
244 }
245
246 #[test]
247 fn entity_kind_unknown_valid_list_includes_new_kinds() {
248 let err = EntityKind::from_str("gadget").unwrap_err();
249 assert!(err.valid.contains(&"artifact"));
250 assert!(err.valid.contains(&"service"));
251 }
252
253 #[test]
254 fn entity_kind_from_str_case_insensitive() {
255 assert_eq!(
256 EntityKind::from_str("CONCEPT").unwrap(),
257 EntityKind::Concept
258 );
259 assert_eq!(EntityKind::from_str("Person").unwrap(), EntityKind::Person);
260 }
261
262 #[test]
263 fn entity_kind_from_str_unknown_errors() {
264 let err = EntityKind::from_str("gadget").unwrap_err();
265 assert_eq!(err.domain, "entity_kind");
266 assert_eq!(err.value, "gadget");
267 assert!(err.valid.contains(&"concept"));
268 }
269
270 #[test]
271 fn link_construction() {
272 let ts = Timestamp::from_secs(1700000000);
273 let link = Link {
274 id: Id128::from_u128(100),
275 namespace: "default".into(),
276 source: Id128::from_u128(1),
277 target: Id128::from_u128(2),
278 relation: EdgeRelation::Extends,
279 properties: BTreeMap::new(),
280 weight: 1.0,
281 created_at: ts,
282 updated_at: ts,
283 deleted_at: None,
284 };
285 assert_eq!(link.relation, EdgeRelation::Extends);
286 }
287}