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}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct GraphEdge {
57 pub id: String,
59 pub source: String,
61 pub target: String,
63 pub relation: String,
65 pub weight: f64,
67 pub ts: String,
69}
70
71#[non_exhaustive]
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct GraphNodeSummary {
76 pub id: String,
78 pub title: String,
80 #[serde(rename = "type")]
82 pub node_type: String,
83 #[serde(default)]
85 pub tags: Vec<String>,
86 #[serde(default = "default_importance")]
88 pub importance: f64,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct Graph {
94 pub nodes: Vec<GraphNodeSummary>,
96 pub edges: Vec<GraphEdge>,
98}
99
100#[derive(Debug, Clone)]
102pub struct ScoredNode {
103 pub node: GraphNode,
105 pub score: f64,
107}
108
109#[non_exhaustive]
111#[derive(Debug, Clone, Serialize, Deserialize, Default)]
112pub struct GraphStats {
113 pub total_nodes: i64,
115 pub total_edges: i64,
117 pub avg_importance: f64,
119 pub by_type: HashMap<String, i64>,
121}
122
123pub(crate) fn join_csv(v: &[String]) -> String {
126 v.join(",")
127}
128
129pub(crate) fn split_csv(s: &str) -> Vec<String> {
130 s.split(',')
131 .map(|x| x.trim().to_string())
132 .filter(|x| !x.is_empty())
133 .collect()
134}
135
136pub(crate) fn escape_like(value: &str) -> String {
141 let mut out = String::with_capacity(value.len());
142 for ch in value.chars() {
143 match ch {
144 '%' | '_' | '\\' => {
145 out.push('\\');
146 out.push(ch);
147 }
148 _ => out.push(ch),
149 }
150 }
151 out
152}
153
154pub fn validate_uuid(id: &str) -> bool {
159 let b = id.as_bytes();
160 b.len() == 36
161 && b[8] == b'-'
162 && b[13] == b'-'
163 && b[18] == b'-'
164 && b[23] == b'-'
165 && b[14] == b'4'
166 && matches!(b[19], b'8'..=b'9' | b'a'..=b'b' | b'A'..=b'B')
167 && b.iter()
168 .enumerate()
169 .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
170}
171
172pub(crate) const NODE_COLUMNS: &str = "id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at";
176
177pub(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";
179
180pub(crate) fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<GraphNode> {
181 let tags: String = row.get(3)?;
182 let projects: String = row.get(4)?;
183 let agents: String = row.get(5)?;
184 Ok(GraphNode {
185 id: row.get(0)?,
186 node_type: row.get(1)?,
187 title: row.get(2)?,
188 tags: split_csv(&tags),
189 projects: split_csv(&projects),
190 agents: split_csv(&agents),
191 created: row.get(6)?,
192 updated: row.get(7)?,
193 body: row.get(8)?,
194 importance: row.get(9).unwrap_or(0.5),
195 access_count: row.get::<_, i64>(10).unwrap_or(0),
196 accessed_at: row.get(11).unwrap_or_default(),
197 })
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn escape_like_escapes_percent() {
206 assert_eq!(escape_like("100%"), r"100\%");
207 }
208
209 #[test]
210 fn escape_like_escapes_underscore() {
211 assert_eq!(escape_like("a_b"), r"a\_b");
212 }
213
214 #[test]
215 fn escape_like_passthrough_normal() {
216 assert_eq!(escape_like("hello"), "hello");
217 }
218
219 #[test]
220 fn escape_like_escapes_backslash() {
221 assert_eq!(escape_like(r"\"), r"\\");
222 }
223
224 #[test]
225 fn escape_like_empty() {
226 assert_eq!(escape_like(""), "");
227 }
228
229 #[test]
230 fn validate_uuid_accepts_valid_v4() {
231 assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000"));
232 assert!(validate_uuid("00000000-0000-4000-8000-000000000000"));
233 assert!(validate_uuid("ffffffff-ffff-4fff-bfff-ffffffffffff"));
234 }
235
236 #[test]
237 fn validate_uuid_rejects_wrong_version() {
238 assert!(!validate_uuid("550e8400-e29b-31d4-a716-446655440000"));
240 }
241
242 #[test]
243 fn validate_uuid_rejects_wrong_variant() {
244 assert!(!validate_uuid("550e8400-e29b-41d4-c716-446655440000"));
246 }
247
248 #[test]
249 fn validate_uuid_rejects_short() {
250 assert!(!validate_uuid(""));
251 assert!(!validate_uuid("550e8400"));
252 }
253
254 #[test]
255 fn validate_uuid_rejects_missing_dashes() {
256 assert!(!validate_uuid("550e8400e29b41d4a716446655440000"));
257 }
258
259 #[test]
260 fn validate_uuid_rejects_non_hex() {
261 assert!(!validate_uuid("550g8400-e29b-41d4-a716-446655440000"));
262 }
263}