refactor(ffi): 降级 FFI 为可选兼容层并整理文档

将 bat-ffi 明确收敛为无状态 C ABI 兼容层,默认集成路径改为 bat --json 进程边界或未来稳定 SDK。

同步 README、当前状态、项目计划、架构文档、开发指南和缺口清单,移除 FFI 作为主集成边界的表述。

新增 AGENTS.md 和 CONTRIBUTING.md,压缩 CLAUDE.md 为兼容入口,并归档阶段性报告、同步 .gitignore 规则。
This commit is contained in:
2026-07-15 21:12:17 +08:00
parent 90307a3243
commit b4b4f25cb3
22 changed files with 420 additions and 772 deletions
+118 -40
View File
@@ -1,8 +1,9 @@
//! # BAT FFI
//! # BAT FFI compatibility layer
//!
//! Go 和 Rust 之间的 FFI 绑定层
//!
//! 导出 C ABI 接口供 Go 通过 CGO 调用
//! Optional C ABI compatibility layer for coarse-grained JSON helpers.
//! Production orchestration should prefer the `bat --json` process boundary or a
//! future stable SDK. This crate intentionally stays stateless: it must not own
//! downloader state, daemon lifecycle, CAS handles, or long-lived resources.
#![warn(clippy::all)]
@@ -17,7 +18,7 @@ use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
/// FFI 版本号
/// FFI compatibility API version.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Sync snapshot JSON 输入。
@@ -111,13 +112,13 @@ struct SyncPlanView {
delta: SyncDeltaView,
}
/// 获取版本号(C ABI
/// Returns the compatibility API version as an owned C string.
#[no_mangle]
pub extern "C" fn bat_version() -> *const c_char {
c_string(VERSION).into_raw()
}
/// 释放 C 字符串内存
/// Frees C strings allocated by this crate.
///
/// # Safety
/// 调用者必须确保:
@@ -130,7 +131,7 @@ pub unsafe extern "C" fn bat_free_string(s: *mut c_char) {
}
}
/// 解析 Addressables Manifest,并返回 JSON summary
/// Parses an Addressables manifest and returns a stateless JSON summary.
#[no_mangle]
pub extern "C" fn bat_manifest_inspect_json(raw_json: *const c_char) -> *mut c_char {
let result = (|| -> Result<ManifestInspectView, String> {
@@ -170,7 +171,7 @@ pub extern "C" fn bat_manifest_inspect_json(raw_json: *const c_char) -> *mut c_c
json_result(result)
}
/// 构建官方同步计划 JSON summary。
/// Builds an official sync plan JSON summary without persisting state.
#[no_mangle]
pub extern "C" fn bat_sync_plan_json(
current_json: *const c_char,
@@ -368,16 +369,31 @@ mod tests {
value
}
#[test]
fn test_ffi_version() {
let version_ptr = bat_version();
let version = take_string(version_ptr);
assert!(!version.is_empty());
fn parse_json_response(raw: String) -> serde_json::Value {
serde_json::from_str(&raw).unwrap()
}
#[test]
fn test_manifest_inspect_json() {
let catalog_json = r#"{
fn inspect_manifest(raw_json: &str) -> serde_json::Value {
let raw_json = CString::new(raw_json).unwrap();
parse_json_response(take_string(bat_manifest_inspect_json(raw_json.as_ptr())))
}
fn sync_plan(current_json: &str, previous_json: Option<&str>) -> serde_json::Value {
let current_json = CString::new(current_json).unwrap();
let previous_json = previous_json.map(|value| CString::new(value).unwrap());
let previous_ptr = previous_json
.as_ref()
.map(|value| value.as_ptr())
.unwrap_or(std::ptr::null());
parse_json_response(take_string(bat_sync_plan_json(
current_json.as_ptr(),
previous_ptr,
)))
}
fn catalog_json() -> &'static str {
r#"{
"m_LocatorId": "AddressablesMainContentCatalog",
"m_InternalIds": [
"synthetic/minimal.bundle"
@@ -391,34 +407,96 @@ mod tests {
"dependencies": ["shared.bundle"]
}
]
}"#;
}"#
}
let result_ptr = bat_manifest_inspect_json(CString::new(catalog_json).unwrap().as_ptr());
let result = take_string(result_ptr);
assert!(result.contains(r#""ok":true"#));
assert!(result.contains(r#""resource_count":1"#));
fn sync_snapshot_json(bundle_version: &str) -> String {
format!(
r#"{{
"connection_group_name":"Prod-Audit",
"app_version":"1.70.0",
"bundle_version":"{bundle_version}",
"addressables_root":"https://prod-clientpatch.bluearchiveyostar.com/r93_token",
"endpoints":[
{{
"kind":"TableCatalog",
"platform":null,
"url":"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
}}
]
}}"#
)
}
#[test]
fn test_ffi_version() {
let version_ptr = bat_version();
let version = take_string(version_ptr);
assert!(!version.is_empty());
}
#[test]
fn test_manifest_inspect_json() {
let result = inspect_manifest(catalog_json());
assert_eq!(result["ok"], true);
assert_eq!(result["data"]["resource_count"], 1);
assert_eq!(
result["data"]["resources"][0]["path"],
"synthetic/minimal.bundle"
);
}
#[test]
fn test_sync_plan_json() {
let current = r#"{
"connection_group_name":"Prod-Audit",
"app_version":"1.70.0",
"bundle_version":"s8tloc7lo3",
"addressables_root":"https://prod-clientpatch.bluearchiveyostar.com/r93_token",
"endpoints":[
{
"kind":"TableCatalog",
"platform":null,
"url":"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
}
]
}"#;
let current = sync_snapshot_json("s8tloc7lo3");
let result = sync_plan(&current, None);
assert_eq!(result["ok"], true);
assert_eq!(result["data"]["decision"], "DownloadVerifyAndPublish");
assert_eq!(result["data"]["should_download"], true);
assert_eq!(result["data"]["should_publish"], true);
}
let result_ptr =
bat_sync_plan_json(CString::new(current).unwrap().as_ptr(), std::ptr::null());
let result = take_string(result_ptr);
assert!(result.contains(r#""ok":true"#));
assert!(result.contains(r#""decision":"DownloadVerifyAndPublish""#));
#[test]
fn stateless_ffi_json_api_repeats_without_cached_state() {
let first_manifest = inspect_manifest(catalog_json());
let second_manifest = inspect_manifest(catalog_json());
assert_eq!(first_manifest, second_manifest);
let current = sync_snapshot_json("s8tloc7lo3");
let initial_plan = sync_plan(&current, None);
let up_to_date_plan = sync_plan(&current, Some(&current));
let initial_plan_again = sync_plan(&current, None);
assert_eq!(initial_plan["data"]["decision"], "DownloadVerifyAndPublish");
assert_eq!(up_to_date_plan["data"]["decision"], "UpToDate");
assert_eq!(
initial_plan_again["data"]["decision"],
initial_plan["data"]["decision"]
);
assert_eq!(
initial_plan_again["data"]["should_download"],
initial_plan["data"]["should_download"]
);
}
#[test]
fn ffi_json_api_returns_structured_errors() {
let manifest_error =
parse_json_response(take_string(bat_manifest_inspect_json(std::ptr::null())));
assert_eq!(manifest_error["ok"], false);
assert!(manifest_error["error"]
.as_str()
.unwrap()
.contains("null pointer"));
let sync_error = parse_json_response(take_string(bat_sync_plan_json(
std::ptr::null(),
std::ptr::null(),
)));
assert_eq!(sync_error["ok"], false);
assert!(sync_error["error"]
.as_str()
.unwrap()
.contains("null pointer"));
}
}