Skip to main content

khive_types/
edge.rs

1//! Edge relation types for the closed ontology defined in ADR-002 / ADR-021.
2
3extern 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/// The 6 structural categories that group the 13 canonical edge relations.
12///
13/// Exposed via [`EdgeRelation::category`] for query planners and UI rendering.
14#[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    /// Composition: `contains`, `part_of`, `instance_of`
19    Structure,
20    /// Intellectual lineage: `extends`, `variant_of`, `introduced_by`, `supersedes`
21    Derivation,
22    /// Build/runtime needs: `depends_on`, `enables`
23    Dependency,
24    /// Code ↔ concept: `implements`
25    Implementation,
26    /// Peer relationships: `competes_with`, `composed_with`
27    Lateral,
28    /// Cross-substrate annotation: `annotates`
29    Annotation,
30}
31
32/// Closed set of 13 canonical edge relations (ADR-002, ADR-021).
33///
34/// No `Default` — every edge requires an explicit relation.
35/// Wire format: snake_case strings (e.g. `"part_of"`, `"introduced_by"`).
36#[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    // Structure
41    Contains,
42    PartOf,
43    InstanceOf,
44    // Derivation
45    Extends,
46    VariantOf,
47    IntroducedBy,
48    Supersedes,
49    // Dependency
50    DependsOn,
51    Enables,
52    // Implementation
53    Implements,
54    // Lateral
55    CompetesWith,
56    ComposedWith,
57    // Annotation
58    Annotates,
59}
60
61impl EdgeRelation {
62    /// All 13 canonical relations in ADR-002 table order.
63    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    /// The category this relation belongs to.
80    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    /// Canonical snake_case name as stored in the database.
94    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/// Parse error returned when a string does not match any canonical relation.
120#[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    /// Case-insensitive, hyphen-tolerant parser.
145    ///
146    /// `part_of`, `part-of`, and `partof` all parse to `PartOf`.
147    /// `Extends`, `extends`, `EXTENDS` all parse to `Extends`.
148    fn from_str(s: &str) -> Result<Self, Self::Err> {
149        // Normalise: lowercase, replace hyphens with underscores, remove remaining non-alphanumeric.
150        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}