1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6pub fn default_importance() -> f64 {
8 0.5
9}
10
11pub fn importance_for_type(node_type: &str) -> f64 {
13 match node_type {
14 "decision" => 0.9,
15 "resolution" => 0.8,
16 "psychographic" => 0.8,
17 "instinct" => 0.7,
18 "concept" => 0.7,
19 "project" => 0.7,
20 "pattern" => 0.5,
21 "error" => 0.4,
22 "session" => 0.05,
23 _ => 0.5,
24 }
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct GraphNode {
36 pub id: String,
38 #[serde(rename = "type")]
40 pub node_type: String,
41 pub title: String,
43 #[serde(default)]
45 pub body: String,
46 #[serde(default)]
48 pub tags: Vec<String>,
49 #[serde(default)]
51 pub projects: Vec<String>,
52 #[serde(default)]
54 pub agents: Vec<String>,
55 pub created: String,
57 pub updated: String,
59 #[serde(default = "default_importance")]
61 pub importance: f64,
62 #[serde(default)]
64 pub access_count: i64,
65 #[serde(default)]
67 pub accessed_at: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct GraphEdge {
73 pub id: String,
75 pub source: String,
77 pub target: String,
79 pub relation: String,
81 pub weight: f64,
83 pub ts: String,
85}
86
87#[non_exhaustive]
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91pub struct GraphNodeSummary {
92 pub id: String,
94 pub title: String,
96 #[serde(rename = "type")]
98 pub node_type: String,
99 #[serde(default)]
101 pub tags: Vec<String>,
102 #[serde(default = "default_importance")]
104 pub importance: f64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct Graph {
110 pub nodes: Vec<GraphNodeSummary>,
112 pub edges: Vec<GraphEdge>,
114}
115
116#[derive(Debug, Clone)]
118pub struct ScoredNode {
119 pub node: GraphNode,
121 pub score: f64,
123}
124
125#[non_exhaustive]
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct GraphStats {
129 pub total_nodes: i64,
131 pub total_edges: i64,
133 pub avg_importance: f64,
135 pub by_type: HashMap<String, i64>,
137}
138
139pub(crate) fn join_csv(v: &[String]) -> String {
142 v.join(",")
143}
144
145pub(crate) fn split_csv(s: &str) -> Vec<String> {
146 s.split(',')
147 .map(|x| x.trim().to_string())
148 .filter(|x| !x.is_empty())
149 .collect()
150}
151
152pub(crate) fn escape_like(value: &str) -> String {
157 let mut out = String::with_capacity(value.len());
158 for ch in value.chars() {
159 match ch {
160 '%' | '_' | '\\' => {
161 out.push('\\');
162 out.push(ch);
163 }
164 _ => out.push(ch),
165 }
166 }
167 out
168}
169
170pub fn validate_uuid(id: &str) -> bool {
175 let b = id.as_bytes();
176 b.len() == 36
177 && b[8] == b'-'
178 && b[13] == b'-'
179 && b[18] == b'-'
180 && b[23] == b'-'
181 && b[14] == b'4'
182 && matches!(b[19], b'8'..=b'9' | b'a'..=b'b' | b'A'..=b'B')
183 && b.iter()
184 .enumerate()
185 .all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
186}
187
188pub(crate) const NODE_COLUMNS: &str = "id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at";
192
193pub(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";
195
196pub(crate) fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<GraphNode> {
197 let tags: String = row.get(3)?;
198 let projects: String = row.get(4)?;
199 let agents: String = row.get(5)?;
200 Ok(GraphNode {
201 id: row.get(0)?,
202 node_type: row.get(1)?,
203 title: row.get(2)?,
204 tags: split_csv(&tags),
205 projects: split_csv(&projects),
206 agents: split_csv(&agents),
207 created: row.get(6)?,
208 updated: row.get(7)?,
209 body: row.get(8)?,
210 importance: row.get(9).unwrap_or(0.5),
211 access_count: row.get::<_, i64>(10).unwrap_or(0),
212 accessed_at: row.get(11).unwrap_or_default(),
213 })
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn escape_like_escapes_percent() {
222 assert_eq!(escape_like("100%"), r"100\%");
223 }
224
225 #[test]
226 fn escape_like_escapes_underscore() {
227 assert_eq!(escape_like("a_b"), r"a\_b");
228 }
229
230 #[test]
231 fn escape_like_passthrough_normal() {
232 assert_eq!(escape_like("hello"), "hello");
233 }
234
235 #[test]
236 fn escape_like_escapes_backslash() {
237 assert_eq!(escape_like(r"\"), r"\\");
238 }
239
240 #[test]
241 fn escape_like_empty() {
242 assert_eq!(escape_like(""), "");
243 }
244
245 #[test]
246 fn validate_uuid_accepts_valid_v4() {
247 assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000"));
248 assert!(validate_uuid("00000000-0000-4000-8000-000000000000"));
249 assert!(validate_uuid("ffffffff-ffff-4fff-bfff-ffffffffffff"));
250 }
251
252 #[test]
253 fn validate_uuid_rejects_wrong_version() {
254 assert!(!validate_uuid("550e8400-e29b-31d4-a716-446655440000"));
256 }
257
258 #[test]
259 fn validate_uuid_rejects_wrong_variant() {
260 assert!(!validate_uuid("550e8400-e29b-41d4-c716-446655440000"));
262 }
263
264 #[test]
265 fn validate_uuid_rejects_short() {
266 assert!(!validate_uuid(""));
267 assert!(!validate_uuid("550e8400"));
268 }
269
270 #[test]
271 fn validate_uuid_rejects_missing_dashes() {
272 assert!(!validate_uuid("550e8400e29b41d4a716446655440000"));
273 }
274
275 #[test]
276 fn validate_uuid_rejects_non_hex() {
277 assert!(!validate_uuid("550g8400-e29b-41d4-a716-446655440000"));
278 }
279}