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