fond-store 1.0.0

SQLite persistence, migrations, and FTS5 search for fond recipe manager
use rusqlite::params;
use serde::Serialize;
use uuid::Uuid;

use crate::db::FondDb;
use crate::error::StoreError;

#[derive(Debug, Clone, Serialize)]
pub struct ImportReviewRecord {
    pub id: String,
    pub source_type: String,
    pub source_name: String,
    pub asset_path: String,
    pub title: String,
    pub draft_cook_text: String,
    pub ocr_text: String,
    pub warnings: Vec<String>,
    pub status: String,
    pub accepted_slug: Option<String>,
    pub accepted_file_path: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone)]
pub struct NewImportReview {
    pub source_type: String,
    pub source_name: String,
    pub asset_path: String,
    pub title: String,
    pub draft_cook_text: String,
    pub ocr_text: String,
    pub warnings: Vec<String>,
}

pub struct ImportReviewRepository<'a> {
    db: &'a FondDb,
}

impl<'a> ImportReviewRepository<'a> {
    pub fn new(db: &'a FondDb) -> Self {
        Self { db }
    }

    pub fn create(&self, draft: &NewImportReview) -> Result<ImportReviewRecord, StoreError> {
        let id = Uuid::now_v7().to_string();
        let warnings_json =
            serde_json::to_string(&draft.warnings).map_err(|e| StoreError::Database {
                message: format!("failed to serialize review warnings: {e}"),
            })?;

        self.db.conn().execute(
            "INSERT INTO import_review_queue (
                id, source_type, source_name, asset_path, title,
                draft_cook_text, ocr_text, warnings_json, status
             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending')",
            params![
                id,
                draft.source_type,
                draft.source_name,
                draft.asset_path,
                draft.title,
                draft.draft_cook_text,
                draft.ocr_text,
                warnings_json,
            ],
        )?;

        self.get(&id)?.ok_or_else(|| StoreError::Database {
            message: "created review record could not be reloaded".to_string(),
        })
    }

    pub fn list_pending(&self) -> Result<Vec<ImportReviewRecord>, StoreError> {
        let mut stmt = self.db.conn().prepare(
            "SELECT id, source_type, source_name, asset_path, title, draft_cook_text,
                    ocr_text, warnings_json, status, accepted_slug, accepted_file_path,
                    created_at, updated_at
             FROM import_review_queue
             WHERE status = 'pending'
             ORDER BY created_at ASC, id ASC",
        )?;

        let rows = stmt.query_map([], row_to_import_review)?;
        let mut records = Vec::new();
        for row in rows {
            records.push(row?);
        }
        Ok(records)
    }

    pub fn get(&self, id: &str) -> Result<Option<ImportReviewRecord>, StoreError> {
        let mut stmt = self.db.conn().prepare(
            "SELECT id, source_type, source_name, asset_path, title, draft_cook_text,
                    ocr_text, warnings_json, status, accepted_slug, accepted_file_path,
                    created_at, updated_at
             FROM import_review_queue
             WHERE id = ?1",
        )?;

        Ok(stmt.query_row(params![id], row_to_import_review).ok())
    }

    pub fn update_draft(
        &self,
        id: &str,
        title: &str,
        draft_cook_text: &str,
    ) -> Result<bool, StoreError> {
        let updated = self.db.conn().execute(
            "UPDATE import_review_queue
             SET title = ?2,
                 draft_cook_text = ?3,
                 updated_at = datetime('now')
             WHERE id = ?1 AND status = 'pending'",
            params![id, title, draft_cook_text],
        )?;

        Ok(updated > 0)
    }

    pub fn mark_accepted(
        &self,
        id: &str,
        accepted_slug: &str,
        accepted_file_path: &str,
    ) -> Result<bool, StoreError> {
        let updated = self.db.conn().execute(
            "UPDATE import_review_queue
             SET status = 'accepted',
                 accepted_slug = ?2,
                 accepted_file_path = ?3,
                 updated_at = datetime('now')
             WHERE id = ?1 AND status = 'pending'",
            params![id, accepted_slug, accepted_file_path],
        )?;

        Ok(updated > 0)
    }

