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,
20 Derivation,
22 Provenance,
24 Temporal,
26 Dependency,
28 Implementation,
30 Lateral,
32 Annotation,
34 Epistemic,
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
45pub enum EdgeRelation {
46 Contains,
48 PartOf,
49 InstanceOf,
50 Extends,
52 VariantOf,
53 IntroducedBy,
54 Supersedes,
55 DerivedFrom,
57 Precedes,
59 DependsOn,
61 Enables,
62 Implements,
64 CompetesWith,
66 ComposedWith,
67 Annotates,
69 Supports,
71 Refutes,
72}
73
74impl EdgeRelation {
75 pub const ALL: [Self; 17] = [
77 Self::Contains,
78 Self::PartOf,
79 Self::InstanceOf,
80 Self::Extends,
81 Self::VariantOf,
82 Self::IntroducedBy,
83 Self::Supersedes,
84 Self::DerivedFrom,
85 Self::Precedes,
86 Self::DependsOn,
87 Self::Enables,
88 Self::Implements,
89 Self::CompetesWith,
90 Self::ComposedWith,
91 Self::Annotates,
92 Self::Supports,
93 Self::Refutes,
94 ];
95
96 pub const VALID_NAMES: &'static [&'static str] = &[
98 "contains",
99 "part_of",
100 "instance_of",
101 "extends",
102 "variant_of",
103 "introduced_by",
104 "supersedes",
105 "derived_from",
106 "precedes",
107 "depends_on",
108 "enables",
109 "implements",
110 "competes_with",
111 "composed_with",
112 "annotates",
113 "supports",
114 "refutes",
115 ];
116
117 pub const fn is_symmetric(&self) -> bool {
119 matches!(self, Self::CompetesWith | Self::ComposedWith)
120 }
121
122 pub const fn category(&self) -> EdgeCategory {
124 match self {
125 Self::Contains | Self::PartOf | Self::InstanceOf => EdgeCategory::Structure,
126 Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
127 EdgeCategory::Derivation
128 }
129 Self::DerivedFrom => EdgeCategory::Provenance,
130 Self::Precedes => EdgeCategory::Temporal,
131 Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
132 Self::Implements => EdgeCategory::Implementation,
133 Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
134 Self::Annotates => EdgeCategory::Annotation,
135 Self::Supports | Self::Refutes => EdgeCategory::Epistemic,
136 }
137 }
138
139 pub const fn as_str(&self) -> &'static str {
141 match self {
142 Self::Contains => "contains",
143 Self::PartOf => "part_of",
144 Self::InstanceOf => "instance_of",
145 Self::Extends => "extends",
146 Self::VariantOf => "variant_of",
147 Self::IntroducedBy => "introduced_by",
148 Self::Supersedes => "supersedes",
149 Self::DerivedFrom => "derived_from",
150 Self::Precedes => "precedes",
151 Self::DependsOn => "depends_on",
152 Self::Enables => "enables",
153 Self::Implements => "implements",
154 Self::CompetesWith => "competes_with",
155 Self::ComposedWith => "composed_with",
156 Self::Annotates => "annotates",
157 Self::Supports => "supports",
158 Self::Refutes => "refutes",
159 }
160 }
161}
162
163impl fmt::Display for EdgeRelation {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 f.write_str(self.as_str())
166 }
167}
168
169impl FromStr for EdgeRelation {
170 type Err = crate::error::UnknownVariant;
171
172 fn from_str(s: &str) -> Result<Self, Self::Err> {
180 let mut normalised = String::with_capacity(s.len());
181 for c in s.chars() {
182 match c {
183 '-' | '_' => normalised.push('_'),
184 c if c.is_ascii_alphanumeric() => normalised.push(c.to_ascii_lowercase()),
185 _ => {
186 return Err(crate::error::UnknownVariant::new(
187 "edge_relation",
188 s,
189 Self::VALID_NAMES,
190 ));
191 }
192 }
193 }
194
195 match normalised.as_str() {
196 "contains" => Ok(Self::Contains),
197 "part_of" | "partof" => Ok(Self::PartOf),
198 "instance_of" | "instanceof" => Ok(Self::InstanceOf),
199 "extends" => Ok(Self::Extends),
200 "variant_of" | "variantof" => Ok(Self::VariantOf),
201 "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
202 "supersedes" => Ok(Self::Supersedes),
203 "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
204 "precedes" => Ok(Self::Precedes),
205 "depends_on" | "dependson" => Ok(Self::DependsOn),
206 "enables" => Ok(Self::Enables),
207 "implements" => Ok(Self::Implements),
208 "competes_with" | "competeswith" => Ok(Self::CompetesWith),
209 "composed_with" | "composedwith" => Ok(Self::ComposedWith),
210 "annotates" => Ok(Self::Annotates),
211 "supports" => Ok(Self::Supports),
212 "refutes" => Ok(Self::Refutes),
213 _ => Err(crate::error::UnknownVariant::new(
214 "edge_relation",
215 s,
216 Self::VALID_NAMES,
217 )),
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use alloc::string::ToString;
226
227 #[test]
228 fn all_has_seventeen_variants() {
229 assert_eq!(EdgeRelation::ALL.len(), 17);
230 }
231
232 #[test]
233 fn all_nine_categories_covered() {
234 let mut cats = alloc::vec::Vec::new();
235 for r in EdgeRelation::ALL {
236 let c = r.category();
237 if !cats.contains(&c) {
238 cats.push(c);
239 }
240 }
241 assert_eq!(cats.len(), 9, "all 9 categories must be represented");
242 }
243
244 #[test]
245 fn display_roundtrip_for_all() {
246 for relation in EdgeRelation::ALL {
247 let s = relation.to_string();
248 let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
249 assert_eq!(parsed, relation);
250 }
251 }
252
253 #[test]
254 fn from_str_case_insensitive() {
255 assert_eq!(
256 "Extends".parse::<EdgeRelation>().unwrap(),
257 EdgeRelation::Extends
258 );
259 assert_eq!(
260 "extends".parse::<EdgeRelation>().unwrap(),
261 EdgeRelation::Extends
262 );
263 assert_eq!(
264 "EXTENDS".parse::<EdgeRelation>().unwrap(),
265 EdgeRelation::Extends
266 );
267 }
268
269 #[test]
270 fn from_str_hyphen_tolerant() {
271 assert_eq!(
272 "part_of".parse::<EdgeRelation>().unwrap(),
273 EdgeRelation::PartOf
274 );
275 assert_eq!(
276 "part-of".parse::<EdgeRelation>().unwrap(),
277 EdgeRelation::PartOf
278 );
279 assert_eq!(
280 "partof".parse::<EdgeRelation>().unwrap(),
281 EdgeRelation::PartOf
282 );
283
284 assert_eq!(
285 "introduced_by".parse::<EdgeRelation>().unwrap(),
286 EdgeRelation::IntroducedBy
287 );
288 assert_eq!(
289 "introduced-by".parse::<EdgeRelation>().unwrap(),
290 EdgeRelation::IntroducedBy
291 );
292 }
293
294 #[test]
295 fn from_str_unknown_returns_error_with_list() {
296 let err = "related_to".parse::<EdgeRelation>().unwrap_err();
297 let msg = err.to_string();
298 assert!(
299 msg.contains("related_to"),
300 "error should mention the bad input"
301 );
302 assert!(
303 msg.contains("contains"),
304 "error should list valid relations"
305 );
306 assert!(
307 msg.contains("derived_from"),
308 "error should list derived_from"
309 );
310 assert!(msg.contains("precedes"), "error should list precedes");
311 assert!(msg.contains("annotates"), "error should list all 17");
312 }
313
314 #[test]
315 fn edge_relation_bang_rejected() {
316 for bad in ["supports!", "part/of", "depends.on", "competes with"] {
317 let err = bad
318 .parse::<EdgeRelation>()
319 .expect_err("malformed punctuation/whitespace must be rejected");
320 assert_eq!(err.domain, "edge_relation");
321 assert_eq!(err.value, bad);
322 }
323 }
324
325 #[test]
326 fn category_returns_correct_group() {
327 assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
328 assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
329 assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
330
331 assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
332 assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
333 assert_eq!(
334 EdgeRelation::IntroducedBy.category(),
335 EdgeCategory::Derivation
336 );
337 assert_eq!(
338 EdgeRelation::Supersedes.category(),
339 EdgeCategory::Derivation
340 );
341
342 assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
343 assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
344
345 assert_eq!(
346 EdgeRelation::Implements.category(),
347 EdgeCategory::Implementation
348 );
349
350 assert_eq!(
351 EdgeRelation::DerivedFrom.category(),
352 EdgeCategory::Provenance
353 );
354 assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
355
356 assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
357 assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
358
359 assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
360 }
361
362 #[test]
363 fn from_str_new_relations() {
364 assert_eq!(
365 "derived_from".parse::<EdgeRelation>().unwrap(),
366 EdgeRelation::DerivedFrom
367 );
368 assert_eq!(
369 "derived-from".parse::<EdgeRelation>().unwrap(),
370 EdgeRelation::DerivedFrom
371 );
372 assert_eq!(
373 "derivedfrom".parse::<EdgeRelation>().unwrap(),
374 EdgeRelation::DerivedFrom
375 );
376 assert_eq!(
377 "precedes".parse::<EdgeRelation>().unwrap(),
378 EdgeRelation::Precedes
379 );
380 }
381
382 #[test]
383 fn is_symmetric_only_for_lateral_peer_relations() {
384 assert!(EdgeRelation::CompetesWith.is_symmetric());
385 assert!(EdgeRelation::ComposedWith.is_symmetric());
386 assert!(!EdgeRelation::DependsOn.is_symmetric());
387 assert!(!EdgeRelation::DerivedFrom.is_symmetric());
388 assert!(!EdgeRelation::Precedes.is_symmetric());
389 assert!(!EdgeRelation::Extends.is_symmetric());
390 }
391
392 #[test]
393 fn from_str_epistemic_relations() {
394 assert_eq!(
395 "supports".parse::<EdgeRelation>().unwrap(),
396 EdgeRelation::Supports
397 );
398 assert_eq!(
399 "refutes".parse::<EdgeRelation>().unwrap(),
400 EdgeRelation::Refutes
401 );
402 assert_eq!(
403 "Supports".parse::<EdgeRelation>().unwrap(),
404 EdgeRelation::Supports
405 );
406 assert_eq!(
407 "REFUTES".parse::<EdgeRelation>().unwrap(),
408 EdgeRelation::Refutes
409 );
410 assert_eq!(EdgeRelation::Supports.category(), EdgeCategory::Epistemic);
411 assert_eq!(EdgeRelation::Refutes.category(), EdgeCategory::Epistemic);
412 assert!(!EdgeRelation::Supports.is_symmetric());
413 assert!(!EdgeRelation::Refutes.is_symmetric());
414 }
415
416 #[cfg(feature = "serde")]
417 #[test]
418 fn serde_snake_case_roundtrip() {
419 let rel = EdgeRelation::IntroducedBy;
420 let json = serde_json::to_string(&rel).unwrap();
421 assert_eq!(json, "\"introduced_by\"");
422 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
423 assert_eq!(parsed, rel);
424 }
425
426 #[cfg(feature = "serde")]
427 #[test]
428 fn serde_new_relations_roundtrip() {
429 for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
430 let json = serde_json::to_string(&rel).unwrap();
431 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
432 assert_eq!(parsed, rel);
433 }
434 }
435
436 #[cfg(feature = "serde")]
437 #[test]
438 fn serde_epistemic_relations_roundtrip() {
439 let sup_json = serde_json::to_string(&EdgeRelation::Supports).unwrap();
440 assert_eq!(sup_json, "\"supports\"");
441 let sup_parsed: EdgeRelation = serde_json::from_str(&sup_json).unwrap();
442 assert_eq!(sup_parsed, EdgeRelation::Supports);
443
444 let ref_json = serde_json::to_string(&EdgeRelation::Refutes).unwrap();
445 assert_eq!(ref_json, "\"refutes\"");
446 let ref_parsed: EdgeRelation = serde_json::from_str(&ref_json).unwrap();
447 assert_eq!(ref_parsed, EdgeRelation::Refutes);
448 }
449}