Skip to main content

recall_echo/graph/
util.rs

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