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)]

// 프로젝트 레지스트리 (Devist 가 관리, Asurada 가 sync).
// PRIMARY KEY (user_id, name).

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

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
    pub user_id: String,
    pub name: String,
    pub path: String,
    pub metadata: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
    pub synced_at: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectInput {
    pub user_id: String,
    pub name: String,
    pub path: String,
    #[serde(default)]
    pub metadata: Option<serde_json::Value>,
}

pub fn upsert(conn: &Connection, input: ProjectInput) -> Result<Project> {
    let now = Utc::now().to_rfc3339();
    let meta_json = input
        .metadata
        .map(|m| m.to_string())
        .unwrap_or_else(|| "{}".into());

    conn.execute(
        r#"INSERT INTO projects (user_id, name, path, metadata, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, ?5)
           ON CONFLICT(user_id, name) DO UPDATE SET
               path = excluded.path,
               metadata = excluded.metadata,
               updated_at = excluded.updated_at,
               synced_at = NULL"#,
        params![input.user_id, input.name, input.path, meta_json, now],
    )
    .context("upsert project")?;

    get(conn, &input.user_id, &input.name)?.context("project missing after upsert")
}

pub fn get(conn: &Connection, user_id: &str, name: &str) -> Result<Option<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE user_id = ?1 AND name = ?2"#,
    )?;
    Ok(stmt.query_row(params![user_id, name], row_to_project).ok())
}

/// cwd 경로 → canonical project name. 우선순위:
///   1. projects.path 정확히 일치 (가장 안전)
///   2. cwd 가 등록 path 의 하위 디렉토리 — 가장 긴 prefix 가 이김
///      (`Devist/website` 같은 sub-dir 도 부모 프로젝트로 roll-up)
///   3. basename 이 LOWER(name) 와 일치 (대소문자 무시)
///   4. None — 호출자가 fallback
pub fn resolve_canonical_name(
    conn: &Connection,
    user_id: &str,
    cwd: &str,
) -> Result<Option<String>> {
    // 1) path 정확 일치
    let mut stmt =
        conn.prepare("SELECT name FROM projects WHERE user_id = ?1 AND path = ?2 LIMIT 1")?;
    if let Ok(name) = stmt.query_row(params![user_id, cwd], |r| r.get::<_, String>(0)) {
        return Ok(Some(name));
    }

    // 2) prefix 매칭 — cwd 가 등록된 path 의 하위 디렉토리. 가장 긴 path 우선
    //    ("/" 추가해 path-of-path 만 매칭, "/Foo/Barbaz" 가 "/Foo/Bar" 로 잘못
    //    잡히는 일 방지).
    let cwd_with_slash = format!("{}/", cwd.trim_end_matches('/'));
    let mut stmt = conn.prepare(
        "SELECT name FROM projects
         WHERE user_id = ?1
           AND ?2 LIKE path || '/%'
         ORDER BY length(path) DESC
         LIMIT 1",
    )?;
    if let Ok(name) = stmt.query_row(params![user_id, cwd_with_slash], |r| r.get::<_, String>(0)) {
        return Ok(Some(name));
    }

    // 3) basename + case-insensitive name 매칭
    let basename = match std::path::Path::new(cwd)
        .file_name()
        .and_then(|s| s.to_str())
    {
        Some(b) => b,
        None => return Ok(None),
    };
    let mut stmt = conn.prepare(
        "SELECT name FROM projects WHERE user_id = ?1 AND LOWER(name) = LOWER(?2) LIMIT 1",
    )?;
    Ok(stmt
        .query_row(params![user_id, basename], |r| r.get::<_, String>(0))
        .ok())
}

pub fn list(conn: &Connection, user_id: &str) -> Result<Vec<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE user_id = ?1
           ORDER BY name ASC"#,
    )?;
    let rows: Vec<Project> = stmt
        .query_map(params![user_id], row_to_project)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn list_unsynced(conn: &Connection, limit: usize) -> Result<Vec<Project>> {
    let mut stmt = conn.prepare(
        r#"SELECT user_id, name, path, metadata, created_at, updated_at, synced_at
           FROM projects
           WHERE synced_at IS NULL OR updated_at > synced_at
           ORDER BY updated_at ASC
           LIMIT ?1"#,
    )?;
    let rows: Vec<Project> = stmt
        .query_map(params![limit as i64], row_to_project)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

pub fn mark_synced(
    conn: &Connection,
    keys: &[(String, String)], // (user_id, name)
    when: &str,
) -> Result<()> {
    let tx = conn.unchecked_transaction()?;
    for (user_id, name) in keys {
        tx.execute(
            "UPDATE projects SET synced_at = ?1 WHERE user_id = ?2 AND name = ?3",
            params![when, user_id, name],
        )?;
    }
    tx.commit()?;
    Ok(())
}

fn row_to_project(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
    let meta_str: String = row.get(3)?;
    let metadata = serde_json::from_str(&meta_str).unwrap_or_else(|_| serde_json::json!({}));
    Ok(Project {
        user_id: row.get(0)?,
        name: row.get(1)?,
        path: row.get(2)?,
        metadata,
        created_at: row.get(4)?,
        updated_at: row.get(5)?,
        synced_at: row.get(6)?,
    })
}