Skip to main content

weavatrix_graph/
legacy.rs

1use crate::{
2    AttributeValue, Confidence, Edge, EdgeKind, EvidenceKind, Graph, GraphBuilder, Node, NodeId,
3    NodeKind, Provenance, Result, SourcePosition, SourceSpan,
4};
5use crate::{String, Vec};
6use alloc::collections::BTreeMap;
7use core::str::FromStr;
8use serde::{Deserialize, Serialize};
9
10/// Compatibility representation for the current JavaScript Weavatrix graph.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct LegacyGraph {
13    #[serde(default)]
14    pub nodes: Vec<LegacyNode>,
15    #[serde(default)]
16    pub links: Vec<LegacyLink>,
17    #[serde(flatten)]
18    pub metadata: BTreeMap<String, AttributeValue>,
19}
20
21impl LegacyGraph {
22    /// Converts legacy `{ nodes, links }` data into a validated graph.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error when node ids are empty, nodes conflict, edge endpoints
27    /// are missing, or source spans are invalid.
28    pub fn into_graph(self, extractor: impl Into<String>) -> Result<Graph> {
29        let extractor = extractor.into();
30        let mut builder = GraphBuilder::new();
31        for node in self.nodes {
32            builder.add_node(node.into_node()?)?;
33        }
34        for link in self.links {
35            builder.add_edge(link.into_edge(extractor.clone())?)?;
36        }
37        builder.build()
38    }
39}
40
41impl TryFrom<LegacyGraph> for Graph {
42    type Error = crate::GraphError;
43
44    fn try_from(value: LegacyGraph) -> Result<Self> {
45        value.into_graph("weavatrix.legacy")
46    }
47}
48
49/// Legacy node shape with all unknown fields preserved as attributes.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct LegacyNode {
52    pub id: String,
53    #[serde(default)]
54    pub label: Option<String>,
55    #[serde(default)]
56    pub kind: Option<String>,
57    #[serde(default, rename = "type")]
58    pub node_type: Option<String>,
59    #[serde(default)]
60    pub language: Option<String>,
61    #[serde(default)]
62    pub source_file: Option<String>,
63    #[serde(default)]
64    pub source_range: Option<LegacyRange>,
65    #[serde(default)]
66    pub selection_start: Option<LegacyPoint>,
67    #[serde(default)]
68    pub selection_end: Option<LegacyPoint>,
69    #[serde(flatten)]
70    pub attributes: BTreeMap<String, AttributeValue>,
71}
72
73impl LegacyNode {
74    /// Converts this compatibility node into a graph node.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error when the id is empty or the source span is invalid.
79    pub fn into_node(mut self) -> Result<Node> {
80        let inferred_label = infer_label(&self.id);
81        let kind = parse_node_kind(self.kind.as_deref().or(self.node_type.as_deref()), &self.id);
82        let mut node = Node::new(self.id, self.label.unwrap_or(inferred_label), kind)?;
83        node.language = self.language.take();
84        if let Some(span) = self.source_range.take().and_then(|range| {
85            self.source_file
86                .as_ref()
87                .map(|file| range.into_span(file.clone()))
88        }) {
89            node.span = Some(span);
90        }
91        if let Some(source_file) = self.source_file {
92            node.attributes
93                .insert("source_file".into(), source_file.into());
94        }
95        if let Some(selection_start) = self.selection_start {
96            node.attributes
97                .insert("selection_start".into(), selection_start.into());
98        }
99        if let Some(selection_end) = self.selection_end {
100            node.attributes
101                .insert("selection_end".into(), selection_end.into());
102        }
103        node.attributes.extend(self.attributes);
104        Ok(node)
105    }
106}
107
108/// Legacy link shape with all unknown fields preserved as attributes.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct LegacyLink {
111    pub source: String,
112    pub target: String,
113    #[serde(default)]
114    pub relation: Option<String>,
115    #[serde(default)]
116    pub kind: Option<String>,
117    #[serde(default, rename = "type")]
118    pub edge_type: Option<String>,
119    #[serde(default)]
120    pub confidence: Option<String>,
121    #[serde(default)]
122    pub provenance: Option<String>,
123    #[serde(default)]
124    pub line: Option<u32>,
125    #[serde(default)]
126    pub character: Option<u32>,
127    #[serde(default, rename = "compileOnly")]
128    pub compile_only: Option<bool>,
129    #[serde(default, rename = "typeOnly")]
130    pub type_only: Option<bool>,
131    #[serde(default)]
132    pub specifier: Option<String>,
133    #[serde(default)]
134    pub usage: Option<String>,
135    #[serde(flatten)]
136    pub attributes: BTreeMap<String, AttributeValue>,
137}
138
139impl LegacyLink {
140    /// Converts this compatibility link into a graph edge.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error when endpoint ids are empty or kinds are invalid.
145    pub fn into_edge(mut self, extractor: impl Into<String>) -> Result<Edge> {
146        let kind_value = self
147            .relation
148            .as_deref()
149            .or(self.kind.as_deref())
150            .or(self.edge_type.as_deref())
151            .unwrap_or("references");
152        let kind = EdgeKind::from_str(kind_value)?;
153        let evidence = parse_evidence(self.provenance.as_deref().or(self.confidence.as_deref()));
154        let confidence = parse_confidence(self.confidence.as_deref(), &evidence);
155        let mut provenance = Provenance::new(extractor, evidence, confidence)?;
156        if let Some(line) = self.line {
157            let column = self.character.unwrap_or(0).saturating_add(1);
158            provenance.span = Some(SourceSpan::new(
159                infer_edge_file(&self.source),
160                SourcePosition::new(line, column),
161                SourcePosition::new(line, column.saturating_add(1)),
162            ));
163            self.attributes
164                .insert("line".into(), i64::from(line).into());
165        }
166        if let Some(character) = self.character {
167            self.attributes
168                .insert("character".into(), i64::from(character).into());
169        }
170        insert_optional(&mut self.attributes, "compileOnly", self.compile_only);
171        insert_optional(&mut self.attributes, "typeOnly", self.type_only);
172        if let Some(specifier) = self.specifier {
173            self.attributes.insert("specifier".into(), specifier.into());
174        }
175        if let Some(usage) = self.usage {
176            self.attributes.insert("usage".into(), usage.into());
177        }
178        Ok(Edge {
179            source: NodeId::new(self.source)?,
180            target: NodeId::new(self.target)?,
181            kind,
182            provenance,
183            attributes: self.attributes,
184        })
185    }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct LegacyPoint {
190    pub line: u32,
191    pub character: u32,
192}
193
194impl From<LegacyPoint> for AttributeValue {
195    fn from(value: LegacyPoint) -> Self {
196        let mut object = BTreeMap::new();
197        object.insert("line".into(), i64::from(value.line).into());
198        object.insert("character".into(), i64::from(value.character).into());
199        Self::Object(object)
200    }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct LegacyRange {
205    pub start: LegacyPoint,
206    pub end: LegacyPoint,
207}
208
209impl LegacyRange {
210    #[must_use]
211    pub fn into_span(self, file: String) -> SourceSpan {
212        SourceSpan::new(
213            file,
214            SourcePosition::new(
215                self.start.line.saturating_add(1),
216                self.start.character.saturating_add(1),
217            ),
218            SourcePosition::new(
219                self.end.line.saturating_add(1),
220                self.end.character.saturating_add(1),
221            ),
222        )
223    }
224}
225
226fn infer_label(id: &str) -> String {
227    String::from(id.rsplit(['/', '#']).next().unwrap_or(id))
228}
229
230fn infer_edge_file(source: &str) -> String {
231    String::from(source.split('#').next().unwrap_or(source))
232}
233
234fn parse_node_kind(value: Option<&str>, id: &str) -> NodeKind {
235    if let Some(value) = value.and_then(|value| NodeKind::from_str(value).ok()) {
236        return value;
237    }
238    if id.contains('#') {
239        NodeKind::Function
240    } else {
241        NodeKind::File
242    }
243}
244
245fn parse_evidence(value: Option<&str>) -> EvidenceKind {
246    value
247        .and_then(|value| EvidenceKind::from_str(value).ok())
248        .unwrap_or(EvidenceKind::Extracted)
249}
250
251fn parse_confidence(value: Option<&str>, evidence: &EvidenceKind) -> Confidence {
252    match value
253        .unwrap_or_default()
254        .trim()
255        .to_ascii_lowercase()
256        .as_str()
257    {
258        "exact" | "exact_lsp" => Confidence::Exact,
259        "high" | "extracted" | "resolved" => Confidence::High,
260        "medium" => Confidence::Medium,
261        "low" | "inferred" | "conflict" => Confidence::Low,
262        _ => match evidence {
263            EvidenceKind::ExactLsp => Confidence::Exact,
264            EvidenceKind::Inferred | EvidenceKind::Conflict => Confidence::Low,
265            _ => Confidence::High,
266        },
267    }
268}
269
270fn insert_optional(
271    attributes: &mut BTreeMap<String, AttributeValue>,
272    key: &'static str,
273    value: Option<bool>,
274) {
275    if let Some(value) = value {
276        attributes.insert(key.into(), value.into());
277    }
278}