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

//! Issue — 작업 세션 단위 응결.
//!
//! brief/reflection 이 시간 윈도우 자동 회고라면, issue 는 *한 호흡의 작업 단위*.
//! 사용자가 capture 시점에 명시적으로 묶거나, Asurada 가 큰 작업 끝맺음 감지 시 제안.

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

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
    pub id: String,
    pub user_id: String,
    pub title: String,
    pub summary: String,
    pub projects: Vec<String>,
    pub status: String,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub event_count: i64,
    pub metadata: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
    pub synced_at: Option<String>,
}

#[derive(Debug, Clone)]
pub struct IssueInput {
    pub user_id: String,
    pub title: String,
    pub summary: String,
    pub projects: Vec<String>,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub event_count: i64,
    pub metadata: serde_json::Value,
}

pub fn insert(conn: &Connection, input: IssueInput) -> Result<Issue> {
    let id = super::uuid_like();
    let now = Utc::now().to_rfc3339();
    conn.execute(
        r#"INSERT INTO issues
           (id, user_id, title, summary, projects, status, started_at, ended_at,
            event_count, metadata, created_at, updated_at)
           VALUES (?1, ?2, ?3, ?4, ?5, 'completed', ?6, ?7, ?8, ?9, ?10, ?10)"#,
        params![
            id,
            input.user_id,
            input.title,
            input.summary,
            serde_json::to_string(&input.projects)?,
            input.started_at,
            input.ended_at,
            input.event_count,
            input.metadata.to_string(),
            now,
        ],
    )
    .context("insert issue")?;
    get(conn, &input.user_id, &id)?.context("issue missing after insert")
}

pub fn get(conn: &Connection, user_id: &str, id: &str) -> Result<Option<Issue>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, title, summary, projects, status,
                  started_at, ended_at, event_count, metadata,
                  created_at, updated_at, synced_at
           FROM issues
           WHERE user_id = ?1 AND id = ?2"#,
    )?;
    Ok(stmt.query_row(params![user_id, id], row_to_issue).ok())
}

pub fn list(conn: &Connection, user_id: &str, limit: usize) -> Result<Vec<Issue>> {
    let mut stmt = conn.prepare(
        r#"SELECT id, user_id, title, summary, projects, status,
                  started_at, ended_at, event_count, metadata,
                  created_at, updated_at, synced_at
           FROM issues
           WHERE user_id = ?1
           ORDER BY started_at DESC LIMIT ?2"#,
    )?;
    let rows: Vec<Issue> = stmt
        .query_map(params![user_id, limit as i64], row_to_issue)?
        .filter_map(|r| r.ok())
        .collect();
    Ok(rows)
}

/// 마지막 issue 의 ended_at — 다음 capture 시작점 결정용.
pub fn last_ended_at(conn: &Connection, user_id: &str) -> Result<Option<String>> {
    let mut stmt = conn.prepare(
        r#"SELECT COALESCE(ended_at, started_at) FROM issues
           WHERE user_id = ?1
           ORDER BY started_at DESC LIMIT 1"#,
    )?;
    Ok(stmt.query_row(params![user_id], |r| r.get(0)).ok())
}

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

pub fn mark_synced(conn: &Connection, ids: &[&str], when: &str) -> Result<()> {
    if ids.is_empty() {
        return Ok(());
    }
    let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
    let sql = format!(
        "UPDATE issues SET synced_at = ? WHERE id IN ({})",
        placeholders
    );
    let mut stmt = conn.prepare(&sql)?;
    let mut binds: Vec<rusqlite::types::Value> = Vec::with_capacity(ids.len() + 1);
    binds.push(when.to_string().into());
    for id in ids {
        binds.push((*id).to_string().into());
    }
    stmt.execute(rusqlite::params_from_iter(binds.iter()))?;
    Ok(())
}

fn row_to_issue(row: &rusqlite::Row<'_>) -> rusqlite::Result<Issue> {
    let projects_str: String = row.get(4)?;
    let metadata_str: String = row.get(9)?;
    Ok(Issue {
        id: row.get(0)?,
        user_id: row.get(1)?,
        title: row.get(2)?,
        summary: row.get(3)?,
        projects: serde_json::from_str(&projects_str).unwrap_or_default(),
        status: row.get(5)?,
        started_at: row.get(6)?,
        ended_at: row.get(7)?,
        event_count: row.get(8)?,
        metadata: serde_json::from_str(&metadata_str).unwrap_or_else(|_| serde_json::json!({})),
        created_at: row.get(10)?,
        updated_at: row.get(11)?,
        synced_at: row.get(12)?,
    })
}