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, Copy, Debug, Default, PartialEq, Eq)]
121pub enum ImportKindPolicy {
122 #[default]
124 Strict,
125 PreserveUnknown,
127}
128
129#[derive(Clone, Debug)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132pub struct Entity {
133 #[cfg_attr(feature = "serde", serde(flatten))]
135 pub header: Header,
136 pub kind: EntityKind,
138 pub entity_type: Option<String>,
141 pub name: String,
143 pub description: Option<String>,
145 pub properties: BTreeMap<String, PropertyValue>,
147 pub tags: Vec<String>,
149 pub deleted_at: Option<Timestamp>,
151}
152
153#[derive(Clone, Debug)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize))]
159#[cfg_attr(feature = "serde", serde(into = "LinkRaw"))]
160pub struct Link {
161 pub id: Id128,
163 pub namespace: String,
165 pub source: Id128,
167 pub target: Id128,
169 pub relation: EdgeRelation,
171 pub properties: BTreeMap<String, PropertyValue>,
173 pub weight: f64,
175 pub created_at: Timestamp,
177 pub updated_at: Timestamp,
179 pub deleted_at: Option<Timestamp>,
181}
182
183pub fn validate_edge_weight(weight: f64) -> bool {
187 weight.is_finite() && (0.0..=1.0).contains(&weight)
188}
189
190impl Link {
191 pub fn is_valid(&self) -> bool {
195 validate_edge_weight(self.weight)
196 }
197}
198
199#[cfg(feature = "serde")]
200#[derive(serde::Serialize, serde::Deserialize)]
201struct LinkRaw {
202 id: Id128,
203 namespace: String,
204 source: Id128,
205 target: Id128,
206 relation: EdgeRelation,
207 properties: BTreeMap<String, PropertyValue>,
208 weight: f64,
209 created_at: Timestamp,
210 updated_at: Timestamp,
211 deleted_at: Option<Timestamp>,
212}
213
214#[cfg(feature = "serde")]
215impl From<Link> for LinkRaw {
216 fn from(l: Link) -> Self {
217 Self {
218 id: l.id,
219 namespace: l.namespace,
220 source: l.source,
221 target: l.target,
222 relation: l.relation,
223 properties: l.properties,
224 weight: l.weight,
225 created_at: l.created_at,
226 updated_at: l.updated_at,
227 deleted_at: l.deleted_at,
228 }
229 }
230}
231
232#[cfg(feature = "serde")]
233impl TryFrom<LinkRaw> for Link {
234 type Error = String;
235
236 fn try_from(raw: LinkRaw) -> Result<Self, Self::Error> {
237 if !raw.weight.is_finite() {
238 return Err(alloc::format!(
239 "Link weight must be finite, got {}",
240 raw.weight
241 ));
242 }
243 if !validate_edge_weight(raw.weight) {
244 return Err(alloc::format!(
245 "Link weight must be in [0.0, 1.0], got {}",
246 raw.weight
247 ));
248 }
249 Ok(Link {
250 id: raw.id,
251 namespace: raw.namespace,
252 source: raw.source,
253 target: raw.target,
254 relation: raw.relation,
255 properties: raw.properties,
256 weight: raw.weight,
257 created_at: raw.created_at,
258 updated_at: raw.updated_at,
259 deleted_at: raw.deleted_at,
260 })
261 }
262}
263
264#[cfg(feature = "serde")]
265impl<'de> serde::Deserialize<'de> for Link {
266 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
267 where
268 D: serde::Deserializer<'de>,
269 {
270 let raw = LinkRaw::deserialize(deserializer)?;
271 Link::try_from(raw).map_err(serde::de::Error::custom)
272 }
273}
274
275#[derive(Clone, Debug, PartialEq)]
280#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
281#[cfg_attr(feature = "serde", serde(untagged))]
282pub enum PropertyValue {
283 String(String),
284 Integer(i64),
285 Float(f64),
286 Boolean(bool),
287 Array(Vec<PropertyValue>),
288 Object(BTreeMap<String, PropertyValue>),
289 Null,
290}
291
292impl fmt::Display for PropertyValue {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 match self {
295 Self::String(s) => f.write_str(s),
296 Self::Integer(n) => write!(f, "{n}"),
297 Self::Float(n) => write!(f, "{n}"),
298 Self::Boolean(b) => write!(f, "{b}"),
299 Self::Array(arr) => write!(f, "[{} items]", arr.len()),
300 Self::Object(obj) => write!(f, "{{{} keys}}", obj.len()),
301 Self::Null => f.write_str("null"),
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::{Namespace, Timestamp};
310 #[cfg(feature = "serde")]
311 use alloc::string::ToString;
312
313 #[test]
314 fn edge_weight_validation_covers_numeric_boundaries() {
315 for weight in [-0.0, 0.0, f64::from_bits(1), 0.5, 1.0] {
316 assert!(crate::validate_edge_weight(weight), "{weight:?}");
317 }
318 for weight in [
319 f64::NAN,
320 f64::INFINITY,
321 f64::NEG_INFINITY,
322 -f64::from_bits(1),
323 1.0 + f64::EPSILON,
324 ] {
325 assert!(!crate::validate_edge_weight(weight), "{weight:?}");
326 }
327 }
328
329 #[test]
330 fn entity_with_properties() {
331 let mut props = BTreeMap::new();
332 props.insert("role".into(), PropertyValue::String("engineer".into()));
333 props.insert("age".into(), PropertyValue::Integer(30));
334
335 let entity = Entity {
336 header: Header::new(
337 Id128::from_u128(1),
338 Namespace::local(),
339 Timestamp::from_secs(1700000000),
340 ),
341 kind: EntityKind::Person,
342 entity_type: Some("researcher".into()),
343 name: "Operator".into(),
344 description: None,
345 properties: props,
346 tags: alloc::vec![],
347 deleted_at: None,
348 };
349 assert_eq!(entity.kind, EntityKind::Person);
350 assert_eq!(entity.kind.name(), "person");
351 assert_eq!(entity.entity_type.as_deref(), Some("researcher"));
352 assert_eq!(entity.properties.len(), 2);
353 }
354
355 #[test]
356 fn entity_kind_default_is_concept() {
357 assert_eq!(EntityKind::default(), EntityKind::Concept);
358 }
359
360 #[test]
361 fn entity_kind_display_roundtrip() {
362 for kind in EntityKind::ALL {
363 let s = alloc::format!("{kind}");
364 let parsed = EntityKind::from_str(&s).unwrap();
365 assert_eq!(parsed, kind);
366 }
367 }
368
369 #[test]
370 fn entity_kind_from_str_aliases() {
371 assert_eq!(EntityKind::from_str("doc").unwrap(), EntityKind::Document);
372 assert_eq!(EntityKind::from_str("paper").unwrap(), EntityKind::Document);
373 assert_eq!(
374 EntityKind::from_str("benchmark").unwrap(),
375 EntityKind::Dataset
376 );
377 assert_eq!(EntityKind::from_str("repo").unwrap(), EntityKind::Project);
378 assert_eq!(EntityKind::from_str("author").unwrap(), EntityKind::Person);
379 assert_eq!(EntityKind::from_str("lab").unwrap(), EntityKind::Org);
380 assert_eq!(EntityKind::from_str("art").unwrap(), EntityKind::Artifact);
381 assert_eq!(EntityKind::from_str("svc").unwrap(), EntityKind::Service);
382 }
383
384 #[test]
385 fn entity_kind_artifact_and_service_roundtrip() {
386 assert_eq!(EntityKind::Artifact.name(), "artifact");
387 assert_eq!(EntityKind::Service.name(), "service");
388 assert_eq!(
389 EntityKind::from_str("artifact").unwrap(),
390 EntityKind::Artifact
391 );
392 assert_eq!(
393 EntityKind::from_str("service").unwrap(),
394 EntityKind::Service
395 );
396 }
397
398 #[test]
399 fn entity_kind_all_has_eight_variants() {
400 assert_eq!(EntityKind::ALL.len(), 8);
401 assert!(EntityKind::ALL.contains(&EntityKind::Artifact));
402 assert!(EntityKind::ALL.contains(&EntityKind::Service));
403 }
404
405 #[test]
406 fn entity_kind_unknown_valid_list_includes_new_kinds() {
407 let err = EntityKind::from_str("gadget").unwrap_err();
408 assert!(err.valid.contains(&"artifact"));
409 assert!(err.valid.contains(&"service"));
410 }
411
412 #[test]
413 fn entity_kind_from_str_case_insensitive() {
414 assert_eq!(
415 EntityKind::from_str("CONCEPT").unwrap(),
416 EntityKind::Concept
417 );
418 assert_eq!(EntityKind::from_str("Person").unwrap(), EntityKind::Person);
419 }
420
421 #[test]
422 fn entity_kind_from_str_unknown_errors() {
423 let err = EntityKind::from_str("gadget").unwrap_err();
424 assert_eq!(err.domain, "entity_kind");
425 assert_eq!(err.value, "gadget");
426 assert!(err.valid.contains(&"concept"));
427 }
428
429 #[test]
430 fn link_construction() {
431 let ts = Timestamp::from_secs(1700000000);
432 let link = Link {
433 id: Id128::from_u128(100),
434 namespace: "default".into(),
435 source: Id128::from_u128(1),
436 target: Id128::from_u128(2),
437 relation: EdgeRelation::Extends,
438 properties: BTreeMap::new(),
439 weight: 1.0,
440 created_at: ts,
441 updated_at: ts,
442 deleted_at: None,
443 };
444 assert_eq!(link.relation, EdgeRelation::Extends);
445 assert!(link.is_valid());
446 }
447
448 #[test]
449 fn link_is_valid_rejects_out_of_range() {
450 let ts = Timestamp::from_secs(1700000000);
451 let link = Link {
452 id: Id128::from_u128(100),
453 namespace: "default".into(),
454 source: Id128::from_u128(1),
455 target: Id128::from_u128(2),
456 relation: EdgeRelation::Extends,
457 properties: BTreeMap::new(),
458 weight: 2.0,
459 created_at: ts,
460 updated_at: ts,
461 deleted_at: None,
462 };
463 assert!(!link.is_valid());
464 }
465
466 #[cfg(feature = "serde")]
467 #[test]
468 fn link_serde_rejects_weight_above_one() {
469 let json = serde_json::json!({
470 "id": "00000000-0000-0000-0000-000000000064",
471 "namespace": "default",
472 "source": "00000000-0000-0000-0000-000000000001",
473 "target": "00000000-0000-0000-0000-000000000002",
474 "relation": "extends",
475 "properties": {},
476 "weight": 2.0,
477 "created_at": 1700000000000000_u64,
478 "updated_at": 1700000000000000_u64,
479 "deleted_at": null
480 });
481 let result: Result<Link, _> = serde_json::from_value(json);
482 assert!(result.is_err());
483 let err = result.unwrap_err().to_string();
484 assert!(
485 err.contains("[0.0, 1.0]"),
486 "error should mention range: {err}"
487 );
488 }
489
490 #[cfg(feature = "serde")]
491 #[test]
492 fn link_serde_rejects_negative_weight() {
493 let json = serde_json::json!({
494 "id": "00000000-0000-0000-0000-000000000064",
495 "namespace": "default",
496 "source": "00000000-0000-0000-0000-000000000001",
497 "target": "00000000-0000-0000-0000-000000000002",
498 "relation": "extends",
499 "properties": {},
500 "weight": -0.1,
501 "created_at": 1700000000000000_u64,
502 "updated_at": 1700000000000000_u64,
503 "deleted_at": null
504 });
505 let result: Result<Link, _> = serde_json::from_value(json);
506 assert!(result.is_err());
507 }
508
509 #[cfg(feature = "serde")]
510 #[test]
511 fn link_serde_accepts_valid_weight() {
512 let json = serde_json::json!({
513 "id": "00000000-0000-0000-0000-000000000064",
514 "namespace": "default",
515 "source": "00000000-0000-0000-0000-000000000001",
516 "target": "00000000-0000-0000-0000-000000000002",
517 "relation": "extends",
518 "properties": {},
519 "weight": 0.75,
520 "created_at": 1700000000000000_u64,
521 "updated_at": 1700000000000000_u64,
522 "deleted_at": null
523 });
524 let link: Link = serde_json::from_value(json).expect("valid weight should deserialize");
525 assert_eq!(link.weight, 0.75);
526 }
527}