asurada 0.3.1

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
#![allow(dead_code)]

// memories CRUD + FTS5 검색.

use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
    pub id: String,
    pub user_id: String,
    pub text: String,
    pub scope: String,    // user | project | tech
    pub priority: String, // constraint | strong | preference | info
    pub source: String,   // user | asurada | imported
    pub project: Option<String>,
    pub tech: Option<String>,
    pub metadata: serde_json::Value,
    pub status: String, // active | proposed | deleted
    pub created_at: String,
    pub updated_at: String,
    pub deleted_at: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct MemoryInput {
    pub user_id: String,
    pub text: String,
    pub scope: String,
    pub priority: String,
    #[serde(default = "default_source")]
    pub source: String,
    #[serde(default)]
    pub project: Option<String>,
    #[serde(default)]
    pub tech: Option<String>,
    #[serde(default = "default_metadata")]
    pub metadata: serde_json::Value,
}

fn default_source() -> String {
    "asurada".into()
}
fn default_metadata() -> serde_json::Value {
    serde_json::json!({})
}

pub fn insert(conn: &Connection, input: MemoryInput) -> Result<Memory> {
    let id = super::uuid_like();
    let now = Utc::now().to_rfc3339();
    conn.execute(
        r#"INSERT INTO memories
           (id, user_id, text, scope, priority, source, project, tech, metadata, status, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'active', ?10, ?10)"#,
        params![
            id,
            input.user_id,
            input.text,
            input.scope,
            input.priority,
            input.source,
            input.project,
            input.tech,
            input.metadata.to_string(),
            now,
        ],
    )
    .context("insert memory")?;
    get(conn, &input.user_id, &id)?.context("memory disappeared after insert")
}

pub fn get(conn: &Connection, user_id: &str, id: &str) -> Result<Option<Memory>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, text, scope, priority, source, project, tech, metadata,
                  status, created_at, updated_at, deleted_at
           FROM memories
           WHERE user_id = ?1 AND id = ?2 AND status != 'deleted'"#,
    )?;
    let row = stmt.query_row(params![user_id, id], row_to_memory).ok();
    Ok(row)
}

pub fn list(
    conn: &Connection,
    user_id: &str,
    scope: Option<&str>,
    limit: usize,
) -> Result<Vec<Memory>> {
    let mut sql = String::from(
        r#"SELECT id, user_id, text, scope, priority, source, project, tech, metadata,
                  status, created_at, updated_at, deleted_at
           FROM memories
           WHERE user_id = ?1 AND status = 'active'"#,
    );
    if scope.is_some() {
        sql.push_str(" AND scope = ?2");
    }
    sql.push_str(" ORDER BY updated_at DESC LIMIT ?_LIM_");
    let sql = sql.replace("?_LIM_", if scope.is_some() { "?3" } else { "?2" });

    let mut stmt = conn.prepare(&sql)?;
    let rows: Vec<Memory> = if let Some(s) = scope {
        stmt.query_map(params![user_id, s, limit as i64], row_to_memory)?
            .filter_map(|r| r.ok())
            .collect()
    } else {
        stmt.query_map(params![user_id, limit as i64], row_to_memory)?
            .filter_map(|r| r.ok())
            .collect()
    };
    Ok(rows)
}

/// FTS5 키워드 검색. Phase 1: 시맨틱 검색 없이 텍스트 매칭만.
/// `query`는 FTS5 문법 그대로 (간단한 단어 검색은 그대로 통과).
pub fn search(conn: &Connection, user_id: &str, query: &str, limit: usize) -> Result<Vec<Memory>> {
    let mut stmt = conn.prepare(
        r#"SELECT m.id, m.user_id, m.text, m.scope, m.priority, m.source, m.project, m.tech,
                  m.metadata, m.status, m.created_at, m.updated_at, m.deleted_at
           FROM memories m
           JOIN memories_fts f ON f.rowid = m.rowid
           WHERE f.text MATCH ?1
             AND m.user_id = ?2
             AND m.status = 'active'
           ORDER BY rank
           LIMIT ?3"#,
    )?;
    let rows: Vec<Memory> = stmt
        .query_map(
            params![sanitize_fts(query), user_id, limit as i64],
            row_to_memory,
        )?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn soft_delete(conn: &Connection, user_id: &str, id: &str) -> Result<bool> {
    let now = Utc::now().to_rfc3339();
    let n = conn.execute(
        r#"UPDATE memories SET status = 'deleted', deleted_at = ?1, updated_at = ?1
           WHERE user_id = ?2 AND id = ?3 AND status != 'deleted'"#,
        params![now, user_id, id],
    )?;
    Ok(n > 0)
}

fn row_to_memory(row: &rusqlite::Row<'_>) -> rusqlite::Result<Memory> {
    let metadata_str: String = row.get(8)?;
    let metadata = serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({}));
    Ok(Memory {
        id: row.get(0)?,
        user_id: row.get(1)?,
        text: row.get(2)?,
        scope: row.get(3)?,
        priority: row.get(4)?,
        source: row.get(5)?,
        project: row.get(6)?,
        tech: row.get(7)?,
        metadata,
        status: row.get(9)?,
        created_at: row.get(10)?,
        updated_at: row.get(11)?,
        deleted_at: row.get(12)?,
    })
}

/// FTS5 특수문자 escape — 단순 사용자 쿼리를 토큰 매칭으로.
fn sanitize_fts(q: &str) -> String {
    // 따옴표/하이픈/괄호 등 FTS5 metachar 들어가면 syntax error 되므로
    // 안전하게 단어들만 추출하여 OR 검색.
    let words: Vec<String> = q
        .split_whitespace()
        .map(|w| {
            w.chars()
                .filter(|c| c.is_alphanumeric() || *c == '_')
                .collect::<String>()
        })
        .filter(|w| !w.is_empty())
        .collect();
    if words.is_empty() {
        return "\"\"".into();
    }
    words.join(" OR ")
}