Skip to main content

recall_echo/graph/
util.rs

1//! Shared utility functions for the graph subsystem.
2
3use chrono::{DateTime, Utc};
4use serde::Deserialize;
5
6/// Read a counter that a record may predate.
7///
8/// SurrealDB renders a field an older record never had as absent under
9/// `SELECT *` and as `null` under a projection; both mean the same thing here,
10/// and both mean zero.
11pub fn count_or_zero<'de, D>(deserializer: D) -> Result<i64, D::Error>
12where
13    D: serde::Deserializer<'de>,
14{
15    Ok(Option::<i64>::deserialize(deserializer)?.unwrap_or(0))
16}
17
18/// Strip markdown code fencing (```json ... ```) from LLM responses.
19#[must_use]
20pub fn strip_markdown_fencing(text: &str) -> String {
21    let trimmed = text.trim();
22    let stripped = trimmed
23        .strip_prefix("```json")
24        .or(trimmed.strip_prefix("```"))
25        .unwrap_or(trimmed);
26    let stripped = stripped.strip_suffix("```").unwrap_or(stripped);
27    stripped.trim().to_string()
28}
29
30/// Extract the first balanced JSON object from a string.
31///
32/// Finds the first `{` and returns the substring up to the matching `}`.
33#[must_use]
34pub fn extract_json_object(text: &str) -> Option<&str> {
35    let start = text.find('{')?;
36    let mut depth = 0;
37    let bytes = text.as_bytes();
38    for (i, &b) in bytes[start..].iter().enumerate() {
39        match b {
40            b'{' => depth += 1,
41            b'}' => {
42                depth -= 1;
43                if depth == 0 {
44                    return Some(&text[start..start + i + 1]);
45                }
46            }
47            _ => {}
48        }
49    }
50    None
51}
52
53/// Parse a SurrealDB datetime value (serde_json::Value) into a chrono DateTime.
54///
55/// Handles both standard ISO 8601 and SurrealDB's datetime format.
56#[must_use]
57pub fn parse_datetime(val: &serde_json::Value) -> Option<DateTime<Utc>> {
58    match val {
59        serde_json::Value::String(s) => s.parse::<DateTime<Utc>>().ok().or_else(|| {
60            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.fZ")
61                .ok()
62                .map(|ndt| ndt.and_utc())
63        }),
64        _ => None,
65    }
66}
67
68/// Merge two JSON objects, with `overlay` keys taking precedence.
69///
70/// If either value is not an object, returns `overlay`.
71#[must_use]
72pub fn merge_json_objects(
73    base: &serde_json::Value,
74    overlay: &serde_json::Value,
75) -> serde_json::Value {
76    match (base, overlay) {
77        (serde_json::Value::Object(b), serde_json::Value::Object(o)) => {
78            let mut merged = b.clone();
79            for (k, v) in o {
80                merged.insert(k.clone(), v.clone());
81            }
82            serde_json::Value::Object(merged)
83        }
84        _ => overlay.clone(),
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn strip_fencing_json() {
94        let input = "```json\n{\"key\": \"value\"}\n```";
95        assert_eq!(strip_markdown_fencing(input), "{\"key\": \"value\"}");
96    }
97
98    #[test]
99    fn strip_fencing_plain() {
100        let input = "```\n{\"key\": \"value\"}\n```";
101        assert_eq!(strip_markdown_fencing(input), "{\"key\": \"value\"}");
102    }
103
104    #[test]
105    fn strip_fencing_none() {
106        let input = "{\"key\": \"value\"}";
107        assert_eq!(strip_markdown_fencing(input), input);
108    }
109
110    #[test]
111    fn extract_json_simple() {
112        let input = "Some text {\"key\": \"value\"} more text";
113        assert_eq!(extract_json_object(input), Some("{\"key\": \"value\"}"));
114    }
115
116    #[test]
117    fn extract_json_nested() {
118        let input = "{\"outer\": {\"inner\": 1}}";
119        assert_eq!(extract_json_object(input), Some(input));
120    }
121
122    #[test]
123    fn extract_json_none() {
124        assert_eq!(extract_json_object("no json here"), None);
125    }
126
127    #[test]
128    fn parse_datetime_iso() {
129        let val = serde_json::Value::String("2024-01-15T10:30:00Z".into());
130        let dt = parse_datetime(&val);
131        assert!(dt.is_some());
132    }
133
134    #[test]
135    fn parse_datetime_invalid() {
136        let val = serde_json::Value::String("not-a-date".into());
137        assert!(parse_datetime(&val).is_none());
138    }
139
140    #[test]
141    fn parse_datetime_non_string() {
142        let val = serde_json::json!(42);
143        assert!(parse_datetime(&val).is_none());
144    }
145
146    #[test]
147    fn merge_objects() {
148        let base = serde_json::json!({"a": 1, "b": 2});
149        let overlay = serde_json::json!({"b": 3, "c": 4});
150        let merged = merge_json_objects(&base, &overlay);
151        assert_eq!(merged, serde_json::json!({"a": 1, "b": 3, "c": 4}));
152    }
153
154    #[test]
155    fn merge_non_objects() {
156        let base = serde_json::json!("string");
157        let overlay = serde_json::json!(42);
158        assert_eq!(merge_json_objects(&base, &overlay), serde_json::json!(42));
159    }
160}