use std::fs;
use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension, params};
use crate::cm_types::path_utils::canonical_workspace_root;
pub const TABLE: &str = "crabmate_codebase_chunks";
pub const TABLE_FILES: &str = "crabmate_codebase_files";
pub const TABLE_FTS: &str = "crabmate_codebase_chunks_fts";
pub const CODEBASE_SEMANTIC_FILES_TABLE: &str = TABLE_FILES;
pub const SCHEMA_VERSION: i64 = 4;
fn migrate(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch(&format!(
r#"
CREATE TABLE IF NOT EXISTS crabmate_codebase_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_root TEXT NOT NULL,
rel_path TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
chunk_text TEXT NOT NULL,
content_hash TEXT NOT NULL,
embedding BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_{TABLE}_workspace ON {TABLE}(workspace_root);
CREATE INDEX IF NOT EXISTS idx_{TABLE}_ws_rel ON {TABLE}(workspace_root, rel_path);
CREATE TABLE IF NOT EXISTS {TABLE_FILES} (
workspace_root TEXT NOT NULL,
rel_path TEXT NOT NULL,
size INTEGER NOT NULL,
mtime_ns INTEGER NOT NULL,
content_sha256 TEXT NOT NULL,
PRIMARY KEY (workspace_root, rel_path)
);
CREATE INDEX IF NOT EXISTS idx_{TABLE_FILES}_ws ON {TABLE_FILES}(workspace_root);
"#
))?;
let ver: Option<i64> = conn
.query_row(
"SELECT value FROM crabmate_codebase_index_meta WHERE key = 'schema_version'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|s| s.parse().ok());
let prev = ver.unwrap_or(0);
if prev < SCHEMA_VERSION {
let _ = conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS idx_{TABLE}_ws_rel ON {TABLE}(workspace_root, rel_path)"
),
[],
);
let _ = conn.execute_batch(&format!(
r#"
CREATE TABLE IF NOT EXISTS {TABLE_FILES} (
workspace_root TEXT NOT NULL,
rel_path TEXT NOT NULL,
size INTEGER NOT NULL,
mtime_ns INTEGER NOT NULL,
content_sha256 TEXT NOT NULL,
PRIMARY KEY (workspace_root, rel_path)
);
CREATE INDEX IF NOT EXISTS idx_{TABLE_FILES}_ws ON {TABLE_FILES}(workspace_root);
"#
));
}
conn.execute_batch(&format!(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS {TABLE_FTS} USING fts5(
chunk_text,
content='{TABLE}',
content_rowid='id',
tokenize = 'unicode61 remove_diacritics 2'
);
CREATE TRIGGER IF NOT EXISTS crabmate_codebase_chunks_ai AFTER INSERT ON {TABLE} BEGIN
INSERT INTO {TABLE_FTS}(rowid, chunk_text) VALUES (new.id, new.chunk_text);
END;
CREATE TRIGGER IF NOT EXISTS crabmate_codebase_chunks_ad AFTER DELETE ON {TABLE} BEGIN
INSERT INTO {TABLE_FTS}(crabmate_codebase_chunks_fts, rowid) VALUES('delete', old.id);
END;
CREATE TRIGGER IF NOT EXISTS crabmate_codebase_chunks_au AFTER UPDATE ON {TABLE} BEGIN
INSERT INTO {TABLE_FTS}(crabmate_codebase_chunks_fts, rowid) VALUES('delete', old.id);
INSERT INTO {TABLE_FTS}(rowid, chunk_text) VALUES (new.id, new.chunk_text);
END;
"#
))?;
if prev < 4 {
let _ = conn.execute(
&format!("INSERT INTO {TABLE_FTS}({TABLE_FTS}) VALUES('rebuild')"),
[],
);
}
conn.execute(
"INSERT OR REPLACE INTO crabmate_codebase_index_meta (key, value) VALUES ('schema_version', ?1)",
params![SCHEMA_VERSION.to_string()],
)?;
Ok(())
}
pub fn open_codebase_semantic_db(path: &Path) -> Result<Connection, String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("无法创建索引目录 {}: {}", parent.display(), e))?;
}
let conn = Connection::open(path)
.map_err(|e| format!("无法打开代码语义索引库 {}: {}", path.display(), e))?;
migrate(&conn).map_err(|e| format!("代码语义索引 schema 初始化失败: {}", e))?;
Ok(conn)
}
pub fn index_path_for_workspace(
workspace_root: &Path,
configured: &str,
) -> Result<PathBuf, String> {
let base = canonical_workspace_root(workspace_root).map_err(|e| e.user_message())?;
if configured.trim().is_empty() {
return Ok(base.join(".crabmate/codebase_semantic.sqlite"));
}
let sub = configured.trim();
if Path::new(sub).is_absolute() {
return Err("codebase_semantic_index_sqlite_path 必须为相对工作区的相对路径".to_string());
}
let joined = base.join(sub);
let canon = joined
.canonicalize()
.map_err(|e| format!("索引路径无法解析: {}", e))?;
if !canon.starts_with(&base) {
return Err("索引路径不能超出工作区根目录".to_string());
}
Ok(canon)
}