1extern crate alloc;
4use alloc::string::String;
5use core::fmt;
6use core::str::FromStr;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
17pub enum EdgeCategory {
18 Structure,
21 Derivation,
23 Provenance,
25 Temporal,
27 Dependency,
29 Implementation,
31 Lateral,
33 Annotation,
35 Epistemic,
37 Ownership,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
47#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
48pub enum EdgeRelation {
49 Contains,
51 PartOf,
52 InstanceOf,
53 LinksTo,
54 LocatedIn,
55 Extends,
57 VariantOf,
58 IntroducedBy,
59 Supersedes,
60 DerivedFrom,
62 Precedes,
64 DependsOn,
66 Enables,
67 Implements,
69 CompetesWith,
71 ComposedWith,
72 Annotates,
74 Supports,
76 Refutes,
77 Owns,
79}
80
81impl EdgeRelation {
82 pub const ALL: [Self; 20] = [
84 Self::Contains,
85 Self::PartOf,
86 Self::InstanceOf,
87 Self::LinksTo,
88 Self::LocatedIn,
89 Self::Extends,
90 Self::VariantOf,
91 Self::IntroducedBy,
92 Self::Supersedes,
93 Self::DerivedFrom,
94 Self::Precedes,
95 Self::DependsOn,
96 Self::Enables,
97 Self::Implements,
98 Self::CompetesWith,
99 Self::ComposedWith,
100 Self::Annotates,
101 Self::Supports,
102 Self::Refutes,
103 Self::Owns,
104 ];
105
106 pub const VALID_NAMES: &'static [&'static str] = &[
108 "contains",
109 "part_of",
110 "instance_of",
111 "links_to",
112 "located_in",
113 "extends",
114 "variant_of",
115 "introduced_by",
116 "supersedes",
117 "derived_from",
118 "precedes",
119 "depends_on",
120 "enables",
121 "implements",
122 "competes_with",
123 "composed_with",
124 "annotates",
125 "supports",
126 "refutes",
127 "owns",
128 ];
129
130 pub const fn is_symmetric(&self) -> bool {
132 matches!(self, Self::CompetesWith | Self::ComposedWith)
133 }
134
135 pub fn canonical_endpoints<T: Ord>(&self, source: T, target: T) -> (T, T) {
145 if self.is_symmetric() && target < source {
146 (target, source)
147 } else {
148 (source, target)
149 }
150 }
151
152 pub const fn category(&self) -> EdgeCategory {
154 match self {
155 Self::Contains | Self::PartOf | Self::InstanceOf | Self::LinksTo | Self::LocatedIn => {
156 EdgeCategory::Structure
157 }
158 Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
159 EdgeCategory::Derivation
160 }
161 Self::DerivedFrom => EdgeCategory::Provenance,
162 Self::Precedes => EdgeCategory::Temporal,
163 Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
164 Self::Implements => EdgeCategory::Implementation,
165 Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
166 Self::Annotates => EdgeCategory::Annotation,
167 Self::Supports | Self::Refutes => EdgeCategory::Epistemic,
168 Self::Owns => EdgeCategory::Ownership,
169 }
170 }
171
172 pub const fn as_str(&self) -> &'static str {
174 match self {
175 Self::Contains => "contains",
176 Self::PartOf => "part_of",
177 Self::InstanceOf => "instance_of",
178 Self::LinksTo => "links_to",
179 Self::LocatedIn => "located_in",
180 Self::Extends => "extends",
181 Self::VariantOf => "variant_of",
182 Self::IntroducedBy => "introduced_by",
183 Self::Supersedes => "supersedes",
184 Self::DerivedFrom => "derived_from",
185 Self::Precedes => "precedes",
186 Self::DependsOn => "depends_on",
187 Self::Enables => "enables",
188 Self::Implements => "implements",
189 Self::CompetesWith => "competes_with",
190 Self::ComposedWith => "composed_with",
191 Self::Annotates => "annotates",
192 Self::Supports => "supports",
193 Self::Refutes => "refutes",
194 Self::Owns => "owns",
195 }
196 }
197}
198
199impl fmt::Display for EdgeRelation {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.write_str(self.as_str())
202 }
203}
204
205impl FromStr for EdgeRelation {
206 type Err = crate::error::UnknownVariant;
207
208 fn from_str(s: &str) -> Result<Self, Self::Err> {
216 let mut normalised = String::with_capacity(s.len());
217 for c in s.chars() {
218 match c {
219 '-' | '_' => normalised.push('_'),
220 c if c.is_ascii_alphanumeric() => normalised.push(c.to_ascii_lowercase()),
221 _ => {
222 return Err(crate::error::UnknownVariant::new(
223 "edge_relation",
224 s,
225 Self::VALID_NAMES,
226 ));
227 }
228 }
229 }
230
231 match normalised.as_str() {
232 "contains" => Ok(Self::Contains),
233 "part_of" | "partof" => Ok(Self::PartOf),
234 "instance_of" | "instanceof" => Ok(Self::InstanceOf),
235 "links_to" | "linksto" => Ok(Self::LinksTo),
236 "located_in" | "locatedin" => Ok(Self::LocatedIn),
237 "extends" => Ok(Self::Extends),
238 "variant_of" | "variantof" => Ok(Self::VariantOf),
239 "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
240 "supersedes" => Ok(Self::Supersedes),
241 "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
242 "precedes" => Ok(Self::Precedes),
243 "depends_on" | "dependson" => Ok(Self::DependsOn),
244 "enables" => Ok(Self::Enables),
245 "implements" => Ok(Self::Implements),
246 "competes_with" | "competeswith" => Ok(Self::CompetesWith),
247 "composed_with" | "composedwith" => Ok(Self::ComposedWith),
248 "annotates" => Ok(Self::Annotates),
249 "supports" => Ok(Self::Supports),
250 "refutes" => Ok(Self::Refutes),
251 "owns" => Ok(Self::Owns),
252 _ => Err(crate::error::UnknownVariant::new(
253 "edge_relation",
254 s,
255 Self::VALID_NAMES,
256 )),
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use alloc::string::ToString;
265
266 #[test]
267 fn all_has_twenty_variants() {
268 assert_eq!(EdgeRelation::ALL.len(), 20);
269 }
270
271 #[test]
272 fn all_ten_categories_covered() {
273 let mut cats = alloc::vec::Vec::new();
274 for r in EdgeRelation::ALL {
275 let c = r.category();
276 if !cats.contains(&c) {
277 cats.push(c);
278 }
279 }
280 assert_eq!(cats.len(), 10, "all 10 categories must be represented");
281 }
282
283 #[test]
284 fn owns_is_directional_ownership() {
285 assert_eq!("owns".parse::<EdgeRelation>().unwrap(), EdgeRelation::Owns);
286 assert_eq!("OWNS".parse::<EdgeRelation>().unwrap(), EdgeRelation::Owns);
287 assert_eq!(EdgeRelation::Owns.to_string(), "owns");
288 assert_eq!(EdgeRelation::Owns.category(), EdgeCategory::Ownership);
289 assert!(!EdgeRelation::Owns.is_symmetric());
290 assert_eq!(EdgeRelation::Owns.canonical_endpoints(9, 2), (9, 2));
291 }
292
293 #[test]
294 fn display_roundtrip_for_all() {
295 for relation in EdgeRelation::ALL {
296 let s = relation.to_string();
297 let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
298 assert_eq!(parsed, relation);
299 }
300 }
301
302 #[test]
303 fn from_str_case_insensitive() {
304 assert_eq!(
305 "Extends".parse::<EdgeRelation>().unwrap(),
306 EdgeRelation::Extends
307 );
308 assert_eq!(
309 "extends".parse::<EdgeRelation>().unwrap(),
310 EdgeRelation::Extends
311 );
312 assert_eq!(
313 "EXTENDS".parse::<EdgeRelation>().unwrap(),
314 EdgeRelation::Extends
315 );
316 }
317
318 #[test]
319 fn from_str_hyphen_tolerant() {
320 assert_eq!(
321 "part_of".parse::<EdgeRelation>().unwrap(),
322 EdgeRelation::PartOf
323 );
324 assert_eq!(
325 "part-of".parse::<EdgeRelation>().unwrap(),
326 EdgeRelation::PartOf
327 );
328 assert_eq!(
329 "partof".parse::<EdgeRelation>().unwrap(),
330 EdgeRelation::PartOf
331 );
332
333 assert_eq!(
334 "introduced_by".parse::<EdgeRelation>().unwrap(),
335 EdgeRelation::IntroducedBy
336 );
337 assert_eq!(
338 "introduced-by".parse::<EdgeRelation>().unwrap(),
339 EdgeRelation::IntroducedBy
340 );
341 }
342
343 #[test]
344 fn from_str_unknown_returns_error_with_list() {
345 let err = "related_to".parse::<EdgeRelation>().unwrap_err();
346 let msg = err.to_string();
347 assert!(
348 msg.contains("related_to"),
349 "error should mention the bad input"
350 );
351 assert!(
352 msg.contains("contains"),
353 "error should list valid relations"
354 );
355 assert!(
356 msg.contains("derived_from"),
357 "error should list derived_from"
358 );
359 assert!(msg.contains("precedes"), "error should list precedes");
360 assert!(msg.contains("annotates"), "error should list annotates");
361 }
362
363 #[test]
364 fn edge_relation_bang_rejected() {
365 for bad in ["supports!", "part/of", "depends.on", "competes with"] {
366 let err = bad
367 .parse::<EdgeRelation>()
368 .expect_err("malformed punctuation/whitespace must be rejected");
369 assert_eq!(err.domain, "edge_relation");
370 assert_eq!(err.value, bad);
371 }
372 }
373
374 #[test]
375 fn category_returns_correct_group() {
376 assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
377 assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
378 assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
379 assert_eq!(EdgeRelation::LinksTo.category(), EdgeCategory::Structure);
380 assert_eq!(EdgeRelation::LocatedIn.category(), EdgeCategory::Structure);
381
382 assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
383 assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
384 assert_eq!(
385 EdgeRelation::IntroducedBy.category(),
386 EdgeCategory::Derivation
387 );
388 assert_eq!(
389 EdgeRelation::Supersedes.category(),
390 EdgeCategory::Derivation
391 );
392
393 assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
394 assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
395
396 assert_eq!(
397 EdgeRelation::Implements.category(),
398 EdgeCategory::Implementation
399 );
400
401 assert_eq!(
402 EdgeRelation::DerivedFrom.category(),
403 EdgeCategory::Provenance
404 );
405 assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
406
407 assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
408 assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
409
410 assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
411 }
412
413 #[test]
414 fn from_str_new_relations() {
415 assert_eq!(
416 "derived_from".parse::<EdgeRelation>().unwrap(),
417 EdgeRelation::DerivedFrom
418 );
419 assert_eq!(
420 "derived-from".parse::<EdgeRelation>().unwrap(),
421 EdgeRelation::DerivedFrom
422 );
423 assert_eq!(
424 "derivedfrom".parse::<EdgeRelation>().unwrap(),
425 EdgeRelation::DerivedFrom
426 );
427 assert_eq!(
428 "precedes".parse::<EdgeRelation>().unwrap(),
429 EdgeRelation::Precedes
430 );
431 }
432
433 #[test]
434 fn from_str_links_to() {
435 assert_eq!(
436 "links_to".parse::<EdgeRelation>().unwrap(),
437 EdgeRelation::LinksTo
438 );
439 assert_eq!(
440 "links-to".parse::<EdgeRelation>().unwrap(),
441 EdgeRelation::LinksTo
442 );
443 assert_eq!(
444 "linksto".parse::<EdgeRelation>().unwrap(),
445 EdgeRelation::LinksTo
446 );
447 assert_eq!(EdgeRelation::LinksTo.to_string(), "links_to");
448 assert_eq!(EdgeRelation::LinksTo.category(), EdgeCategory::Structure);
449 }
450
451 #[test]
452 fn from_str_located_in() {
453 assert_eq!(
454 "located_in".parse::<EdgeRelation>().unwrap(),
455 EdgeRelation::LocatedIn
456 );
457 assert_eq!(
458 "located-in".parse::<EdgeRelation>().unwrap(),
459 EdgeRelation::LocatedIn
460 );
461 assert_eq!(
462 "locatedin".parse::<EdgeRelation>().unwrap(),
463 EdgeRelation::LocatedIn
464 );
465 assert_eq!(EdgeRelation::LocatedIn.to_string(), "located_in");
466 assert_eq!(EdgeRelation::LocatedIn.category(), EdgeCategory::Structure);
467 }
468
469 #[test]
470 fn is_symmetric_only_for_lateral_peer_relations() {
471 assert!(EdgeRelation::CompetesWith.is_symmetric());
472 assert!(EdgeRelation::ComposedWith.is_symmetric());
473 assert!(!EdgeRelation::DependsOn.is_symmetric());
474 assert!(!EdgeRelation::DerivedFrom.is_symmetric());
475 assert!(!EdgeRelation::Precedes.is_symmetric());
476 assert!(!EdgeRelation::Extends.is_symmetric());
477 assert!(!EdgeRelation::LinksTo.is_symmetric());
478 assert!(!EdgeRelation::LocatedIn.is_symmetric());
479 }
480
481 const SYMMETRIC: [EdgeRelation; 2] = [EdgeRelation::CompetesWith, EdgeRelation::ComposedWith];
484
485 const PAIRS: [(u8, u8); 3] = [(1, 2), (2, 1), (7, 7)];
487
488 #[test]
489 fn canonical_endpoints_sorts_symmetric_relations() {
490 for relation in SYMMETRIC {
491 assert!(relation.is_symmetric(), "{relation}");
492 for (source, target) in PAIRS {
493 assert_eq!(
494 relation.canonical_endpoints(source, target),
495 (source.min(target), source.max(target)),
496 "{relation}: {source} to {target}"
497 );
498 }
499 }
500 }
501
502 #[test]
503 fn canonical_endpoints_leaves_other_relations_as_given() {
504 let others: alloc::vec::Vec<EdgeRelation> = EdgeRelation::ALL
505 .into_iter()
506 .filter(|relation| !SYMMETRIC.contains(relation))
507 .collect();
508 assert_eq!(others.len(), EdgeRelation::ALL.len() - SYMMETRIC.len());
509 for relation in others {
510 assert!(!relation.is_symmetric(), "{relation}");
511 for (source, target) in PAIRS {
512 assert_eq!(
513 relation.canonical_endpoints(source, target),
514 (source, target),
515 "{relation}: {source} to {target}"
516 );
517 }
518 }
519 }
520
521 #[test]
522 fn from_str_epistemic_relations() {
523 assert_eq!(
524 "supports".parse::<EdgeRelation>().unwrap(),
525 EdgeRelation::Supports
526 );
527 assert_eq!(
528 "refutes".parse::<EdgeRelation>().unwrap(),
529 EdgeRelation::Refutes
530 );
531 assert_eq!(
532 "Supports".parse::<EdgeRelation>().unwrap(),
533 EdgeRelation::Supports
534 );
535 assert_eq!(
536 "REFUTES".parse::<EdgeRelation>().unwrap(),
537 EdgeRelation::Refutes
538 );
539 assert_eq!(EdgeRelation::Supports.category(), EdgeCategory::Epistemic);
540 assert_eq!(EdgeRelation::Refutes.category(), EdgeCategory::Epistemic);
541 assert!(!EdgeRelation::Supports.is_symmetric());
542 assert!(!EdgeRelation::Refutes.is_symmetric());
543 }
544
545 #[cfg(feature = "serde")]
546 #[test]
547 fn serde_snake_case_roundtrip() {
548 let rel = EdgeRelation::IntroducedBy;
549 let json = serde_json::to_string(&rel).unwrap();
550 assert_eq!(json, "\"introduced_by\"");
551 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
552 assert_eq!(parsed, rel);
553 }
554
555 #[cfg(feature = "serde")]
556 #[test]
557 fn serde_new_relations_roundtrip() {
558 for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
559 let json = serde_json::to_string(&rel).unwrap();
560 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
561 assert_eq!(parsed, rel);
562 }
563 }
564
565 #[cfg(feature = "serde")]
566 #[test]
567 fn serde_epistemic_relations_roundtrip() {
568 let sup_json = serde_json::to_string(&EdgeRelation::Supports).unwrap();
569 assert_eq!(sup_json, "\"supports\"");
570 let sup_parsed: EdgeRelation = serde_json::from_str(&sup_json).unwrap();
571 assert_eq!(sup_parsed, EdgeRelation::Supports);
572
573 let ref_json = serde_json::to_string(&EdgeRelation::Refutes).unwrap();
574 assert_eq!(ref_json, "\"refutes\"");
575 let ref_parsed: EdgeRelation = serde_json::from_str(&ref_json).unwrap();
576 assert_eq!(ref_parsed, EdgeRelation::Refutes);
577 }
578}