asurada 0.3.0

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

//! Intent — 사용자 개인성의 의도 표현.
//!
//! 규칙은 "현재 Claude Code 형식"이 아니라 의도(intent) 로 저장한다.
//! 외부(Claude Code) 형식이 바뀌어도 RuntimeAdapter 만 갈아끼우면
//! 의도 자체는 보존된다.

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

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Strength {
    /// 선호 — 메모리 주입 (UserPromptSubmit hook 컨텍스트 prepend)
    Preference,
    /// 원칙 — Hook 차단 (PreToolUse hook deny/ask)
    Principle,
    /// 맥락 — 파일 생성 (CLAUDE.md / .claude/agents)
    Context,
}

impl Strength {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Preference => "preference",
            Self::Principle => "principle",
            Self::Context => "context",
        }
    }
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "preference" | "pref" => Some(Self::Preference),
            "principle" | "prin" => Some(Self::Principle),
            "context" | "ctx" => Some(Self::Context),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
    User,
    AdvicePromotion,
    Inferred,
}

impl Source {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::User => "user",
            Self::AdvicePromotion => "advice_promotion",
            Self::Inferred => "inferred",
        }
    }
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "user" => Some(Self::User),
            "advice_promotion" => Some(Self::AdvicePromotion),
            "inferred" => Some(Self::Inferred),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    Active,
    Archived,
}

impl Status {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Active => "active",
            Self::Archived => "archived",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Intent {
    pub id: String,
    pub user_id: String,
    /// None = 전역 (모든 프로젝트에 적용)
    pub project: Option<String>,
    pub strength: Strength,
    pub intent_text: String,
    pub source: Source,
    pub source_signal_ids: Vec<String>,
    pub status: Status,
    pub metadata: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
    pub synced_at: Option<String>,
}

#[derive(Debug, Clone)]
pub struct IntentInput {
    pub user_id: String,
    pub project: Option<String>,
    pub strength: Strength,
    pub intent_text: String,
    pub source: Source,
    pub source_signal_ids: Vec<String>,
    pub metadata: serde_json::Value,
}

pub fn insert(conn: &Connection, input: IntentInput) -> Result<Intent> {
    let id = super::uuid_like();
    let now = Utc::now().to_rfc3339();
    conn.execute(
        r#"INSERT INTO intents
           (id, user_id, project, strength, intent_text, source,
            source_signal_ids, status, metadata, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'active', ?8, ?9, ?9)"#,
        params![
            id,
            input.user_id,
            input.project,
            input.strength.as_str(),
            input.intent_text,
            input.source.as_str(),
            serde_json::to_string(&input.source_signal_ids)?,
            input.metadata.to_string(),
            now,
        ],
    )
    .context("insert intent")?;
    get(conn, &input.user_id, &id)?.context("intent missing after insert")
}

pub fn get(conn: &Connection, user_id: &str, id: &str) -> Result<Option<Intent>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, project, strength, intent_text, source,
                  source_signal_ids, status, metadata, created_at, updated_at, synced_at
           FROM intents
           WHERE user_id = ?1 AND id = ?2"#,
    )?;
    Ok(stmt.query_row(params![user_id, id], row_to_intent).ok())
}

/// 활성 intent 목록. project 가 Some 이면 해당 project 또는 전역(NULL) 중 하나.
pub fn list_active(
    conn: &Connection,
    user_id: &str,
    project: Option<&str>,
    strength: Option<Strength>,
) -> Result<Vec<Intent>> {
    let mut sql = String::from(
        "SELECT id, user_id, project, strength, intent_text, source, \
         source_signal_ids, status, metadata, created_at, updated_at, synced_at \
         FROM intents WHERE user_id = ?1 AND status = 'active'",
    );
    let mut binds: Vec<rusqlite::types::Value> = vec![user_id.to_string().into()];

    if let Some(p) = project {
        sql.push_str(" AND (project IS NULL OR project = ?)");
        binds.push(p.to_string().into());
    }
    if let Some(s) = strength {
        sql.push_str(" AND strength = ?");
        binds.push(s.as_str().to_string().into());
    }
    sql.push_str(" ORDER BY created_at DESC");

    let mut stmt = conn.prepare(&sql)?;
    let rows: Vec<Intent> = stmt
        .query_map(rusqlite::params_from_iter(binds.iter()), row_to_intent)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn list_all(conn: &Connection, user_id: &str) -> Result<Vec<Intent>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, project, strength, intent_text, source,
                  source_signal_ids, status, metadata, created_at, updated_at, synced_at
           FROM intents WHERE user_id = ?1 ORDER BY created_at DESC"#,
    )?;
    let rows: Vec<Intent> = stmt
        .query_map(params![user_id], row_to_intent)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn set_status(conn: &Connection, id: &str, status: Status) -> Result<()> {
    let now = Utc::now().to_rfc3339();
    conn.execute(
        "UPDATE intents SET status = ?1, updated_at = ?2, synced_at = NULL WHERE id = ?3",
        params![status.as_str(), now, id],
    )?;
    Ok(())
}

fn row_to_intent(row: &rusqlite::Row<'_>) -> rusqlite::Result<Intent> {
    let strength_str: String = row.get(3)?;
    let source_str: String = row.get(5)?;
    let source_signal_ids_str: String = row.get(6)?;
    let status_str: String = row.get(7)?;
    let metadata_str: String = row.get(8)?;
    Ok(Intent {
        id: row.get(0)?,
        user_id: row.get(1)?,
        project: row.get(2)?,
        strength: Strength::parse(&strength_str).unwrap_or(Strength::Preference),
        intent_text: row.get(4)?,
        source: Source::parse(&source_str).unwrap_or(Source::User),
        source_signal_ids: serde_json::from_str(&source_signal_ids_str).unwrap_or_default(),
        status: match status_str.as_str() {
            "archived" => Status::Archived,
            _ => Status::Active,
        },
        metadata: serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({})),
        created_at: row.get(9)?,
        updated_at: row.get(10)?,
        synced_at: row.get(11)?,
    })
}