#![cfg(test)]
use crate::commands::export::{base64_encode, handle_export};
use crate::commands::import::{handle_import, run_import};
use crate::embedding::EMBEDDING_DIMS;
use crate::errors::Error;
use crate::sqlite::Database;
use crate::sqlite::embedding::vec_to_blob;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Debug, Clone, PartialEq, Eq)]
struct SeedRow {
id: String,
project_id: String,
content: String,
metadata: Option<String>,
blob: Vec<u8>,
created_at: String,
updated_at: String,
memory_type: String,
status: String,
superseded_by: Option<String>,
retrieval_count: i64,
last_retrieved_at: Option<String>,
importance: String,
}
fn blob_for(seed: usize) -> Vec<u8> {
let vec: Vec<f32> = (0..EMBEDDING_DIMS)
.map(|i| (i as f32 + seed as f32 * 7.0) * 0.001)
.collect();
let blob = vec_to_blob(&vec).expect("384-dim vector");
assert_eq!(blob.len(), EMBEDDING_DIMS * 4, "384 x f32-LE");
blob
}
fn seed_fixture() -> Vec<SeedRow> {
let specs: [(&str, &str, &str, i64, &str); 4] = [
("r0", "proj-a", "row a0", 0, "low"),
("r1", "proj-a", "row a1", 7, "critical"),
("r2", "proj-b", "row b1", 12, "high"),
("r3", "proj-b", "row b2", 1, "medium"),
];
let mut rows: Vec<SeedRow> = specs
.iter()
.enumerate()
.map(
|(index, (id, project_id, content, retrieval_count, importance))| SeedRow {
id: (*id).to_string(),
project_id: (*project_id).to_string(),
content: (*content).to_string(),
metadata: None,
blob: blob_for(index),
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
memory_type: "fact".to_string(),
status: "active".to_string(),
superseded_by: None,
retrieval_count: *retrieval_count,
last_retrieved_at: None,
importance: (*importance).to_string(),
},
)
.collect();
rows[1].metadata = Some(r#"{"k":"v"}"#.to_string());
rows[1].last_retrieved_at = Some("2024-03-20T14:30:00Z".to_string());
rows[2].superseded_by = Some("r1".to_string());
rows[2].last_retrieved_at = Some("2024-01-05T09:00:00Z".to_string());
rows[3].memory_type = "guard".to_string();
rows[3].status = "candidate".to_string();
rows
}
fn seed(db_path: &Path, rows: &[SeedRow]) {
let db = Database::open(db_path).expect("open source db");
for row in rows {
db.conn()
.execute(
r#"
INSERT INTO memories (id, project_id, content, embedding, metadata, created_at, updated_at, type, status, superseded_by, retrieval_count, last_retrieved_at, importance)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
"#,
rusqlite::params![
row.id,
row.project_id,
row.content,
row.blob,
row.metadata,
row.created_at,
row.updated_at,
row.memory_type,
row.status,
row.superseded_by,
row.retrieval_count,
row.last_retrieved_at,
row.importance,
],
)
.expect("seed row");
}
}
fn fetch_all(db_path: &Path) -> Vec<SeedRow> {
let conn = rusqlite::Connection::open(db_path).expect("open for read");
let mut stmt = conn
.prepare(
"SELECT id, project_id, content, embedding, metadata, created_at, updated_at, type, status, superseded_by, retrieval_count, last_retrieved_at, importance FROM memories ORDER BY id",
)
.expect("prepare");
stmt.query_map([], |r| {
Ok(SeedRow {
id: r.get(0)?,
project_id: r.get(1)?,
content: r.get(2)?,
blob: r.get(3)?,
metadata: r.get(4)?,
created_at: r.get(5)?,
updated_at: r.get(6)?,
memory_type: r.get(7)?,
status: r.get(8)?,
superseded_by: r.get(9)?,
retrieval_count: r.get(10)?,
last_retrieved_at: r.get(11)?,
importance: r.get(12)?,
})
})
.expect("query")
.collect::<Result<Vec<_>, _>>()
.expect("read rows")
}
fn row_line(id: &str, blob: &[u8]) -> Value {
json!({
"id": id,
"project_id": "proj",
"content": "content",
"metadata": null,
"embedding": base64_encode(blob),
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z",
"memory_type": "fact",
"status": "active",
"superseded_by": null,
"retrieval_count": 0,
"last_retrieved_at": null,
})
}
fn write_jsonl_file(dir: &Path, name: &str, header: &Value, rows: &[Value]) -> PathBuf {
let path = dir.join(name);
let mut text = serde_json::to_string(header).expect("header json");
text.push('\n');
for row in rows {
text.push_str(&serde_json::to_string(row).expect("row json"));
text.push('\n');
}
std::fs::write(&path, text).expect("write jsonl");
path
}
fn header_without_version(rows: usize) -> Value {
json!({
"type": "export",
"embedding_dims": EMBEDDING_DIMS,
"exported_at": "2024-01-01T00:00:00Z",
"rows": rows,
})
}
#[test]
fn test_export_then_import_roundtrip_is_byte_identical() {
let dir = tempfile::TempDir::new().unwrap();
let src = dir.path().join("source.db");
let dst = dir.path().join("destination.db");
let export = dir.path().join("export.jsonl");
let seeded = seed_fixture();
seed(&src, &seeded);
let code = handle_export(&src, &export, None, false).expect("real export must succeed");
assert_eq!(code, ExitCode::SUCCESS);
let text = std::fs::read_to_string(&export).expect("read export file");
let lines: Vec<&str> = text.lines().collect();
let header: Value = serde_json::from_str(lines[0]).expect("header is JSON");
assert_eq!(header["type"], "export");
assert_eq!(
header["format_version"],
json!(super::export::EXPORT_FORMAT_VERSION),
"export keeps writing the canonical key"
);
assert!(
header.get("version").is_none(),
"export must not emit the legacy alias: {header}"
);
assert_eq!(header["rows"].as_u64(), Some(seeded.len() as u64));
assert_eq!(lines.len(), seeded.len() + 1, "header + one line per row");
drop(Database::open(&dst).expect("create destination"));
let response = run_import(&dst, export.to_str().unwrap()).expect("real import must succeed");
assert_eq!(response.inserted, seeded.len(), "inserted == seeded count");
assert_eq!(response.skipped, 0);
let source_rows = fetch_all(&src);
let destination_rows = fetch_all(&dst);
assert_eq!(source_rows, seeded, "fixture seeded what it declared");
assert_eq!(
destination_rows, source_rows,
"round trip must restore every column verbatim, embedding BLOB included"
);
}
#[test]
fn test_export_then_handle_import_is_idempotent() {
let dir = tempfile::TempDir::new().unwrap();
let src = dir.path().join("source.db");
let dst = dir.path().join("destination.db");
let export = dir.path().join("export.jsonl");
let seeded = seed_fixture();
seed(&src, &seeded);
handle_export(&src, &export, None, true).expect("export ok");
drop(Database::open(&dst).expect("create destination"));
let code = handle_import(&dst, export.to_str(), None, false).expect("import ok");
assert_eq!(code, ExitCode::SUCCESS);
let first = fetch_all(&dst);
assert_eq!(first.len(), seeded.len());
let second = run_import(&dst, export.to_str().unwrap()).expect("re-import ok");
assert_eq!(second.inserted, 0);
assert_eq!(second.skipped, seeded.len());
assert_eq!(
fetch_all(&dst),
first,
"a skipped row must keep its telemetry and blob untouched"
);
}
#[test]
fn test_import_rejects_header_missing_both_version_keys() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
drop(Database::open(&db_path).expect("create db"));
let file = write_jsonl_file(
dir.path(),
"no-version.jsonl",
&header_without_version(1),
&[row_line("id-1", &blob_for(0))],
);
let err = run_import(&db_path, file.to_str().unwrap())
.expect_err("a header with neither version key must be refused");
match err {
Error::InvalidInput(msg) => assert_eq!(
msg,
"line 1: header missing format_version (or the legacy alias \"version\")"
),
other => panic!("expected InvalidInput, got {other:?}"),
}
assert!(fetch_all(&db_path).is_empty(), "all-or-nothing");
}
#[test]
fn test_import_accepts_format_version_without_numeric_gate() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
drop(Database::open(&db_path).expect("create db"));
let mut header = header_without_version(1);
header["format_version"] = json!("2");
let file = write_jsonl_file(
dir.path(),
"quoted-version.jsonl",
&header,
&[row_line("id-1", &blob_for(1))],
);
let response = run_import(&db_path, file.to_str().unwrap()).expect("quoted version parses");
assert_eq!(response.inserted, 1);
assert_eq!(response.skipped, 0);
}
#[test]
fn test_import_accepts_unseen_and_null_format_version_values() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
drop(Database::open(&db_path).expect("create db"));
let mut future = header_without_version(1);
future["format_version"] = json!(super::export::EXPORT_FORMAT_VERSION + 1);
let file = write_jsonl_file(
dir.path(),
"future.jsonl",
&future,
&[row_line("id-1", &blob_for(3))],
);
let response = run_import(&db_path, file.to_str().unwrap())
.expect("a version this reader has not seen still imports");
assert_eq!(response.inserted, 1);
let mut null_version = header_without_version(1);
null_version["format_version"] = Value::Null;
let file = write_jsonl_file(
dir.path(),
"null-version.jsonl",
&null_version,
&[row_line("id-2", &blob_for(4))],
);
let response = run_import(&db_path, file.to_str().unwrap())
.expect("a null version still counts as present");
assert_eq!(response.inserted, 1);
}
#[test]
fn test_import_accepts_legacy_version_alias_still() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
drop(Database::open(&db_path).expect("create db"));
let mut header = header_without_version(1);
header["version"] = json!(1);
let file = write_jsonl_file(
dir.path(),
"legacy.jsonl",
&header,
&[row_line("id-1", &blob_for(2))],
);
let response = run_import(&db_path, file.to_str().unwrap()).expect("legacy alias imports");
assert_eq!(response.inserted, 1);
}