1use rusqlite::Connection;
20
21use crate::error::{KernelError, Result};
22
23use super::types::{GraphNode, NODE_COLUMNS, escape_like, row_to_node};
24
25fn is_cjk_char(ch: char) -> bool {
27 matches!(
28 ch,
29 '\u{3040}'..='\u{30FF}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{FF00}'..='\u{FFEF}' )
36}
37
38pub fn segment_cjk(text: &str) -> String {
45 let mut out = String::with_capacity(text.len() + 8);
46 for ch in text.chars() {
47 out.push(ch);
48 if is_cjk_char(ch) {
49 out.push(' ');
50 }
51 }
52 out
53}
54
55pub fn search_nodes_cjk(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
66 let terms: Vec<String> = query
67 .split_whitespace()
68 .map(|s| s.to_lowercase())
69 .filter(|s| !s.is_empty())
70 .collect();
71 if terms.is_empty() {
72 return Ok(Vec::new());
73 }
74
75 let term_cond = "(lower(title) LIKE ? ESCAPE '\\' OR lower(body) LIKE ? ESCAPE '\\' OR lower(tags) LIKE ? ESCAPE '\\')";
78 let where_clause = terms
79 .iter()
80 .map(|_| term_cond)
81 .collect::<Vec<_>>()
82 .join(" AND ");
83
84 let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::with_capacity(terms.len() * 3 + 1);
85 for t in &terms {
86 let pat = format!("%{}%", escape_like(t));
87 bind.push(Box::new(pat.clone()));
88 bind.push(Box::new(pat.clone()));
89 bind.push(Box::new(pat));
90 }
91 bind.push(Box::new(limit as i64));
92
93 let sql = format!(
94 "SELECT {NODE_COLUMNS} FROM nodes WHERE {where_clause} ORDER BY importance DESC LIMIT ?"
95 );
96 let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
97
98 let mut stmt = conn
99 .prepare(&sql)
100 .map_err(|e| KernelError::Store(e.to_string()))?;
101 let nodes: Vec<GraphNode> = stmt
102 .query_map(refs.as_slice(), row_to_node)
103 .map_err(|e| KernelError::Store(e.to_string()))?
104 .filter_map(|r| r.ok())
105 .collect();
106 Ok(nodes)
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::graph::schema::init_graph_schema;
113 use crate::graph::store::upsert_node;
114
115 fn mem_db() -> Connection {
116 let conn = Connection::open_in_memory().unwrap();
117 init_graph_schema(&conn).unwrap();
118 conn
119 }
120
121 fn node(id: &str, title: &str, body: &str) -> GraphNode {
122 GraphNode {
123 id: id.to_string(),
124 node_type: "concept".to_string(),
125 title: title.to_string(),
126 body: body.to_string(),
127 tags: vec![],
128 projects: vec![],
129 agents: vec![],
130 created: "2026-01-01T00:00:00Z".to_string(),
131 updated: "2026-01-01T00:00:00Z".to_string(),
132 importance: 0.7,
133 access_count: 0,
134 accessed_at: String::new(),
135 }
136 }
137
138 #[test]
139 fn segment_separates_cjk_chars() {
140 assert_eq!(segment_cjk("知識"), "知 識 ");
141 assert_eq!(segment_cjk("rust async"), "rust async");
143 let seg = segment_cjk("Rustでトークン");
145 assert!(seg.starts_with("Rustで "), "got: {seg:?}");
146 assert!(seg.contains("ト "), "got: {seg:?}");
147 }
148
149 #[test]
151 fn cjk_search_finds_cjk_node() {
152 let conn = mem_db();
153 upsert_node(
154 &conn,
155 &node("k1", "知識グラフの構築", "ナレッジベースをグラフ化する"),
156 )
157 .unwrap();
158 upsert_node(&conn, &node("k2", "Python GIL", "global interpreter lock")).unwrap();
159
160 let hits = search_nodes_cjk(&conn, "グラフ", 10).unwrap();
162 assert_eq!(hits.len(), 1);
163 assert_eq!(hits[0].id, "k1");
164 }
165
166 #[test]
168 fn cjk_search_and_semantics() {
169 let conn = mem_db();
170 upsert_node(&conn, &node("k1", "知識グラフ", "説明本文")).unwrap();
171 let hits = search_nodes_cjk(&conn, "グラフ 存在", 10).unwrap();
173 assert!(hits.is_empty());
174 let hits = search_nodes_cjk(&conn, "知識 グラフ", 10).unwrap();
176 assert_eq!(hits.len(), 1);
177 }
178
179 #[test]
180 fn empty_query_returns_nothing() {
181 let conn = mem_db();
182 upsert_node(&conn, &node("k1", "知識", "body")).unwrap();
183 assert!(search_nodes_cjk(&conn, " ", 10).unwrap().is_empty());
184 }
185}