use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub fn default_importance() -> f64 {
0.5
}
pub fn importance_for_type(node_type: &str) -> f64 {
match node_type {
"decision" => 0.9,
"resolution" => 0.8,
"psychographic" => 0.8,
"instinct" => 0.7,
"concept" => 0.7,
"project" => 0.7,
"pattern" => 0.5,
"error" => 0.4,
"session" => 0.05,
_ => 0.5,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub id: String,
#[serde(rename = "type")]
pub node_type: String,
pub title: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub projects: Vec<String>,
#[serde(default)]
pub agents: Vec<String>,
pub created: String,
pub updated: String,
#[serde(default = "default_importance")]
pub importance: f64,
#[serde(default)]
pub access_count: i64,
#[serde(default)]
pub accessed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub id: String,
pub source: String,
pub target: String,
pub relation: String,
pub weight: f64,
pub ts: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNodeSummary {
pub id: String,
pub title: String,
#[serde(rename = "type")]
pub node_type: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default = "default_importance")]
pub importance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Graph {
pub nodes: Vec<GraphNodeSummary>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone)]
pub struct ScoredNode {
pub node: GraphNode,
pub score: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphStats {
pub total_nodes: i64,
pub total_edges: i64,
pub avg_importance: f64,
pub by_type: HashMap<String, i64>,
}
pub(crate) fn join_csv(v: &[String]) -> String {
v.join(",")
}
pub(crate) fn split_csv(s: &str) -> Vec<String> {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect()
}
pub(crate) fn escape_like(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'%' | '_' | '\\' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
pub fn validate_uuid(id: &str) -> bool {
let b = id.as_bytes();
b.len() == 36
&& b[8] == b'-'
&& b[13] == b'-'
&& b[18] == b'-'
&& b[23] == b'-'
&& b[14] == b'4'
&& matches!(b[19], b'8'..=b'9' | b'a'..=b'b' | b'A'..=b'B')
&& b.iter()
.enumerate()
.all(|(i, &c)| matches!(i, 8 | 13 | 18 | 23) || c.is_ascii_hexdigit())
}
pub(crate) const NODE_COLUMNS: &str = "id, type, title, tags, projects, agents, created, updated, body, importance, access_count, accessed_at";
pub(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";
pub(crate) fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<GraphNode> {
let tags: String = row.get(3)?;
let projects: String = row.get(4)?;
let agents: String = row.get(5)?;
Ok(GraphNode {
id: row.get(0)?,
node_type: row.get(1)?,
title: row.get(2)?,
tags: split_csv(&tags),
projects: split_csv(&projects),
agents: split_csv(&agents),
created: row.get(6)?,
updated: row.get(7)?,
body: row.get(8)?,
importance: row.get(9).unwrap_or(0.5),
access_count: row.get::<_, i64>(10).unwrap_or(0),
accessed_at: row.get(11).unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_like_escapes_percent() {
assert_eq!(escape_like("100%"), r"100\%");
}
#[test]
fn escape_like_escapes_underscore() {
assert_eq!(escape_like("a_b"), r"a\_b");
}
#[test]
fn escape_like_passthrough_normal() {
assert_eq!(escape_like("hello"), "hello");
}
#[test]
fn escape_like_escapes_backslash() {
assert_eq!(escape_like(r"\"), r"\\");
}
#[test]
fn escape_like_empty() {
assert_eq!(escape_like(""), "");
}
#[test]
fn validate_uuid_accepts_valid_v4() {
assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000"));
assert!(validate_uuid("00000000-0000-4000-8000-000000000000"));
assert!(validate_uuid("ffffffff-ffff-4fff-bfff-ffffffffffff"));
}
#[test]
fn validate_uuid_rejects_wrong_version() {
assert!(!validate_uuid("550e8400-e29b-31d4-a716-446655440000"));
}
#[test]
fn validate_uuid_rejects_wrong_variant() {
assert!(!validate_uuid("550e8400-e29b-41d4-c716-446655440000"));
}
#[test]
fn validate_uuid_rejects_short() {
assert!(!validate_uuid(""));
assert!(!validate_uuid("550e8400"));
}
#[test]
fn validate_uuid_rejects_missing_dashes() {
assert!(!validate_uuid("550e8400e29b41d4a716446655440000"));
}
#[test]
fn validate_uuid_rejects_non_hex() {
assert!(!validate_uuid("550g8400-e29b-41d4-a716-446655440000"));
}
}