    pub fn mark_rejected(&self, id: &str) -> Result<bool, StoreError> {
        let updated = self.db.conn().execute(
            "UPDATE import_review_queue
             SET status = 'rejected',
                 updated_at = datetime('now')
             WHERE id = ?1 AND status = 'pending'",
            params![id],
        )?;

        Ok(updated > 0)
    }
}

fn row_to_import_review(row: &rusqlite::Row<'_>) -> rusqlite::Result<ImportReviewRecord> {
    let warnings_json: String = row.get(7)?;
    let warnings = serde_json::from_str(&warnings_json).map_err(|error| {
        rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error))
    })?;
    let accepted_slug: String = row.get(9)?;
    let accepted_file_path: String = row.get(10)?;

    Ok(ImportReviewRecord {
        id: row.get(0)?,
        source_type: row.get(1)?,
        source_name: row.get(2)?,
        asset_path: row.get(3)?,
        title: row.get(4)?,
        draft_cook_text: row.get(5)?,
        ocr_text: row.get(6)?,
        warnings,
        status: row.get(8)?,
        accepted_slug: if accepted_slug.is_empty() {
            None
        } else {
            Some(accepted_slug)
        },
        accepted_file_path: if accepted_file_path.is_empty() {
            None
        } else {
            Some(accepted_file_path)
        },
        created_at: row.get(11)?,
        updated_at: row.get(12)?,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn create_and_load_pending_review() {
        let db = FondDb::open_memory().unwrap();
        let repo = ImportReviewRepository::new(&db);

        let created = repo
            .create(&NewImportReview {
                source_type: "ocr-photo".to_string(),
                source_name: "card.jpg".to_string(),
                asset_path: "photos/review/test.jpg".to_string(),
                title: "Grandma Soup".to_string(),
                draft_cook_text: "---\ntitle: Grandma Soup\n---\n".to_string(),
                ocr_text: "GRANDMA SOUP".to_string(),
                warnings: vec!["Needs review".to_string()],
            })
            .unwrap();

        assert_eq!(created.status, "pending");
        assert_eq!(created.title, "Grandma Soup");
        assert_eq!(created.warnings, vec!["Needs review"]);

        let pending = repo.list_pending().unwrap();
        assert_eq!(pending.len(), 1);
        assert_eq!(pending[0].id, created.id);
    }

    #[test]
    fn update_and_transition_review() {
        let db = FondDb::open_memory().unwrap();
        let repo = ImportReviewRepository::new(&db);

        let created = repo
            .create(&NewImportReview {
                source_type: "ocr-photo".to_string(),
                source_name: "card.jpg".to_string(),
                asset_path: "photos/review/test.jpg".to_string(),
                title: "Old Title".to_string(),
                draft_cook_text: "---\ntitle: Old Title\n---\n".to_string(),
                ocr_text: "OLD TITLE".to_string(),
                warnings: Vec::new(),
            })
            .unwrap();

        assert!(
            repo.update_draft(&created.id, "New Title", "---\ntitle: New Title\n---\n")
                .unwrap()
        );

        let updated = repo.get(&created.id).unwrap().unwrap();
        assert_eq!(updated.title, "New Title");

        assert!(
            repo.mark_accepted(&created.id, "new-title", "new-title.cook")
                .unwrap()
        );

        let accepted = repo.get(&created.id).unwrap().unwrap();
        assert_eq!(accepted.status, "accepted");
        assert_eq!(accepted.accepted_slug.as_deref(), Some("new-title"));
        assert_eq!(
            accepted.accepted_file_path.as_deref(),
            Some("new-title.cook")
        );
        assert!(repo.list_pending().unwrap().is_empty());
    }
}