tano-native 0.10.0

Tano local filesystem, search and desktop host core
Documentation
use rusqlite::{params, Connection, OptionalExtension};
use serde_json::{json, Value};
use std::{path::Path, time::Duration};
use tauri::State;

fn database(path: &Path) -> Result<Connection, String> {
    std::fs::create_dir_all(path.parent().ok_or("机器人数据路径无效")?)
        .map_err(|_| "无法创建机器人数据目录")?;
    let db = Connection::open(path).map_err(|_| "无法打开机器人收件箱")?;
    db.busy_timeout(Duration::from_secs(5))
        .map_err(|_| "无法配置机器人收件箱")?;
    db.execute_batch("CREATE TABLE IF NOT EXISTS inbox (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, data TEXT NOT NULL);")
        .map_err(|_| "无法初始化机器人收件箱")?;
    Ok(db)
}

fn validate(inbox: &Value, workspace_id: &str) -> Result<u64, String> {
    if inbox.get("version").and_then(Value::as_u64) != Some(1)
        || inbox.get("workspaceId").and_then(Value::as_str) != Some(workspace_id)
        || inbox
            .get("bindings")
            .and_then(Value::as_array)
            .is_none_or(|v| v.len() > 1000)
        || inbox
            .get("tasks")
            .and_then(Value::as_array)
            .is_none_or(|v| v.len() > 10000)
        || serde_json::to_vec(inbox)
            .map_err(|_| "机器人收件箱无效")?
            .len()
            > 32_000_000
    {
        return Err("机器人收件箱身份或大小无效".into());
    }
    inbox
        .get("revision")
        .and_then(Value::as_u64)
        .filter(|revision| *revision <= 9_007_199_254_740_991)
        .ok_or_else(|| "机器人收件箱版本无效".into())
}

fn load(db: &Connection, workspace_id: &str) -> Result<Value, String> {
    let row: Option<(u64, String)> = db
        .query_row("SELECT revision,data FROM inbox WHERE id=1", [], |row| {
            Ok((row.get(0)?, row.get(1)?))
        })
        .optional()
        .map_err(|_| "无法读取机器人收件箱")?;
    let Some((revision, data)) = row else {
        return Ok(
            json!({"version":1,"workspaceId":workspace_id,"revision":0,"bindings":[],"tasks":[]}),
        );
    };
    let value = serde_json::from_str(&data).map_err(|_| "机器人收件箱损坏,原记录已保留")?;
    if validate(&value, workspace_id)? != revision {
        return Err("机器人收件箱版本损坏,原记录已保留".into());
    }
    Ok(value)
}

fn save(db: &Connection, inbox: &Value, workspace_id: &str, expected: u64) -> Result<bool, String> {
    let revision = validate(inbox, workspace_id)?;
    if expected.checked_add(1) != Some(revision) {
        return Err("机器人收件箱版本无效".into());
    }
    let data = serde_json::to_string(inbox).map_err(|_| "机器人收件箱无效")?;
    let changed = if expected == 0 {
        db.execute(
            "INSERT OR IGNORE INTO inbox(id,revision,data) VALUES(1,?1,?2)",
            params![revision, data],
        )
    } else {
        db.execute(
            "UPDATE inbox SET revision=?1,data=?2 WHERE id=1 AND revision=?3",
            params![revision, data, expected],
        )
    }
    .map_err(|_| "无法保存机器人收件箱")?;
    Ok(changed == 1)
}

#[tauri::command(async)]
pub fn assistant_inbox_load(
    app: tauri::AppHandle,
    state: State<crate::AppState>,
    workspace_id: String,
) -> Result<Value, String> {
    let directory = super::session_directory(&app, &workspace_id, state)?;
    load(&database(&directory.join("inbox.sqlite"))?, &workspace_id)
}

#[tauri::command(async)]
pub fn assistant_inbox_save(
    app: tauri::AppHandle,
    state: State<crate::AppState>,
    inbox: Value,
    expected_revision: u64,
) -> Result<bool, String> {
    let workspace_id = inbox
        .get("workspaceId")
        .and_then(Value::as_str)
        .ok_or("工作区身份无效")?;
    let directory = super::session_directory(&app, workspace_id, state)?;
    save(
        &database(&directory.join("inbox.sqlite"))?,
        &inbox,
        workspace_id,
        expected_revision,
    )
}

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

    #[test]
    fn inbox_compare_exchange_survives_reopen_and_rejects_stale_writers() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("inbox.sqlite");
        let first = database(&path).unwrap();
        let second = database(&path).unwrap();
        let mut value = load(&first, "workspace").unwrap();
        value["revision"] = json!(1);
        assert!(save(&first, &value, "workspace", 0).unwrap());
        assert!(!save(&second, &value, "workspace", 0).unwrap());
        value["revision"] = json!(2);
        assert!(save(&second, &value, "workspace", 1).unwrap());
        assert!(!save(&first, &value, "workspace", 1).unwrap());
        drop(first);
        drop(second);
        assert_eq!(
            load(&database(&path).unwrap(), "workspace").unwrap()["revision"],
            2
        );
        assert!(load(&database(&path).unwrap(), "other").is_err());
    }
}