use std::io::{Seek, Write};
use tempfile::TempDir;
use tokensave::db::Database;
use tokensave::types::*;
async fn setup_db() -> (Database, TempDir, std::path::PathBuf) {
let dir = TempDir::new().expect("failed to create temp dir");
let db_path = dir.path().join("test.db");
let (db, _) = Database::initialize(&db_path)
.await
.expect("failed to initialize database");
(db, dir, db_path)
}
fn sample_node(id: &str, name: &str) -> Node {
Node {
id: id.to_string(),
kind: NodeKind::Function,
name: name.to_string(),
qualified_name: format!("crate::{name}"),
file_path: "src/lib.rs".to_string(),
start_line: 1,
attrs_start_line: 1,
end_line: 10,
start_column: 0,
end_column: 1,
signature: Some(format!("fn {name}()")),
docstring: Some(format!("Documentation for {name}")),
visibility: Visibility::Pub,
is_async: false,
branches: 0,
loops: 0,
returns: 0,
max_nesting: 0,
unsafe_blocks: 0,
unchecked_calls: 0,
assertions: 0,
updated_at: 1000,
}
}
#[tokio::test]
async fn quick_check_passes_on_healthy_db() {
let (db, _dir, _path) = setup_db().await;
assert!(
db.quick_check().await.unwrap(),
"fresh database should pass quick_check"
);
}
#[tokio::test]
async fn quick_check_passes_after_inserts() {
let (db, _dir, _path) = setup_db().await;
let nodes: Vec<Node> = (0..50)
.map(|i| sample_node(&format!("n{i}"), &format!("func_{i}")))
.collect();
db.insert_nodes(&nodes).await.unwrap();
assert!(
db.quick_check().await.unwrap(),
"database with data should pass quick_check"
);
}
#[tokio::test]
async fn quick_check_detects_page_level_corruption() {
let (db, _dir, db_path) = setup_db().await;
let nodes: Vec<Node> = (0..100)
.map(|i| sample_node(&format!("n{i}"), &format!("function_with_long_name_{i}")))
.collect();
db.insert_nodes(&nodes).await.unwrap();
db.checkpoint().await.unwrap();
drop(db);
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&db_path)
.unwrap();
let len = file.metadata().unwrap().len();
let offset = std::cmp::min(len / 2, 8192);
file.seek(std::io::SeekFrom::Start(offset)).unwrap();
file.write_all(&[0xDE, 0xAD, 0xBE, 0xEF].repeat(64))
.unwrap();
file.sync_all().unwrap();
}
let (db2, _) = Database::open(&db_path)
.await
.expect("open should succeed even with corruption");
let intact = db2.quick_check().await.unwrap();
assert!(!intact, "quick_check should detect page-level corruption");
}
#[tokio::test]
async fn rebuild_fts_on_fresh_db() {
let (db, _dir, _path) = setup_db().await;
db.rebuild_fts().await.unwrap();
}
#[tokio::test]
async fn rebuild_fts_restores_search_after_fts_damage() {
let (db, _dir, _path) = setup_db().await;
let nodes = vec![
sample_node("a1", "process_data"),
sample_node("a2", "validate_input"),
];
db.insert_nodes(&nodes).await.unwrap();
let results = db.search_nodes("process_data", 10).await.unwrap();
assert!(!results.is_empty(), "search should find process_data");
db.conn()
.execute_batch("DELETE FROM nodes_fts;")
.await
.unwrap();
db.rebuild_fts().await.unwrap();
let results = db.search_nodes("process_data", 10).await.unwrap();
assert!(!results.is_empty(), "search should work after FTS rebuild");
assert_eq!(results[0].node.id, "a1");
}
#[tokio::test]
async fn search_nodes_falls_back_to_like_when_fts_empty() {
let (db, _dir, _path) = setup_db().await;
let nodes = vec![sample_node("b1", "my_function")];
db.insert_nodes(&nodes).await.unwrap();
db.conn()
.execute_batch("DELETE FROM nodes_fts;")
.await
.unwrap();
let results = db.search_nodes("my_function", 10).await.unwrap();
assert!(!results.is_empty(), "LIKE fallback should find the node");
assert_eq!(results[0].node.id, "b1");
}
#[tokio::test]
async fn bulk_load_preserves_synchronous_normal() {
let (db, _dir, _path) = setup_db().await;
db.begin_bulk_load().await.unwrap();
let mut rows = db.conn().query("PRAGMA synchronous", ()).await.unwrap();
let row = rows.next().await.unwrap().unwrap();
let sync_value: i64 = row.get(0).unwrap();
assert_eq!(
sync_value, 1,
"synchronous should be NORMAL (1) during bulk load, not OFF (0)"
);
db.end_bulk_load().await.unwrap();
}
#[tokio::test]
async fn bulk_load_round_trip_preserves_data() {
let (db, _dir, _path) = setup_db().await;
db.begin_bulk_load().await.unwrap();
let nodes = vec![sample_node("c1", "alpha"), sample_node("c2", "beta")];
db.insert_nodes(&nodes).await.unwrap();
db.end_bulk_load().await.unwrap();
let results = db.search_nodes("alpha", 10).await.unwrap();
assert!(!results.is_empty());
assert_eq!(results[0].node.id, "c1");
}
#[test]
fn is_corruption_error_matches_malformed() {
let e = tokensave::errors::TokenSaveError::Database {
message: "failed to read search result: SQLite failure: `database disk image is malformed`"
.to_string(),
operation: "search_nodes".to_string(),
};
assert!(Database::is_corruption_error(&e));
}
#[test]
fn is_corruption_error_matches_corrupt() {
let e = tokensave::errors::TokenSaveError::Database {
message: "database is corrupt".to_string(),
operation: "test".to_string(),
};
assert!(Database::is_corruption_error(&e));
}
#[test]
fn is_corruption_error_rejects_normal_errors() {
let e = tokensave::errors::TokenSaveError::Database {
message: "no such table: foobar".to_string(),
operation: "test".to_string(),
};
assert!(!Database::is_corruption_error(&e));
let e2 = tokensave::errors::TokenSaveError::Config {
message: "some config error".to_string(),
};
assert!(!Database::is_corruption_error(&e2));
}
#[test]
fn dirty_sentinel_lifecycle() {
let dir = TempDir::new().unwrap();
let ts_dir = dir.path().join(".tokensave");
std::fs::create_dir_all(&ts_dir).unwrap();
let dirty_path = ts_dir.join("dirty");
assert!(!dirty_path.exists());
std::fs::write(
&dirty_path,
format!("pid={}\nversion=test", std::process::id()),
)
.unwrap();
assert!(dirty_path.exists());
let contents = std::fs::read_to_string(&dirty_path).unwrap();
assert!(contents.contains("pid="));
assert!(contents.contains("version=test"));
std::fs::remove_file(&dirty_path).unwrap();
assert!(!dirty_path.exists());
}
#[test]
fn dirty_sentinel_survives_drop() {
let dir = TempDir::new().unwrap();
let ts_dir = dir.path().join(".tokensave");
std::fs::create_dir_all(&ts_dir).unwrap();
let dirty_path = ts_dir.join("dirty");
{
std::fs::write(&dirty_path, "pid=99999\nversion=test").unwrap();
}
assert!(dirty_path.exists(), "sentinel must survive scope drop");
}
#[tokio::test]
async fn corrupt_db_detected_and_repaired_on_reopen() {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let (db, _) = Database::initialize(&db_path).await.unwrap();
let nodes: Vec<Node> = (0..50)
.map(|i| sample_node(&format!("d{i}"), &format!("func_{i}")))
.collect();
db.insert_nodes(&nodes).await.unwrap();
db.checkpoint().await.unwrap();
drop(db);
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.open(&db_path)
.unwrap();
let len = file.metadata().unwrap().len();
let offset = std::cmp::min(len / 2, 8192);
file.seek(std::io::SeekFrom::Start(offset)).unwrap();
file.write_all(&[0xFF; 256]).unwrap();
file.sync_all().unwrap();
}
let open_result = Database::open(&db_path).await;
match open_result {
Ok((db2, _)) => {
let intact = db2.quick_check().await.unwrap();
assert!(!intact, "corrupted db should fail quick_check");
}
Err(e) => {
assert!(
Database::is_corruption_error(&e)
|| format!("{e}").contains("malformed")
|| format!("{e}").contains("not a database"),
"unexpected error: {e}"
);
}
}
std::fs::remove_file(&db_path).ok();
let mut wal = db_path.clone();
wal.set_extension("db-wal");
std::fs::remove_file(&wal).ok();
wal.set_extension("db-shm");
std::fs::remove_file(&wal).ok();
let (db3, _) = Database::initialize(&db_path).await.unwrap();
assert!(
db3.quick_check().await.unwrap(),
"fresh db after recovery should be healthy"
);
}
#[tokio::test]
async fn fts_corruption_healed_by_search_nodes() {
let (db, _dir, _path) = setup_db().await;
let nodes = vec![
sample_node("e1", "important_handler"),
sample_node("e2", "other_helper"),
];
db.insert_nodes(&nodes).await.unwrap();
let results = db.search_nodes("important_handler", 10).await.unwrap();
assert_eq!(results[0].node.id, "e1");
db.conn()
.execute_batch(
"INSERT INTO nodes_fts(nodes_fts, rowid, name, qualified_name, docstring, signature)
VALUES('delete', 1, 'important_handler', 'crate::important_handler', 'Documentation for important_handler', 'fn important_handler()');",
)
.await
.unwrap();
let results = db.search_nodes("important_handler", 10).await.unwrap();
assert!(
!results.is_empty(),
"search should recover via self-healing or LIKE fallback"
);
}