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] = [
46 Self::Concept,
47 Self::Document,
48 Self::Dataset,
49 Self::Project,
50 Self::Person,
51 Self::Org,
52 Self::Artifact,
53 Self::Service,
54 ];
55
56 pub const fn name(self) -> &'static str {
58 match self {
59 Self::Concept => "concept",
60 Self::Document => "document",
61 Self::Dataset => "dataset",
62 Self::Project => "project",
63 Self::Person => "person",
64 Self::Org => "org",
65 Self::Artifact => "artifact",
66 Self::Service => "service",
67 }
68 }
69}
70
71impl fmt::Display for EntityKind {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(self.name())
74 }
75}
76
77const ENTITY_KIND_VALID: &[&str] = &[
79 "concept", "document", "dataset", "project", "person", "org", "artifact", "service",
80];
81
82impl FromStr for EntityKind {
83 type Err = crate::error::UnknownVariant;
84
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
98 match s.trim().to_ascii_lowercase().as_str() {
99 "concept" => Ok(Self::Concept),
100 "document" | "doc" | "paper" => Ok(Self::Document),
101 "dataset" | "data" | "benchmark" => Ok(Self::Dataset),
102 "project" | "repo" | "crate" | "library" | "lib" => Ok(Self::Project),
103 "person" | "author" | "researcher" => Ok(Self::Person),
104 "org" | "organization" | "organisation" | "lab" | "company" => Ok(Self::Org),
105 "artifact" | "art" => Ok(Self::Artifact),
106 "service" | "svc" => Ok(Self::Service),
107 other => Err(crate::error::UnknownVariant::new(
108 "entity_kind",
109 other,
110 ENTITY_KIND_VALID,
111 )),
112 }
113 }
114}
115
116#[derive(Clone, Debug)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
119pub struct Entity {
120 #[cfg_attr(feature = "serde", serde(flatten))]
122 pub header: Header,
123 pub kind: EntityKind,
125 pub entity_type: Option<String>,
128 pub name: String,
130 pub description: Option<String>,
132 pub properties: BTreeMap<String, PropertyValue>,
134 pub tags: Vec<String>,
136 pub deleted_at: Option<Timestamp>,
138}
139
140#[derive(Clone, Debug)]
145#[cfg_attr(feature = "serde", derive(serde::Serialize))]
146#[cfg_attr(feature = "serde", serde(into = "LinkRaw"))]
147pub struct Link {
148 pub id: Id128,
150 pub namespace: String,
152 pub source: Id128,
154 pub target: Id128,
156 pub relation: EdgeRelation,
158 pub properties: BTreeMap<String, PropertyValue>,
160 pub weight: f64,
162 pub created_at: Timestamp,
164 pub updated_at: Timestamp,
166 pub deleted_at: Option<Timestamp>,
168}
169
170pub fn validate_edge_weight(weight: f64) -> bool {
174 weight.is_finite() && (0.0..=1.0).contains(&weight)
175}
176
177impl Link {
178 pub fn is_valid(&self) -> bool {
182 validate_edge_weight(self.weight)
183 }
184}
185
186#[cfg(feature = "serde")]
187#[derive(serde::Serialize, serde::Deserialize)]
188struct LinkRaw {
189 id: Id128,
190 namespace: String,
191 source: Id128,
192 target: Id128,
193 relation: EdgeRelation,
194 properties: BTreeMap<String, PropertyValue>,
195 weight: f64,
196 created_at: Timestamp,
197 updated_at: Timestamp,
198 deleted_at: Option<Timestamp>,
199}
200
201#[cfg(feature = "serde")]
202impl From<Link> for LinkRaw {
203 fn from(l: Link) -> Self {
204 Self {
205 id: l.id,
206 namespace: l.namespace,
207 source: l.source,
208 target: l.target,
209 relation: l.relation,
210 properties: l.properties,
211 weight: l.weight,
212 created_at: l.created_at,
213 updated_at: l.updated_at,
214 deleted_at: l.deleted_at,
215 }
216 }
217}
218
219#[cfg(feature = "serde")]
220impl TryFrom<LinkRaw> for Link {
221 type Error = String;
222
223 fn try_from(raw: LinkRaw) -> Result<Self, Self::Error> {
224 if !raw.weight.is_finite() {
225 return Err(alloc::format!(
226 "Link weight must be finite, got {}",
227 raw.weight
228 ));
229 }
230 if !(0.0..=1.0).contains(&raw.weight) {
231 return Err(alloc::format!(
232 "Link weight must be in [0.0, 1.0], got {}",
233 raw.weight
234 ));
235 }
236 Ok(Link {
237 id: raw.id,
238 namespace: raw.namespace,
239 source: raw.source,
240 target: raw.target,
241 relation: raw.relation,
242 properties: raw.properties,
243 weight: raw.weight,
244 created_at: raw.created_at,
245 updated_at: raw.updated_at,
246 deleted_at: raw.deleted_at,
247 })
248 }
249}
250
251#[cfg(feature = "serde")]
252impl<'de> serde::Deserialize<'de> for Link {
253 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
254 where
255 D: serde::Deserializer<'de>,
256 {
257 let raw = LinkRaw::deserialize(deserializer)?;
258 Link::try_from(raw).map_err(serde::de::Error::custom)
259 }
260}
261
262#[derive(Clone, Debug, PartialEq)]
267#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
268#[cfg_attr(feature = "serde", serde(untagged))]
269pub enum PropertyValue {
270 String(String),
271 Integer(i64),
272 Float(f64),
273 Boolean(bool),
274 Array(Vec<PropertyValue>),
275 Object(BTreeMap<String, PropertyValue>),
276 Null,
277}
278
279impl fmt::Display for PropertyValue {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 match self {
282 Self::String(s) => f.write_str(s),
283 Self::Integer(n) => write!(f, "{n}"),
284 Self::Float(n) => write!(f, "{n}"),
285 Self::Boolean(b) => write!(f, "{b}"),
286 Self::Array(arr) => write!(f, "[{} items]", arr.len()),
287 Self::Object(obj) => write!(f, "{{{} keys}}", obj.len()),
288 Self::Null => f.write_str("null"),
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::{Namespace, Timestamp};
297 #[cfg(feature = "serde")]
298 use alloc::string::ToString;
299
300 #[test]
301 fn edge_weight_validation_covers_numeric_boundaries() {
302 for weight in [-0.0, 0.0, f64::from_bits(1), 0.5, 1.0] {
303 assert!(crate::validate_edge_weight(weight), "{weight:?}");
304 }
305 for weight in [
306 f64::NAN,
307 f64::INFINITY,
308 f64::NEG_INFINITY,
309 -f64::from_bits(1),
310 1.0 + f64::EPSILON,
311 ] {
312 assert!(!crate::validate_edge_weight(weight), "{weight:?}");
313 }
314 }
315
316 #[test]
317 fn entity_with_properties() {
318 let mut props = BTreeMap::new();
319 props.insert("role".into(), PropertyValue::String("engineer".into()));
320 props.insert("age".into(), PropertyValue::Integer(30));
321
322 let entity = Entity {
323 header: Header::new(
324 Id128::from_u128(1),
325 Namespace::local(),
326 Timestamp::from_secs(1700000000),
327 ),
328 kind: EntityKind::Person,
329 entity_type: Some("researcher".into()),
330 name: "Operator".into(),
331 description: None,
332 properties: props,
333 tags: alloc::vec![],
334 deleted_at: None,
335 };
336 assert_eq!(entity.kind, EntityKind::Person);
337 assert_eq!(entity.kind.name(), "person");
338 assert_eq!(entity.entity_type.as_deref(), Some("researcher"));
339 assert_eq!(entity.properties.len(), 2);
340 }
341
342 #[test]
343 fn entity_kind_default_is_concept() {
344 assert_eq!(EntityKind::default(), EntityKind::Concept);
345 }
346
347 #[test]
348 fn entity_kind_display_roundtrip() {
349 for kind in EntityKind::ALL {
350 let s = alloc::format!("{kind}");
351 let parsed = EntityKind::from_str(&s).unwrap();
352 assert_eq!(parsed, kind);
353 }
354 }
355
356 #[test]
357 fn entity_kind_from_str_aliases() {
358 assert_eq!(EntityKind::from_str("doc").unwrap(), EntityKind::Document);
359 assert_eq!(EntityKind::from_str("paper").unwrap(), EntityKind::Document);
360 assert_eq!(
361 EntityKind::from_str("benchmark").unwrap(),
362 EntityKind::Dataset
363 );
364 assert_eq!(EntityKind::from_str("repo").unwrap(), EntityKind::Project);
365 assert_eq!(EntityKind::from_str("author").unwrap(), EntityKind::Person);
366 assert_eq!(EntityKind::from_str("lab").unwrap(), EntityKind::Org);
367 assert_eq!(EntityKind::from_str("art").unwrap(), EntityKind::Artifact);
368 assert_eq!(EntityKind::from_str("svc").unwrap(), EntityKind::Service);
369 }
370
371 #[test]
372 fn entity_kind_artifact_and_service_roundtrip() {
373 assert_eq!(EntityKind::Artifact.name(), "artifact");
374 assert_eq!(EntityKind::Service.name(), "service");
375 assert_eq!(
376 EntityKind::from_str("artifact").unwrap(),
377 EntityKind::Artifact
378 );
379 assert_eq!(
380 EntityKind::from_str("service").unwrap(),
381 EntityKind::Service
382 );
383 }
384
385 #[test]
386 fn entity_kind_all_has_eight_variants() {
387 assert_eq!(EntityKind::ALL.len(), 8);
388 assert!(EntityKind::ALL.contains(&EntityKind::Artifact));
389 assert!(EntityKind::ALL.contains(&EntityKind::Service));
390 }
391
392 #[test]
393 fn entity_kind_unknown_valid_list_includes_new_kinds() {
394 let err = EntityKind::from_str("gadget").unwrap_err();
395 assert!(err.valid.contains(&"artifact"));
396 assert!(err.valid.contains(&"service"));
397 }
398
399 #[test]
400 fn entity_kind_from_str_case_insensitive() {
401 assert_eq!(
402 EntityKind::from_str("CONCEPT").unwrap(),
403 EntityKind::Concept
404 );
405 assert_eq!(EntityKind::from_str("Person").unwrap(), EntityKind::Person);
406 }
407
408 #[test]
409 fn entity_kind_from_str_unknown_errors() {
410 let err = EntityKind::from_str("gadget").unwrap_err();
411 assert_eq!(err.domain, "entity_kind");
412 assert_eq!(err.value, "gadget");
413 assert!(err.valid.contains(&"concept"));
414 }
415
416 #[test]
417 fn link_construction() {
418 let ts = Timestamp::from_secs(1700000000);
419 let link = Link {
420 id: Id128::from_u128(100),
421 namespace: "default".into(),
422 source: Id128::from_u128(1),
423 target: Id128::from_u128(2),
424 relation: EdgeRelation::Extends,
425 properties: BTreeMap::new(),
426 weight: 1.0,
427 created_at: ts,
428 updated_at: ts,
429 deleted_at: None,
430 };
431 assert_eq!(link.relation, EdgeRelation::Extends);
432 assert!(link.is_valid());
433 }
434
435 #[test]
436 fn link_is_valid_rejects_out_of_range() {
437 let ts = Timestamp::from_secs(1700000000);
438 let link = Link {
439 id: Id128::from_u128(100),
440 namespace: "default".into(),
441 source: Id128::from_u128(1),
442 target: Id128::from_u128(2),
443 relation: EdgeRelation::Extends,
444 properties: BTreeMap::new(),
445 weight: 2.0,
446 created_at: ts,
447 updated_at: ts,
448 deleted_at: None,
449 };
450 assert!(!link.is_valid());
451 }
452
453 #[cfg(feature = "serde")]
454 #[test]
455 fn link_serde_rejects_weight_above_one() {
456 let json = serde_json::json!({
457 "id": "00000000-0000-0000-0000-000000000064",
458 "namespace": "default",
459 "source": "00000000-0000-0000-0000-000000000001",
460 "target": "00000000-0000-0000-0000-000000000002",
461 "relation": "extends",
462 "properties": {},
463 "weight": 2.0,
464 "created_at": 1700000000000000_u64,
465 "updated_at": 1700000000000000_u64,
466 "deleted_at": null
467 });
468 let result: Result<Link, _> = serde_json::from_value(json);
469 assert!(result.is_err());
470 let err = result.unwrap_err().to_string();
471 assert!(
472 err.contains("[0.0, 1.0]"),
473 "error should mention range: {err}"
474 );
475 }
476
477 #[cfg(feature = "serde")]
478 #[test]
479 fn link_serde_rejects_negative_weight() {
480 let json = serde_json::json!({
481 "id": "00000000-0000-0000-0000-000000000064",
482 "namespace": "default",
483 "source": "00000000-0000-0000-0000-000000000001",
484 "target": "00000000-0000-0000-0000-000000000002",
485 "relation": "extends",
486 "properties": {},
487 "weight": -0.1,
488 "created_at": 1700000000000000_u64,
489 "updated_at": 1700000000000000_u64,
490 "deleted_at": null
491 });
492 let result: Result<Link, _> = serde_json::from_value(json);
493 assert!(result.is_err());
494 }
495
496 #[cfg(feature = "serde")]
497 #[test]
498 fn link_serde_accepts_valid_weight() {
499 let json = serde_json::json!({
500 "id": "00000000-0000-0000-0000-000000000064",
501 "namespace": "default",
502 "source": "00000000-0000-0000-0000-000000000001",
503 "target": "00000000-0000-0000-0000-000000000002",
504 "relation": "extends",
505 "properties": {},
506 "weight": 0.75,
507 "created_at": 1700000000000000_u64,
508 "updated_at": 1700000000000000_u64,
509 "deleted_at": null
510 });
511 let link: Link = serde_json::from_value(json).expect("valid weight should deserialize");
512 assert_eq!(link.weight, 0.75);
513 }
514}