Skip to main content

recall_echo/graph/
util.rs

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