nzbdav-core 0.4.0

Core models, database abstraction, and blob storage for nzbdav
use chrono::NaiveDateTime;
use rusqlite::{Connection, Row, params};
use uuid::Uuid;

use crate::error::Result;
use crate::models::{DownloadStatus, HistoryItem};

fn row_to_history_item(row: &Row) -> rusqlite::Result<HistoryItem> {
    let id: String = row.get("id")?;
    let created_at_str: String = row.get("created_at")?;
    let status_i: i32 = row.get("download_status")?;
    let download_dir_id: Option<String> = row.get("download_dir_id")?;
    let nzb_blob_id: Option<String> = row.get("nzb_blob_id")?;

    let parse_uuid = |s: String| -> rusqlite::Result<Uuid> {
        Uuid::parse_str(&s).map_err(|e| {
            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
        })
    };

    Ok(HistoryItem {
        id: parse_uuid(id)?,
        created_at: NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S").map_err(
            |e| {
                rusqlite::Error::FromSqlConversionFailure(
                    0,
                    rusqlite::types::Type::Text,
                    Box::new(e),
                )
            },
        )?,
        file_name: row.get("file_name")?,
        job_name: row.get("job_name")?,
        category: row.get("category")?,
        download_status: DownloadStatus::try_from(status_i).map_err(|e| {
            rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Integer, e.into())
        })?,
        total_segment_bytes: row.get("total_segment_bytes")?,
        download_time_seconds: row.get("download_time_seconds")?,
        fail_message: row.get("fail_message")?,
        download_dir_id: download_dir_id.map(parse_uuid).transpose()?,
        nzb_blob_id: nzb_blob_id.map(parse_uuid).transpose()?,
    })
}

const COLUMNS: &str = "id, created_at, file_name, job_name, category, download_status, total_segment_bytes, download_time_seconds, fail_message, download_dir_id, nzb_blob_id";

pub fn insert(conn: &Connection, item: &HistoryItem) -> Result<()> {
    conn.execute(
        &format!(
            "INSERT INTO history_items ({COLUMNS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"
        ),
        params![
            item.id.to_string(),
            item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
            item.file_name,
            item.job_name,
            item.category,
            item.download_status as i32,
            item.total_segment_bytes,
            item.download_time_seconds,
            item.fail_message,
            item.download_dir_id.map(|u| u.to_string()),
            item.nzb_blob_id.map(|u| u.to_string()),
        ],
    )?;
    Ok(())
}

pub fn get_by_id(conn: &Connection, id: Uuid) -> Result<Option<HistoryItem>> {
    let mut stmt = conn.prepare(&format!(
        "SELECT {COLUMNS} FROM history_items WHERE id = ?1"
    ))?;
    let mut rows = stmt.query_map(params![id.to_string()], row_to_history_item)?;
    match rows.next() {
        Some(row) => Ok(Some(row?)),
        None => Ok(None),
    }
}

pub fn list(conn: &Connection, offset: i64, limit: i64) -> Result<Vec<HistoryItem>> {
    let mut stmt = conn.prepare(&format!(
        "SELECT {COLUMNS} FROM history_items ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
    ))?;
    let rows = stmt.query_map(params![limit, offset], row_to_history_item)?;
    let mut items = Vec::new();
    for row in rows {
        items.push(row?);
    }
    Ok(items)
}

pub fn delete(conn: &Connection, id: Uuid) -> Result<()> {
    conn.execute(
        "DELETE FROM history_items WHERE id = ?1",
        params![id.to_string()],
    )?;
    Ok(())
}

pub fn delete_all(conn: &Connection) -> Result<()> {
    conn.execute("DELETE FROM history_items", [])?;
    Ok(())
}

pub fn count(conn: &Connection) -> Result<i64> {
    let c: i64 = conn.query_row("SELECT COUNT(*) FROM history_items", [], |row| row.get(0))?;
    Ok(c)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::open_in_memory;
    use chrono::Utc;

    fn make_history_item(status: DownloadStatus) -> HistoryItem {
        HistoryItem {
            id: Uuid::new_v4(),
            created_at: Utc::now().naive_utc(),
            file_name: "test.nzb".to_string(),
            job_name: "test".to_string(),
            category: "default".to_string(),
            download_status: status,
            total_segment_bytes: 10240,
            download_time_seconds: 60,
            fail_message: None,
            download_dir_id: None,
            nzb_blob_id: None,
        }
    }

    #[test]
    fn test_insert_and_get_by_id() {
        let conn = open_in_memory().unwrap();
        let item = make_history_item(DownloadStatus::Completed);
        insert(&conn, &item).unwrap();
        let fetched = get_by_id(&conn, item.id).unwrap().unwrap();
        assert_eq!(fetched.id, item.id);
        assert_eq!(fetched.download_status, DownloadStatus::Completed);
    }

    #[test]
    fn test_list_pagination() {
        let conn = open_in_memory().unwrap();
        for _ in 0..5 {
            insert(&conn, &make_history_item(DownloadStatus::Completed)).unwrap();
        }
        let page1 = list(&conn, 0, 2).unwrap();
        assert_eq!(page1.len(), 2);

        let page2 = list(&conn, 2, 2).unwrap();
        assert_eq!(page2.len(), 2);

        let page3 = list(&conn, 4, 2).unwrap();
        assert_eq!(page3.len(), 1);
    }

    #[test]
    fn test_delete() {
        let conn = open_in_memory().unwrap();
        let item = make_history_item(DownloadStatus::Failed);
        insert(&conn, &item).unwrap();
        delete(&conn, item.id).unwrap();
        assert!(get_by_id(&conn, item.id).unwrap().is_none());
    }

    #[test]
    fn test_count() {
        let conn = open_in_memory().unwrap();
        assert_eq!(count(&conn).unwrap(), 0);
        insert(&conn, &make_history_item(DownloadStatus::Completed)).unwrap();
        insert(&conn, &make_history_item(DownloadStatus::Failed)).unwrap();
        assert_eq!(count(&conn).unwrap(), 2);
    }

    #[test]
    fn test_failed_with_message() {
        let conn = open_in_memory().unwrap();
        let mut item = make_history_item(DownloadStatus::Failed);
        item.fail_message = Some("disk full".to_string());
        insert(&conn, &item).unwrap();
        let fetched = get_by_id(&conn, item.id).unwrap().unwrap();
        assert_eq!(fetched.fail_message.as_deref(), Some("disk full"));
    }
}