use rusqlite::Connection;
use crate::error::{KernelError, Result};
use super::types::{GraphNode, NODE_COLUMNS, escape_like, row_to_node};
fn is_cjk_char(ch: char) -> bool {
matches!(
ch,
'\u{3040}'..='\u{30FF}' | '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}' | '\u{AC00}'..='\u{D7AF}' | '\u{FF00}'..='\u{FFEF}' )
}
pub fn segment_cjk(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 8);
for ch in text.chars() {
out.push(ch);
if is_cjk_char(ch) {
out.push(' ');
}
}
out
}
pub fn search_nodes_cjk(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
let terms: Vec<String> = query
.split_whitespace()
.map(|s| s.to_lowercase())
.filter(|s| !s.is_empty())
.collect();
if terms.is_empty() {
return Ok(Vec::new());
}
let term_cond = "(lower(title) LIKE ? ESCAPE '\\' OR lower(body) LIKE ? ESCAPE '\\' OR lower(tags) LIKE ? ESCAPE '\\')";
let where_clause = terms
.iter()
.map(|_| term_cond)
.collect::<Vec<_>>()
.join(" AND ");
let mut bind: Vec<Box<dyn rusqlite::ToSql>> = Vec::with_capacity(terms.len() * 3 + 1);
for t in &terms {
let pat = format!("%{}%", escape_like(t));
bind.push(Box::new(pat.clone()));
bind.push(Box::new(pat.clone()));
bind.push(Box::new(pat));
}
bind.push(Box::new(limit as i64));
let sql = format!(
"SELECT {NODE_COLUMNS} FROM nodes WHERE {where_clause} ORDER BY importance DESC LIMIT ?"
);
let refs: Vec<&dyn rusqlite::ToSql> = bind.iter().map(|b| b.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| KernelError::Store(e.to_string()))?;
let nodes: Vec<GraphNode> = stmt
.query_map(refs.as_slice(), row_to_node)
.map_err(|e| KernelError::Store(e.to_string()))?
.filter_map(|r| r.ok())
.collect();
Ok(nodes)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::schema::init_graph_schema;
use crate::graph::store::upsert_node;
fn mem_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_graph_schema(&conn).unwrap();
conn
}
fn node(id: &str, title: &str, body: &str) -> GraphNode {
GraphNode {
id: id.to_string(),
node_type: "concept".to_string(),
title: title.to_string(),
body: body.to_string(),
tags: vec![],
projects: vec![],
agents: vec![],
created: "2026-01-01T00:00:00Z".to_string(),
updated: "2026-01-01T00:00:00Z".to_string(),
importance: 0.7,
access_count: 0,
accessed_at: String::new(),
}
}
#[test]
fn segment_separates_cjk_chars() {
assert_eq!(segment_cjk("知識"), "知 識 ");
assert_eq!(segment_cjk("rust async"), "rust async");
let seg = segment_cjk("Rustでトークン");
assert!(seg.starts_with("Rustで "), "got: {seg:?}");
assert!(seg.contains("ト "), "got: {seg:?}");
}
#[test]
fn cjk_search_finds_cjk_node() {
let conn = mem_db();
upsert_node(
&conn,
&node("k1", "知識グラフの構築", "ナレッジベースをグラフ化する"),
)
.unwrap();
upsert_node(&conn, &node("k2", "Python GIL", "global interpreter lock")).unwrap();
let hits = search_nodes_cjk(&conn, "グラフ", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "k1");
}
#[test]
fn cjk_search_and_semantics() {
let conn = mem_db();
upsert_node(&conn, &node("k1", "知識グラフ", "説明本文")).unwrap();
let hits = search_nodes_cjk(&conn, "グラフ 存在", 10).unwrap();
assert!(hits.is_empty());
let hits = search_nodes_cjk(&conn, "知識 グラフ", 10).unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn empty_query_returns_nothing() {
let conn = mem_db();
upsert_node(&conn, &node("k1", "知識", "body")).unwrap();
assert!(search_nodes_cjk(&conn, " ", 10).unwrap().is_empty());
}
}