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}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
43pub enum EdgeRelation {
44 Contains,
46 PartOf,
47 InstanceOf,
48 Extends,
50 VariantOf,
51 IntroducedBy,
52 Supersedes,
53 DerivedFrom,
55 Precedes,
57 DependsOn,
59 Enables,
60 Implements,
62 CompetesWith,
64 ComposedWith,
65 Annotates,
67}
68
69impl EdgeRelation {
70 pub const ALL: [Self; 15] = [
72 Self::Contains,
73 Self::PartOf,
74 Self::InstanceOf,
75 Self::Extends,
76 Self::VariantOf,
77 Self::IntroducedBy,
78 Self::Supersedes,
79 Self::DerivedFrom,
80 Self::Precedes,
81 Self::DependsOn,
82 Self::Enables,
83 Self::Implements,
84 Self::CompetesWith,
85 Self::ComposedWith,
86 Self::Annotates,
87 ];
88
89 pub const VALID_NAMES: &'static [&'static str] = &[
91 "contains",
92 "part_of",
93 "instance_of",
94 "extends",
95 "variant_of",
96 "introduced_by",
97 "supersedes",
98 "derived_from",
99 "precedes",
100 "depends_on",
101 "enables",
102 "implements",
103 "competes_with",
104 "composed_with",
105 "annotates",
106 ];
107
108 pub const fn is_symmetric(&self) -> bool {
110 matches!(self, Self::CompetesWith | Self::ComposedWith)
111 }
112
113 pub const fn category(&self) -> EdgeCategory {
115 match self {
116 Self::Contains | Self::PartOf | Self::InstanceOf => EdgeCategory::Structure,
117 Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
118 EdgeCategory::Derivation
119 }
120 Self::DerivedFrom => EdgeCategory::Provenance,
121 Self::Precedes => EdgeCategory::Temporal,
122 Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
123 Self::Implements => EdgeCategory::Implementation,
124 Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
125 Self::Annotates => EdgeCategory::Annotation,
126 }
127 }
128
129 pub const fn as_str(&self) -> &'static str {
131 match self {
132 Self::Contains => "contains",
133 Self::PartOf => "part_of",
134 Self::InstanceOf => "instance_of",
135 Self::Extends => "extends",
136 Self::VariantOf => "variant_of",
137 Self::IntroducedBy => "introduced_by",
138 Self::Supersedes => "supersedes",
139 Self::DerivedFrom => "derived_from",
140 Self::Precedes => "precedes",
141 Self::DependsOn => "depends_on",
142 Self::Enables => "enables",
143 Self::Implements => "implements",
144 Self::CompetesWith => "competes_with",
145 Self::ComposedWith => "composed_with",
146 Self::Annotates => "annotates",
147 }
148 }
149}
150
151impl fmt::Display for EdgeRelation {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.write_str(self.as_str())
154 }
155}
156
157impl FromStr for EdgeRelation {
158 type Err = crate::error::UnknownVariant;
159
160 fn from_str(s: &str) -> Result<Self, Self::Err> {
168 let normalised: String = s
169 .chars()
170 .map(|c| {
171 if c == '-' {
172 '_'
173 } else {
174 c.to_ascii_lowercase()
175 }
176 })
177 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
178 .collect();
179
180 match normalised.as_str() {
181 "contains" => Ok(Self::Contains),
182 "part_of" | "partof" => Ok(Self::PartOf),
183 "instance_of" | "instanceof" => Ok(Self::InstanceOf),
184 "extends" => Ok(Self::Extends),
185 "variant_of" | "variantof" => Ok(Self::VariantOf),
186 "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
187 "supersedes" => Ok(Self::Supersedes),
188 "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
189 "precedes" => Ok(Self::Precedes),
190 "depends_on" | "dependson" => Ok(Self::DependsOn),
191 "enables" => Ok(Self::Enables),
192 "implements" => Ok(Self::Implements),
193 "competes_with" | "competeswith" => Ok(Self::CompetesWith),
194 "composed_with" | "composedwith" => Ok(Self::ComposedWith),
195 "annotates" => Ok(Self::Annotates),
196 _ => Err(crate::error::UnknownVariant::new(
197 "edge_relation",
198 s,
199 Self::VALID_NAMES,
200 )),
201 }
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use alloc::string::ToString;
209
210 #[test]
211 fn all_has_fifteen_variants() {
212 assert_eq!(EdgeRelation::ALL.len(), 15);
213 }
214
215 #[test]
216 fn all_eight_categories_covered() {
217 let mut cats = alloc::vec::Vec::new();
218 for r in EdgeRelation::ALL {
219 let c = r.category();
220 if !cats.contains(&c) {
221 cats.push(c);
222 }
223 }
224 assert_eq!(cats.len(), 8, "all 8 categories must be represented");
225 }
226
227 #[test]
228 fn display_roundtrip_for_all() {
229 for relation in EdgeRelation::ALL {
230 let s = relation.to_string();
231 let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
232 assert_eq!(parsed, relation);
233 }
234 }
235
236 #[test]
237 fn from_str_case_insensitive() {
238 assert_eq!(
239 "Extends".parse::<EdgeRelation>().unwrap(),
240 EdgeRelation::Extends
241 );
242 assert_eq!(
243 "extends".parse::<EdgeRelation>().unwrap(),
244 EdgeRelation::Extends
245 );
246 assert_eq!(
247 "EXTENDS".parse::<EdgeRelation>().unwrap(),
248 EdgeRelation::Extends
249 );
250 }
251
252 #[test]
253 fn from_str_hyphen_tolerant() {
254 assert_eq!(
255 "part_of".parse::<EdgeRelation>().unwrap(),
256 EdgeRelation::PartOf
257 );
258 assert_eq!(
259 "part-of".parse::<EdgeRelation>().unwrap(),
260 EdgeRelation::PartOf
261 );
262 assert_eq!(
263 "partof".parse::<EdgeRelation>().unwrap(),
264 EdgeRelation::PartOf
265 );
266
267 assert_eq!(
268 "introduced_by".parse::<EdgeRelation>().unwrap(),
269 EdgeRelation::IntroducedBy
270 );
271 assert_eq!(
272 "introduced-by".parse::<EdgeRelation>().unwrap(),
273 EdgeRelation::IntroducedBy
274 );
275 }
276
277 #[test]
278 fn from_str_unknown_returns_error_with_list() {
279 let err = "related_to".parse::<EdgeRelation>().unwrap_err();
280 let msg = err.to_string();
281 assert!(
282 msg.contains("related_to"),
283 "error should mention the bad input"
284 );
285 assert!(
286 msg.contains("contains"),
287 "error should list valid relations"
288 );
289 assert!(
290 msg.contains("derived_from"),
291 "error should list derived_from"
292 );
293 assert!(msg.contains("precedes"), "error should list precedes");
294 assert!(msg.contains("annotates"), "error should list all 15");
295 }
296
297 #[test]
298 fn category_returns_correct_group() {
299 assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
300 assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
301 assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
302
303 assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
304 assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
305 assert_eq!(
306 EdgeRelation::IntroducedBy.category(),
307 EdgeCategory::Derivation
308 );
309 assert_eq!(
310 EdgeRelation::Supersedes.category(),
311 EdgeCategory::Derivation
312 );
313
314 assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
315 assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
316
317 assert_eq!(
318 EdgeRelation::Implements.category(),
319 EdgeCategory::Implementation
320 );
321
322 assert_eq!(
323 EdgeRelation::DerivedFrom.category(),
324 EdgeCategory::Provenance
325 );
326 assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
327
328 assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
329 assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
330
331 assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
332 }
333
334 #[test]
335 fn from_str_new_relations() {
336 assert_eq!(
337 "derived_from".parse::<EdgeRelation>().unwrap(),
338 EdgeRelation::DerivedFrom
339 );
340 assert_eq!(
341 "derived-from".parse::<EdgeRelation>().unwrap(),
342 EdgeRelation::DerivedFrom
343 );
344 assert_eq!(
345 "derivedfrom".parse::<EdgeRelation>().unwrap(),
346 EdgeRelation::DerivedFrom
347 );
348 assert_eq!(
349 "precedes".parse::<EdgeRelation>().unwrap(),
350 EdgeRelation::Precedes
351 );
352 }
353
354 #[test]
355 fn is_symmetric_only_for_lateral_peer_relations() {
356 assert!(EdgeRelation::CompetesWith.is_symmetric());
357 assert!(EdgeRelation::ComposedWith.is_symmetric());
358 assert!(!EdgeRelation::DependsOn.is_symmetric());
359 assert!(!EdgeRelation::DerivedFrom.is_symmetric());
360 assert!(!EdgeRelation::Precedes.is_symmetric());
361 assert!(!EdgeRelation::Extends.is_symmetric());
362 }
363
364 #[cfg(feature = "serde")]
365 #[test]
366 fn serde_snake_case_roundtrip() {
367 let rel = EdgeRelation::IntroducedBy;
368 let json = serde_json::to_string(&rel).unwrap();
369 assert_eq!(json, "\"introduced_by\"");
370 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
371 assert_eq!(parsed, rel);
372 }
373
374 #[cfg(feature = "serde")]
375 #[test]
376 fn serde_new_relations_roundtrip() {
377 for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
378 let json = serde_json::to_string(&rel).unwrap();
379 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
380 assert_eq!(parsed, rel);
381 }
382 }
383}