asurada 0.1.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
// HTTP API: localhost only, axum.
//
// Phase 1 엔드포인트:
//   POST   /memories          — 메모리 생성
//   GET    /memories          — 목록 (?user_id=&scope=&limit=)
//   GET    /memories/:id      — 단건 (?user_id=)
//   DELETE /memories/:id      — soft delete (?user_id=)
//   GET    /search            — FTS5 키워드 검색 (?user_id=&q=&limit=)
//   GET    /healthz           — 헬스체크

use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use rusqlite::Connection;
use serde::Deserialize;
use serde_json::json;
use std::sync::{Arc, Mutex};

use crate::db::{self, MemoryInput};

#[derive(Clone)]
pub struct AppState {
    pub conn: Arc<Mutex<Connection>>,
}

impl AppState {
    pub fn new(conn: Connection) -> Self {
        Self {
            conn: Arc::new(Mutex::new(conn)),
        }
    }
}

pub fn router(state: AppState) -> Router {
    Router::new()
        .route("/healthz", get(healthz))
        .route("/memories", post(create_memory).get(list_memories))
        .route("/memories/:id", get(get_memory).delete(delete_memory))
        .route("/search", get(search_memories))
        .with_state(state)
}

async fn healthz() -> impl IntoResponse {
    Json(json!({
        "status": "ok",
        "service": "asurada",
        "version": env!("CARGO_PKG_VERSION"),
    }))
}

async fn create_memory(
    State(state): State<AppState>,
    Json(input): Json<MemoryInput>,
) -> Result<impl IntoResponse, ApiError> {
    let conn = state.conn.lock().map_err(|e| ApiError::internal(e.to_string()))?;
    let m = db::memory::insert(&conn, input).map_err(ApiError::from)?;
    Ok((StatusCode::CREATED, Json(m)))
}

#[derive(Deserialize)]
struct ListQ {
    user_id: String,
    #[serde(default)]
    scope: Option<String>,
    #[serde(default = "default_limit")]
    limit: usize,
}

async fn list_memories(
    State(state): State<AppState>,
    Query(q): Query<ListQ>,
) -> Result<impl IntoResponse, ApiError> {
    let conn = state.conn.lock().map_err(|e| ApiError::internal(e.to_string()))?;
    let rows = db::memory::list(&conn, &q.user_id, q.scope.as_deref(), q.limit)
        .map_err(ApiError::from)?;
    Ok(Json(rows))
}

#[derive(Deserialize)]
struct GetQ {
    user_id: String,
}

async fn get_memory(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
    let conn = state.conn.lock().map_err(|e| ApiError::internal(e.to_string()))?;
    let row = db::memory::get(&conn, &q.user_id, &id).map_err(ApiError::from)?;
    match row {
        Some(m) => Ok(Json(m)),
        None => Err(ApiError {
            status: StatusCode::NOT_FOUND,
            message: "memory not found".into(),
        }),
    }
}

async fn delete_memory(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Query(q): Query<GetQ>,
) -> Result<impl IntoResponse, ApiError> {
    let conn = state.conn.lock().map_err(|e| ApiError::internal(e.to_string()))?;
    let ok = db::memory::soft_delete(&conn, &q.user_id, &id).map_err(ApiError::from)?;
    if ok {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(ApiError {
            status: StatusCode::NOT_FOUND,
            message: "memory not found".into(),
        })
    }
}

#[derive(Deserialize)]
struct SearchQ {
    user_id: String,
    q: String,
    #[serde(default = "default_limit")]
    limit: usize,
}

async fn search_memories(
    State(state): State<AppState>,
    Query(q): Query<SearchQ>,
) -> Result<impl IntoResponse, ApiError> {
    let conn = state.conn.lock().map_err(|e| ApiError::internal(e.to_string()))?;
    let rows = db::memory::search(&conn, &q.user_id, &q.q, q.limit).map_err(ApiError::from)?;
    Ok(Json(rows))
}

fn default_limit() -> usize {
    20
}

// 단순 에러 매핑.
struct ApiError {
    status: StatusCode,
    message: String,
}

impl ApiError {
    fn internal(msg: impl Into<String>) -> Self {
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: msg.into(),
        }
    }
}

impl From<anyhow::Error> for ApiError {
    fn from(e: anyhow::Error) -> Self {
        Self::internal(e.to_string())
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        (self.status, Json(json!({ "error": self.message }))).into_response()
    }
}