1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6pub fn default_importance() -> f64 {
8 0.5
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct GraphNode {
20 pub id: String,
22 #[serde(rename = "type")]
24 pub node_type: String,
25 pub title: String,
27 #[serde(default)]
29 pub body: String,
30 #[serde(default)]
32 pub tags: Vec<String>,
33 #[serde(default)]
35 pub projects: Vec<String>,
36 #[serde(default)]
38 pub agents: Vec<String>,
39 pub created: String,
41 pub updated: String,
43 #[serde(default = "default_importance")]
45 pub importance: f64,
46 #[serde(default)]
48 pub access_count: i64,
49 #[serde(default)]
51 pub accessed_at: String,
52 #[serde(default)]
55 pub valid_until: String,
56 #[serde(default)]
59 pub last_verified: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct GraphEdge {
65 pub id: String,
67 pub source: String,
69 pub target: String,
71 pub relation: String,
73 pub weight: f64,
75 pub ts: String,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
88pub enum EdgeDirection {
89 Out,
91 In,
93 #[default]
95 Both,
96}
97
98#[non_exhaustive]
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
102pub struct GraphNodeSummary {
103 pub id: String,
105 pub title: String,
107 #[serde(rename = "type")]
109 pub node_type: String,
110 #[serde(default)]
112 pub tags: Vec<String>,
113 #[serde(default = "default_importance")]
115 pub importance: f64,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default)]
120pub struct Graph {
121 pub nodes: Vec<GraphNodeSummary>,
123 pub edges: Vec<GraphEdge>,
125}
126
127#[derive(Debug, Clone)]
129pub struct ScoredNode {
130 pub node: GraphNode,
132 pub score: f64,
134}
135
136#[non_exhaustive]
138#[derive(Debug, Clone, Serialize, Deserialize, Default)]
139pub struct GraphStats {
140 pub total_nodes: i64,
142 pub total_edges: i64,
144 pub avg_importance: f64,
146 pub by_type: HashMap<String, i64>,
148}
149
150pub(crate) fn join_csv(v: &[String]) -> String {
153 v.join(",")
154}
155
156pub(crate) fn split_csv(s: &str) -> Vec<String> {
157 s.split(',')
158 .map(|x| x.trim().to_string())
159 .filter(|x| !x.is_empty())
160 .collect()
161}
162
163pub(crate) fn escape_like(value: &str) -> String {
168 let mut out = String::with_capacity(value.len());
169 for ch in value.chars() {
170 match ch {
171 '%' | '_' | '\\' => {
172 out.push('\\');
173 out.push(ch);
174 }
175 _ => out.push(ch),
176 }
177 }
178 out
179}
180
181pub fn validate_uuid(id: &str) -> bool {
186 let b = id.as_bytes();
187 b.len() == 36
188 && b[8] == b'-'
189 && b[13] == b'-'
190 && b[18] == b'-'
191 && b[23] == b'-'
192 && b[14] == b'4'
193 && matches!(b[19], b'8'..=b'9' | b'a'..=b'b' | b'A'..=b'B')
194 && b.iter()
195 .enumerate()
196 .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
197}
198
199pub(crate) const NODE_COLUMNS: &str = "id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at, valid_until, last_verified";
203
204pub(crate) const NODE_COLUMNS_PREFIXED: &str = "id, n.type, n.title, n.tags, n.projects, n.agents, n.created, n.updated, n.body, n.importance, n.access_count, n.accessed_at, n.valid_until, n.last_verified";
206
207pub(crate) fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<GraphNode> {
208 let tags: String = row.get(3)?;
209 let projects: String = row.get(4)?;
210 let agents: String = row.get(5)?;
211 Ok(GraphNode {
212 id: row.get(0)?,
213 node_type: row.get(1)?,
214 title: row.get(2)?,
215 tags: split_csv(&tags),
216 projects: split_csv(&projects),
217 agents: split_csv(&agents),
218 created: row.get(6)?,
219 updated: row.get(7)?,
220 body: row.get(8)?,
221 importance: row.get(9).unwrap_or(0.5),
222 access_count: row.get::<_, i64>(10).unwrap_or(0),
223 accessed_at: row.get(11).unwrap_or_default(),
224 valid_until: row.get(12).unwrap_or_default(),
225 last_verified: row.get(13).unwrap_or_default(),
226 })
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn escape_like_escapes_percent() {
235 assert_eq!(escape_like("100%"), r"100\%");
236 }
237
238 #[test]
239 fn escape_like_escapes_underscore() {
240 assert_eq!(escape_like("a_b"), r"a\_b");
241 }
242
243 #[test]
244 fn escape_like_passthrough_normal() {
245 assert_eq!(escape_like("hello"), "hello");
246 }
247
248 #[test]
249 fn escape_like_escapes_backslash() {
250 assert_eq!(escape_like(r"\"), r"\\");
251 }
252
253 #[test]
254 fn escape_like_empty() {
255 assert_eq!(escape_like(""), "");
256 }
257
258 #[test]
259 fn validate_uuid_accepts_valid_v4() {
260 assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000"));
261 assert!(validate_uuid("00000000-0000-4000-8000-000000000000"));
262 assert!(validate_uuid("ffffffff-ffff-4fff-bfff-ffffffffffff"));
263 }
264
265 #[test]
266 fn validate_uuid_rejects_wrong_version() {
267 assert!(!validate_uuid("550e8400-e29b-31d4-a716-446655440000"));
269 }
270
271 #[test]
272 fn validate_uuid_rejects_wrong_variant() {
273 assert!(!validate_uuid("550e8400-e29b-41d4-c716-446655440000"));
275 }
276
277 #[test]
278 fn validate_uuid_rejects_short() {
279 assert!(!validate_uuid(""));
280 assert!(!validate_uuid("550e8400"));
281 }
282
283 #[test]
284 fn validate_uuid_rejects_missing_dashes() {
285 assert!(!validate_uuid("550e8400e29b41d4a716446655440000"));
286 }
287
288 #[test]
289 fn validate_uuid_rejects_non_hex() {
290 assert!(!validate_uuid("550g8400-e29b-41d4-a716-446655440000"));
291 }
292
293 #[test]
294 fn edge_direction_default_is_both() {
295 assert_eq!(EdgeDirection::default(), EdgeDirection::Both);
296 }
297}