Skip to main content

khive_vcs_adapters/
json_adapter.rs

1// Copyright 2026 Haiyang Li. Licensed under Apache-2.0.
2//
3//! JSON array format adapter.
4
5use crate::adapter::FormatAdapter;
6use crate::error::AdapterError;
7use crate::record::{EdgeRecord, EntityRecord};
8use khive_types::{EdgeRelation, EntityKind};
9use serde_json::Value;
10use std::str::FromStr;
11use uuid::Uuid;
12
13/// A [`FormatAdapter`] that parses a JSON array of objects.
14///
15/// Entities and edges may be mixed in the same array — the adapter dispatches
16/// by checking for `source` + `target` keys (edge) vs. their absence (entity).
17///
18/// Construct with [`JsonFormatAdapter::new`], passing the raw JSON bytes.
19/// The constructor parses eagerly; iteration is cheap once constructed.
20pub struct JsonFormatAdapter {
21    entities: Vec<Result<EntityRecord, AdapterError>>,
22    edges: Vec<Result<EdgeRecord, AdapterError>>,
23    warnings: Vec<String>,
24}
25
26impl JsonFormatAdapter {
27    /// Parse `json_input` and return a ready adapter.
28    ///
29    /// Returns `Err(AdapterError::Parse)` if `json_input` is not valid JSON or
30    /// is not a JSON array at the top level.
31    pub fn new(json_input: &str) -> Result<Self, AdapterError> {
32        let value: Value =
33            serde_json::from_str(json_input).map_err(|e| AdapterError::Parse(e.to_string()))?;
34
35        let array = match value {
36            Value::Array(a) => a,
37            _ => {
38                return Err(AdapterError::Parse(
39                    "expected a JSON array at the top level".into(),
40                ))
41            }
42        };
43
44        let mut entities = Vec::new();
45        let mut edges = Vec::new();
46        let mut warnings = Vec::new();
47
48        for (index, item) in array.into_iter().enumerate() {
49            let obj = match item {
50                Value::Object(m) => m,
51                other => {
52                    warnings.push(format!(
53                        "record {index}: expected an object, got {}; skipped",
54                        other.type_str()
55                    ));
56                    continue;
57                }
58            };
59
60            // Normalise keys to lowercase once for dispatch detection.
61            // Keys are matched case-insensitively.
62            let has_source = obj.keys().any(|k| {
63                let l = k.to_ascii_lowercase();
64                l == "source" || l == "from"
65            });
66            let has_target = obj.keys().any(|k| {
67                let l = k.to_ascii_lowercase();
68                l == "target" || l == "to"
69            });
70
71            if has_source && has_target {
72                edges.push(parse_edge(index, obj));
73            } else {
74                entities.push(parse_entity(index, obj, &mut warnings));
75            }
76        }
77
78        Ok(Self {
79            entities,
80            edges,
81            warnings,
82        })
83    }
84}
85
86impl FormatAdapter for JsonFormatAdapter {
87    fn name(&self) -> &str {
88        "json"
89    }
90
91    fn entities(&mut self) -> impl Iterator<Item = Result<EntityRecord, AdapterError>> {
92        self.entities.drain(..)
93    }
94
95    fn edges(&mut self) -> impl Iterator<Item = Result<EdgeRecord, AdapterError>> {
96        self.edges.drain(..)
97    }
98
99    fn warnings(&self) -> &[String] {
100        &self.warnings
101    }
102}
103
104// ---------------------------------------------------------------------------
105// Internal helpers
106// ---------------------------------------------------------------------------
107
108/// Remove a key from the map case-insensitively.
109///
110/// Looks for the first key whose ASCII-lowercase form equals `field_lower`.
111/// Returns `(original_key, value)` if found, `None` otherwise.
112fn remove_ci(
113    obj: &mut serde_json::Map<String, Value>,
114    field_lower: &str,
115) -> Option<(String, Value)> {
116    let key = obj
117        .keys()
118        .find(|k| k.to_ascii_lowercase() == field_lower)
119        .cloned()?;
120    let val = obj.remove(&key)?;
121    Some((key, val))
122}
123
124/// Extract a required non-empty string field, case-insensitive.
125fn extract_required_string(
126    obj: &mut serde_json::Map<String, Value>,
127    index: usize,
128    field: &str,
129) -> Result<String, AdapterError> {
130    match remove_ci(obj, field) {
131        Some((_, Value::String(s))) if !s.is_empty() => Ok(s),
132        Some(_) => Err(AdapterError::InvalidField {
133            index,
134            field: field.into(),
135            reason: "must be a non-empty string".into(),
136        }),
137        None => Err(AdapterError::MissingField {
138            index,
139            field: field.into(),
140        }),
141    }
142}
143
144/// Extract an optional UUID field, generating a new one if absent.
145fn extract_uuid_field(
146    obj: &mut serde_json::Map<String, Value>,
147    index: usize,
148    field: &str,
149) -> Result<Uuid, AdapterError> {
150    match remove_ci(obj, field) {
151        Some((_, Value::String(s))) => s.parse::<Uuid>().map_err(|e| AdapterError::InvalidField {
152            index,
153            field: field.into(),
154            reason: e.to_string(),
155        }),
156        Some(_) => Err(AdapterError::InvalidField {
157            index,
158            field: field.into(),
159            reason: "must be a UUID string".into(),
160        }),
161        None => Ok(Uuid::new_v4()),
162    }
163}
164
165/// Extract and validate an edge weight field.
166fn extract_weight(
167    obj: &mut serde_json::Map<String, Value>,
168    index: usize,
169) -> Result<f64, AdapterError> {
170    match remove_ci(obj, "weight") {
171        Some((_, Value::Number(n))) => {
172            let w = n.as_f64().ok_or_else(|| AdapterError::InvalidField {
173                index,
174                field: "weight".into(),
175                reason: "weight is not a finite f64".into(),
176            })?;
177            if !w.is_finite() || !(0.0..=1.0).contains(&w) {
178                return Err(AdapterError::InvalidField {
179                    index,
180                    field: "weight".into(),
181                    reason: format!("must be finite and in [0.0, 1.0], got {w}"),
182                });
183            }
184            Ok(w)
185        }
186        Some(_) => Err(AdapterError::InvalidField {
187            index,
188            field: "weight".into(),
189            reason: "must be a number".into(),
190        }),
191        None => Ok(0.7),
192    }
193}
194
195fn parse_entity(
196    index: usize,
197    mut obj: serde_json::Map<String, Value>,
198    warnings: &mut Vec<String>,
199) -> Result<EntityRecord, AdapterError> {
200    let name = extract_required_string(&mut obj, index, "name")?;
201
202    let kind = {
203        let raw = extract_required_string(&mut obj, index, "kind")?;
204        EntityKind::from_str(&raw)
205            .map_err(|_| AdapterError::UnknownKind {
206                index,
207                kind: raw.clone(),
208            })?
209            .name()
210            .to_owned()
211    };
212
213    let id = extract_uuid_field(&mut obj, index, "id")?;
214
215    let description = match remove_ci(&mut obj, "description") {
216        Some((_, Value::String(s))) => Some(s),
217        Some(_) => {
218            warnings.push(format!(
219                "record {index}: 'description' is not a string; ignored"
220            ));
221            None
222        }
223        None => None,
224    };
225
226    let tags: Vec<String> = match remove_ci(&mut obj, "tags") {
227        Some((_, Value::Array(arr))) => arr
228            .into_iter()
229            .filter_map(|v| match v {
230                Value::String(s) => Some(s),
231                _ => {
232                    warnings.push(format!("record {index}: non-string tag value ignored"));
233                    None
234                }
235            })
236            .collect(),
237        Some(_) => {
238            warnings.push(format!("record {index}: 'tags' is not an array; ignored"));
239            Vec::new()
240        }
241        None => Vec::new(),
242    };
243
244    let mut props_base = match remove_ci(&mut obj, "properties") {
245        Some((_, Value::Object(m))) => m,
246        Some((_, other)) => {
247            warnings.push(format!(
248                "record {index}: 'properties' is not an object (got {}); ignored",
249                other.type_str()
250            ));
251            serde_json::Map::new()
252        }
253        None => serde_json::Map::new(),
254    };
255    for (k, v) in obj {
256        props_base.insert(k, v);
257    }
258
259    Ok(EntityRecord {
260        id,
261        kind,
262        name,
263        description,
264        properties: Value::Object(props_base),
265        tags,
266    })
267}
268
269fn parse_edge(
270    index: usize,
271    mut obj: serde_json::Map<String, Value>,
272) -> Result<EdgeRecord, AdapterError> {
273    let source = remove_ci(&mut obj, "source")
274        .or_else(|| remove_ci(&mut obj, "from"))
275        .and_then(|(_, v)| v.as_str().map(|s| s.to_owned()))
276        .ok_or_else(|| AdapterError::MissingField {
277            index,
278            field: "source".into(),
279        })?;
280
281    let target = remove_ci(&mut obj, "target")
282        .or_else(|| remove_ci(&mut obj, "to"))
283        .and_then(|(_, v)| v.as_str().map(|s| s.to_owned()))
284        .ok_or_else(|| AdapterError::MissingField {
285            index,
286            field: "target".into(),
287        })?;
288
289    let relation = {
290        let raw = extract_required_string(&mut obj, index, "relation")?;
291        EdgeRelation::from_str(&raw)
292            .map_err(|_| AdapterError::UnknownRelation {
293                index,
294                relation: raw.clone(),
295            })?
296            .as_str()
297            .to_owned()
298    };
299
300    let edge_id = match remove_ci(&mut obj, "edge_id").or_else(|| remove_ci(&mut obj, "id")) {
301        Some((_, Value::String(s))) => {
302            s.parse::<Uuid>().map_err(|e| AdapterError::InvalidField {
303                index,
304                field: "edge_id".into(),
305                reason: e.to_string(),
306            })?
307        }
308        Some(_) => {
309            return Err(AdapterError::InvalidField {
310                index,
311                field: "edge_id".into(),
312                reason: "must be a UUID string".into(),
313            })
314        }
315        None => Uuid::new_v4(),
316    };
317
318    let weight = extract_weight(&mut obj, index)?;
319
320    let mut properties = match remove_ci(&mut obj, "properties") {
321        Some((_, Value::Object(m))) => m,
322        Some(_) | None => serde_json::Map::new(),
323    };
324    for (k, v) in obj {
325        properties.insert(k, v);
326    }
327
328    Ok(EdgeRecord {
329        edge_id,
330        source,
331        target,
332        relation,
333        weight,
334        properties: Value::Object(properties),
335    })
336}
337
338// ---------------------------------------------------------------------------
339// Helper trait: readable type name for error messages
340// ---------------------------------------------------------------------------
341
342trait TypeStr {
343    fn type_str(&self) -> &'static str;
344}
345
346impl TypeStr for Value {
347    fn type_str(&self) -> &'static str {
348        match self {
349            Value::Null => "null",
350            Value::Bool(_) => "bool",
351            Value::Number(_) => "number",
352            Value::String(_) => "string",
353            Value::Array(_) => "array",
354            Value::Object(_) => "object",
355        }
356    }
357}