use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tracing::{debug, info, warn};
use crate::embeddings::Embedder;
use crate::embeddings::rvf_backend::{RvfError, RvfStore};
#[non_exhaustive]
#[derive(Debug)]
pub enum BootstrapError {
Io(std::io::Error),
Embedding(crate::embeddings::EmbeddingError),
Store(RvfError),
}
impl std::fmt::Display for BootstrapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BootstrapError::Io(e) => write!(f, "bootstrap I/O error: {e}"),
BootstrapError::Embedding(e) => write!(f, "bootstrap embedding error: {e}"),
BootstrapError::Store(e) => write!(f, "bootstrap store error: {e}"),
}
}
}
impl std::error::Error for BootstrapError {}
impl From<std::io::Error> for BootstrapError {
fn from(e: std::io::Error) -> Self {
BootstrapError::Io(e)
}
}
impl From<crate::embeddings::EmbeddingError> for BootstrapError {
fn from(e: crate::embeddings::EmbeddingError) -> Self {
BootstrapError::Embedding(e)
}
}
impl From<RvfError> for BootstrapError {
fn from(e: RvfError) -> Self {
BootstrapError::Store(e)
}
}
pub async fn bootstrap_memory_index(
memory_path: &Path,
index_path: &Path,
embedder: &dyn Embedder,
) -> Result<usize, BootstrapError> {
bootstrap_memory_index_inner(memory_path, index_path, embedder, false).await
}
pub async fn reindex_memory_index(
memory_path: &Path,
index_path: &Path,
embedder: &dyn Embedder,
) -> Result<usize, BootstrapError> {
bootstrap_memory_index_inner(memory_path, index_path, embedder, true).await
}
pub fn memory_is_newer_than_index(memory_path: &Path, index_path: &Path) -> bool {
if !index_path.exists() {
return memory_path.exists();
}
if !memory_path.exists() {
return false;
}
match (file_mtime(memory_path), file_mtime(index_path)) {
(Some(mem), Some(idx)) => mem > idx,
_ => true,
}
}
pub fn memory_index_sidecar_path(index_path: &Path) -> PathBuf {
let mut s = index_path.as_os_str().to_owned();
s.push(".sidecar.json");
PathBuf::from(s)
}
pub fn remove_memory_index(index_path: &Path) {
if index_path.exists()
&& let Err(e) = std::fs::remove_file(index_path)
{
warn!(
path = %index_path.display(),
error = %e,
"failed to remove stale memory index"
);
}
let sidecar = memory_index_sidecar_path(index_path);
if sidecar.exists()
&& let Err(e) = std::fs::remove_file(&sidecar)
{
warn!(
path = %sidecar.display(),
error = %e,
"failed to remove stale memory index sidecar"
);
}
}
async fn bootstrap_memory_index_inner(
memory_path: &Path,
index_path: &Path,
embedder: &dyn Embedder,
force: bool,
) -> Result<usize, BootstrapError> {
if index_path.exists() && !force {
if !memory_is_newer_than_index(memory_path, index_path) {
info!(
path = %index_path.display(),
"memory index already exists and is up to date, skipping bootstrap"
);
return Ok(0);
}
info!(
memory = %memory_path.display(),
index = %index_path.display(),
"MEMORY.md is newer than index, re-indexing"
);
} else if force && index_path.exists() {
info!(
path = %index_path.display(),
"forced reindex requested, rebuilding memory index"
);
}
if index_path.exists() || memory_index_sidecar_path(index_path).exists() {
remove_memory_index(index_path);
}
if !memory_path.exists() {
info!(
path = %memory_path.display(),
"MEMORY.md not found, skipping bootstrap"
);
return Ok(0);
}
let content = std::fs::read_to_string(memory_path)?;
if content.trim().is_empty() {
info!("MEMORY.md is empty, skipping bootstrap");
return Ok(0);
}
debug!(
path = %memory_path.display(),
bytes = content.len(),
"read MEMORY.md for bootstrap"
);
let sections = split_into_sections(&content);
if sections.is_empty() {
info!("no sections found in MEMORY.md");
return Ok(0);
}
debug!(count = sections.len(), "split MEMORY.md into sections");
let mut store = RvfStore::create(Some(index_path));
let mut indexed = 0;
for (i, section) in sections.iter().enumerate() {
let trimmed = section.trim();
if trimmed.is_empty() {
continue;
}
match embedder.embed(trimmed).await {
Ok(embedding) => {
let id = format!("memory-section-{i}");
let metadata = serde_json::json!({
"source": "MEMORY.md",
"section_index": i,
"text": trimmed,
"char_count": trimmed.len(),
});
match store.ingest(id, embedding, metadata) {
Ok(()) => indexed += 1,
Err(e) => {
warn!(
section = i,
error = %e,
"failed to ingest section, skipping"
);
}
}
}
Err(e) => {
warn!(
section = i,
error = %e,
"failed to embed section, skipping"
);
}
}
}
store.compact()?;
info!(
indexed,
total_sections = sections.len(),
path = %index_path.display(),
force,
"memory bootstrap complete"
);
Ok(indexed)
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
fn split_into_sections(content: &str) -> Vec<String> {
let header_sections: Vec<String> = split_by_headers(content);
if header_sections.len() >= 2 {
return header_sections;
}
split_by_paragraphs(content)
}
fn split_by_headers(content: &str) -> Vec<String> {
let mut sections = Vec::new();
let mut current = String::new();
for line in content.lines() {
if line.starts_with("## ") && !current.trim().is_empty() {
sections.push(current.trim().to_string());
current = String::new();
}
current.push_str(line);
current.push('\n');
}
if !current.trim().is_empty() {
sections.push(current.trim().to_string());
}
sections.into_iter().filter(|s| !s.is_empty()).collect()
}
fn split_by_paragraphs(content: &str) -> Vec<String> {
content
.split("\n\n")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embeddings::api_embedder::ApiEmbedder;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::Duration;
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(label: &str) -> std::path::PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!("clawft_bootstrap_test_{label}_{pid}_{n}"))
}
#[test]
fn split_by_headers_basic() {
let content = "# Title\n\nIntro text.\n\n## Section One\n\nContent one.\n\n## Section Two\n\nContent two.";
let sections = split_by_headers(content);
assert_eq!(sections.len(), 3);
assert!(sections[0].starts_with("# Title"));
assert!(sections[1].starts_with("## Section One"));
assert!(sections[2].starts_with("## Section Two"));
}
#[test]
fn split_by_headers_no_headers() {
let content = "Just some plain text\nwith no headers.";
let sections = split_by_headers(content);
assert_eq!(sections.len(), 1);
}
#[test]
fn split_by_paragraphs_basic() {
let content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
let sections = split_by_paragraphs(content);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0], "First paragraph.");
assert_eq!(sections[1], "Second paragraph.");
assert_eq!(sections[2], "Third paragraph.");
}
#[test]
fn split_into_sections_uses_headers_when_available() {
let content = "## One\n\nContent.\n\n## Two\n\nMore content.";
let sections = split_into_sections(content);
assert_eq!(sections.len(), 2);
}
#[test]
fn split_into_sections_falls_back_to_paragraphs() {
let content = "Para one.\n\nPara two.\n\nPara three.";
let sections = split_into_sections(content);
assert_eq!(sections.len(), 3);
}
#[test]
fn split_into_sections_empty() {
let sections = split_into_sections("");
assert!(sections.is_empty());
}
#[test]
fn split_into_sections_whitespace_only() {
let sections = split_into_sections(" \n\n \n\n ");
assert!(sections.is_empty());
}
#[tokio::test]
async fn bootstrap_creates_index() {
let dir = temp_dir("creates_index");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(
&memory_path,
"## Facts\n\nThe sky is blue.\n\n## Notes\n\nRust is fast.",
)
.unwrap();
let embedder = ApiEmbedder::hash_only(64);
let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count, 2);
assert!(index_path.exists());
let store = RvfStore::open(&index_path).unwrap();
assert_eq!(store.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_skips_existing_index() {
let dir = temp_dir("skips_existing");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(&memory_path, "## Stuff\n\nContent.").unwrap();
thread::sleep(Duration::from_millis(20));
std::fs::write(&index_path, "{}").unwrap();
let embedder = ApiEmbedder::hash_only(64);
let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count, 0, "should skip when index already exists and is current");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_skips_missing_memory() {
let dir = temp_dir("missing_memory");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md"); let index_path = dir.join("memory.rvf.json");
let embedder = ApiEmbedder::hash_only(64);
let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count, 0, "should skip when MEMORY.md is missing");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_skips_empty_memory() {
let dir = temp_dir("empty_memory");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(&memory_path, "").unwrap();
let embedder = ApiEmbedder::hash_only(64);
let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count, 0, "should skip when MEMORY.md is empty");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_paragraph_splitting() {
let dir = temp_dir("paragraphs");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(
&memory_path,
"First fact about something.\n\nSecond fact about another thing.\n\nThird fact.",
)
.unwrap();
let embedder = ApiEmbedder::hash_only(64);
let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count, 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_index_is_searchable() {
let dir = temp_dir("searchable");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(
&memory_path,
"## Rust\n\nRust is a systems programming language.\n\n## Python\n\nPython is interpreted.",
)
.unwrap();
let embedder = ApiEmbedder::hash_only(64);
bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
let store = RvfStore::open(&index_path).unwrap();
let query_emb = embedder.hash_fallback("rust programming");
let results = store.query(&query_emb, 2);
assert_eq!(results.len(), 2);
let first = &results[0];
assert!(first.metadata.get("text").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn bootstrap_reindexes_when_memory_newer() {
let dir = temp_dir("mtime_reindex");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
let embedder = ApiEmbedder::hash_only(64);
std::fs::write(
&memory_path,
"## Alpha\n\nALPHA_ONLY unique token about widgets.\n\n## Misc\n\nOther old note.",
)
.unwrap();
let count1 = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count1, 2);
assert!(index_path.exists());
thread::sleep(Duration::from_millis(1100));
std::fs::write(
&memory_path,
"## Beta\n\nBETA_ONLY unique token about gadgets.\n\n## Misc\n\nOther new note.",
)
.unwrap();
assert!(
memory_is_newer_than_index(&memory_path, &index_path),
"MEMORY.md mtime should exceed index mtime after rewrite"
);
let count2 = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count2, 2, "should reindex when MEMORY.md is newer");
let store = RvfStore::open(&index_path).unwrap();
assert_eq!(store.len(), 2);
let query_emb = embedder.hash_fallback("BETA_ONLY unique token gadgets");
let results = store.query(&query_emb, 2);
assert!(!results.is_empty());
let texts: Vec<&str> = results
.iter()
.filter_map(|r| r.metadata.get("text").and_then(|v| v.as_str()))
.collect();
let joined = texts.join("\n");
assert!(
joined.contains("BETA_ONLY"),
"search should surface reindexed content, got: {joined}"
);
assert!(
!joined.contains("ALPHA_ONLY"),
"stale sections should not remain after reindex, got: {joined}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn force_reindex_rebuilds_current_index() {
let dir = temp_dir("force_reindex");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
let embedder = ApiEmbedder::hash_only(64);
std::fs::write(
&memory_path,
"## Facts\n\nFORCE_REINDEX_TOKEN for zebra quilts.\n\n## More\n\nSecondary section.",
)
.unwrap();
let count1 = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count1, 2);
let skipped = bootstrap_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(skipped, 0, "up-to-date index should skip without force");
let count2 = reindex_memory_index(&memory_path, &index_path, &embedder)
.await
.unwrap();
assert_eq!(count2, 2, "force reindex should rebuild");
let store = RvfStore::open(&index_path).unwrap();
let query_emb = embedder.hash_fallback("FORCE_REINDEX_TOKEN zebra quilts");
let results = store.query(&query_emb, 2);
assert!(!results.is_empty());
let joined: String = results
.iter()
.filter_map(|r| r.metadata.get("text").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("FORCE_REINDEX_TOKEN"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn memory_is_newer_when_index_missing() {
let dir = temp_dir("mtime_missing_index");
std::fs::create_dir_all(&dir).unwrap();
let memory_path = dir.join("MEMORY.md");
let index_path = dir.join("memory.rvf.json");
std::fs::write(&memory_path, "x").unwrap();
assert!(memory_is_newer_than_index(&memory_path, &index_path));
let _ = std::fs::remove_dir_all(&dir);
}
}