1extern crate alloc;
4use alloc::string::{String, ToString};
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 Dependency,
24 Implementation,
26 Lateral,
28 Annotation,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
39pub enum EdgeRelation {
40 Contains,
42 PartOf,
43 InstanceOf,
44 Extends,
46 VariantOf,
47 IntroducedBy,
48 Supersedes,
49 DependsOn,
51 Enables,
52 Implements,
54 CompetesWith,
56 ComposedWith,
57 Annotates,
59}
60
61impl EdgeRelation {
62 pub const ALL: [Self; 13] = [
64 Self::Contains,
65 Self::PartOf,
66 Self::InstanceOf,
67 Self::Extends,
68 Self::VariantOf,
69 Self::IntroducedBy,
70 Self::Supersedes,
71 Self::DependsOn,
72 Self::Enables,
73 Self::Implements,
74 Self::CompetesWith,
75 Self::ComposedWith,
76 Self::Annotates,
77 ];
78
79 pub const fn category(&self) -> EdgeCategory {
81 match self {
82 Self::Contains | Self::PartOf | Self::InstanceOf => EdgeCategory::Structure,
83 Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
84 EdgeCategory::Derivation
85 }
86 Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
87 Self::Implements => EdgeCategory::Implementation,
88 Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
89 Self::Annotates => EdgeCategory::Annotation,
90 }
91 }
92
93 pub const fn as_str(&self) -> &'static str {
95 match self {
96 Self::Contains => "contains",
97 Self::PartOf => "part_of",
98 Self::InstanceOf => "instance_of",
99 Self::Extends => "extends",
100 Self::VariantOf => "variant_of",
101 Self::IntroducedBy => "introduced_by",
102 Self::Supersedes => "supersedes",
103 Self::DependsOn => "depends_on",
104 Self::Enables => "enables",
105 Self::Implements => "implements",
106 Self::CompetesWith => "competes_with",
107 Self::ComposedWith => "composed_with",
108 Self::Annotates => "annotates",
109 }
110 }
111}
112
113impl fmt::Display for EdgeRelation {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.write_str(self.as_str())
116 }
117}
118
119#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct UnknownRelation(pub String);
122
123impl fmt::Display for UnknownRelation {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 write!(
126 f,
127 "unknown edge relation {:?}; valid relations: {}",
128 self.0,
129 EdgeRelation::ALL
130 .iter()
131 .map(|r| r.as_str())
132 .collect::<alloc::vec::Vec<_>>()
133 .join(", ")
134 )
135 }
136}
137
138#[cfg(feature = "std")]
139impl std::error::Error for UnknownRelation {}
140
141impl FromStr for EdgeRelation {
142 type Err = UnknownRelation;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
149 let normalised: String = s
151 .chars()
152 .map(|c| {
153 if c == '-' {
154 '_'
155 } else {
156 c.to_ascii_lowercase()
157 }
158 })
159 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
160 .collect();
161
162 match normalised.as_str() {
163 "contains" => Ok(Self::Contains),
164 "part_of" | "partof" => Ok(Self::PartOf),
165 "instance_of" | "instanceof" => Ok(Self::InstanceOf),
166 "extends" => Ok(Self::Extends),
167 "variant_of" | "variantof" => Ok(Self::VariantOf),
168 "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
169 "supersedes" => Ok(Self::Supersedes),
170 "depends_on" | "dependson" => Ok(Self::DependsOn),
171 "enables" => Ok(Self::Enables),
172 "implements" => Ok(Self::Implements),
173 "competes_with" | "competeswith" => Ok(Self::CompetesWith),
174 "composed_with" | "composedwith" => Ok(Self::ComposedWith),
175 "annotates" => Ok(Self::Annotates),
176 _ => Err(UnknownRelation(s.to_string())),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn all_has_thirteen_variants() {
187 assert_eq!(EdgeRelation::ALL.len(), 13);
188 }
189
190 #[test]
191 fn display_roundtrip_for_all() {
192 for relation in EdgeRelation::ALL {
193 let s = relation.to_string();
194 let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
195 assert_eq!(parsed, relation);
196 }
197 }
198
199 #[test]
200 fn from_str_case_insensitive() {
201 assert_eq!(
202 "Extends".parse::<EdgeRelation>().unwrap(),
203 EdgeRelation::Extends
204 );
205 assert_eq!(
206 "extends".parse::<EdgeRelation>().unwrap(),
207 EdgeRelation::Extends
208 );
209 assert_eq!(
210 "EXTENDS".parse::<EdgeRelation>().unwrap(),
211 EdgeRelation::Extends
212 );
213 }
214
215 #[test]
216 fn from_str_hyphen_tolerant() {
217 assert_eq!(
218 "part_of".parse::<EdgeRelation>().unwrap(),
219 EdgeRelation::PartOf
220 );
221 assert_eq!(
222 "part-of".parse::<EdgeRelation>().unwrap(),
223 EdgeRelation::PartOf
224 );
225 assert_eq!(
226 "partof".parse::<EdgeRelation>().unwrap(),
227 EdgeRelation::PartOf
228 );
229
230 assert_eq!(
231 "introduced_by".parse::<EdgeRelation>().unwrap(),
232 EdgeRelation::IntroducedBy
233 );
234 assert_eq!(
235 "introduced-by".parse::<EdgeRelation>().unwrap(),
236 EdgeRelation::IntroducedBy
237 );
238 }
239
240 #[test]
241 fn from_str_unknown_returns_error_with_list() {
242 let err = "related_to".parse::<EdgeRelation>().unwrap_err();
243 let msg = err.to_string();
244 assert!(
245 msg.contains("related_to"),
246 "error should mention the bad input"
247 );
248 assert!(
249 msg.contains("contains"),
250 "error should list valid relations"
251 );
252 assert!(msg.contains("annotates"), "error should list all 13");
253 }
254
255 #[test]
256 fn category_returns_correct_group() {
257 assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
258 assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
259 assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
260
261 assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
262 assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
263 assert_eq!(
264 EdgeRelation::IntroducedBy.category(),
265 EdgeCategory::Derivation
266 );
267 assert_eq!(
268 EdgeRelation::Supersedes.category(),
269 EdgeCategory::Derivation
270 );
271
272 assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
273 assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
274
275 assert_eq!(
276 EdgeRelation::Implements.category(),
277 EdgeCategory::Implementation
278 );
279
280 assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
281 assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
282
283 assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
284 }
285
286 #[test]
287 fn all_categories_covered() {
288 let mut cats = alloc::vec::Vec::new();
289 for r in EdgeRelation::ALL {
290 let c = r.category();
291 if !cats.contains(&c) {
292 cats.push(c);
293 }
294 }
295 assert_eq!(cats.len(), 6, "all 6 categories must be represented");
296 }
297
298 #[cfg(feature = "serde")]
299 #[test]
300 fn serde_snake_case_roundtrip() {
301 let rel = EdgeRelation::IntroducedBy;
302 let json = serde_json::to_string(&rel).unwrap();
303 assert_eq!(json, "\"introduced_by\"");
304 let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
305 assert_eq!(parsed, rel);
306 }
307}