use crate::embedding::{EmbeddingEngine, MAX_EMBEDDING_TOKENS};
use crate::embedding_profiles::{EmbeddingRole, profile_for};
use crate::errors::Error;
use crate::migration::migrate_model;
use crate::output::{ReindexFailure, ReindexResponse, print_json};
use crate::sqlite::Database;
use crate::sqlite::embedding::classify_embedding;
use crate::sqlite::identity;
use crate::sqlite::identity::ModelIdentity;
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
const PROGRESS_INTERVAL: usize = 50;
fn open_fast_fail(db_path: &Path) -> Result<Database, Error> {
let db = super::db_busy::open_db(db_path)?;
db.set_busy_timeout(Duration::ZERO)?;
Ok(db)
}
fn migration_config(model_id: &str) -> Result<crate::config::Config, Error> {
let mut config = crate::config::Config::load().map_err(|e| Error::Config(e.to_string()))?;
config.embedding_model = model_id.to_string();
Ok(config)
}
pub fn handle_reindex(
db_path: &Path,
model_id: &str,
project_filter: Option<&str>,
force: bool,
json: bool,
) -> Result<ExitCode, Error> {
let db = open_fast_fail(db_path)?;
let all_project_ids = super::db_busy::wrap_busy(db.list_all_project_ids())?;
let projects: Vec<String> = if let Some(filter) = project_filter {
vec![filter.to_string()]
} else {
all_project_ids.clone()
};
if projects.is_empty() {
if json {
print_json(&[ReindexResponse {
project_id: "(none)".to_string(),
reindexed: 0,
skipped: 0,
failed: vec![],
}]);
} else {
println!("No projects found in database. Nothing to reindex.");
}
return Ok(ExitCode::SUCCESS);
}
if !json && let Some(filter) = project_filter {
let other_count = all_project_ids
.iter()
.filter(|pid| pid.as_str() != filter)
.count();
if other_count > 0 {
println!(
"Note: {} other project(s) in this database not reindexed. Run with --all-projects to reindex them.",
other_count
);
}
}
if force {
if project_filter.is_some() {
eprintln!(
"Error: `reindex --force` migrates the entire database: pass --all-projects so every project is re-embedded before the marker is written and the new identity is recorded."
);
return Ok(ExitCode::from(1));
}
return handle_reindex_force(db_path, model_id, &all_project_ids, json);
}
let mut total_failed: usize = 0;
let mut responses: Vec<ReindexResponse> = vec![];
let mut engine = EmbeddingEngine::new(model_id)?;
for project_id in &projects {
if !json {
println!("Project {}: reindexing...", project_id);
}
let mut embed_callback = |content: &str| {
engine
.embed_passage(content)
.map_err(|e| Error::Inference(e.to_string()))
};
let (reindexed, skipped, failed) =
reindex_project(&db, &mut embed_callback, project_id, json)?;
let failed_count = failed.len();
total_failed += failed_count;
responses.push(ReindexResponse {
project_id: project_id.clone(),
reindexed,
skipped,
failed,
});
if !json {
println!(
" Done: {} reindexed, {} skipped, {} failed",
reindexed, skipped, failed_count
);
}
}
print_summary(
&responses,
&total_projects_scope(&projects),
json,
total_failed,
)?;
Ok(ExitCode::SUCCESS)
}
fn handle_reindex_force(
db_path: &Path,
model_id: &str,
projects: &[String],
json: bool,
) -> Result<ExitCode, Error> {
let profile = profile_for(model_id)?;
let target = ModelIdentity {
model_id: profile.model_id.to_string(),
revision: profile.revision.to_string(),
};
let passage_prefix = EmbeddingRole::Passage.prefix(profile).to_string();
let (current, migrating) = {
let db = open_fast_fail(db_path)?;
let current = identity::current_identity(db.conn()).map_err(Error::from)?;
let migrating = identity::is_migrating(db.conn()).map_err(Error::from)?;
(current, migrating)
};
if !json {
if migrating {
println!("Resuming interrupted migration to {}...", target.display());
} else if current != target {
println!(
"Migrating from {} to {}...",
current.display(),
target.display()
);
}
}
let lib_config = migration_config(model_id)?;
let result = migrate_model(db_path, &lib_config);
let (reindexed, skipped, failed) = match result {
Ok(report) => {
let failed: Vec<ReindexFailure> = report
.failures
.into_iter()
.map(|f| ReindexFailure {
id: f.id,
error: f.error,
})
.collect();
(report.reindexed, report.skipped, failed)
}
Err(crate::errors::Error::MigrationRefused { offending }) => {
eprintln!(
"Error: reindex --force refused to start: {} row(s) exceed the {}-token limit once the '{}' passage prefix is prepended. Fix or remove these memories, then re-run `vipune reindex --force`:",
offending.len(),
MAX_EMBEDDING_TOKENS,
passage_prefix.trim(),
);
for id in &offending {
eprintln!(" {id}");
}
eprintln!("No migration marker was written and no rows were changed.");
return Ok(ExitCode::from(1));
}
Err(crate::errors::Error::MigrationIncomplete { report }) => {
let failed: Vec<ReindexFailure> = report
.failures
.into_iter()
.map(|f| ReindexFailure {
id: f.id,
error: f.error,
})
.collect();
(report.reindexed, report.skipped, failed)
}
Err(e) => return Err(e),
};
let total_failed = failed.len();
let responses = vec![ReindexResponse {
project_id: total_projects_scope(projects),
reindexed,
skipped,
failed,
}];
if total_failed > 0 {
eprintln!(
"Error: {} row(s) failed during force reindex. The migration marker is left in place; fix the errors and re-run `vipune reindex --force`.",
total_failed
);
print_summary(
&responses,
&total_projects_scope(projects),
json,
total_failed,
)?;
return Ok(ExitCode::from(1));
}
if skipped > 0 {
eprintln!(
"{} row(s) had corrupted embeddings and were skipped",
skipped
);
}
if !json {
println!(
"Model identity updated to {} (marker cleared).",
target.display()
);
}
print_summary(
&responses,
&total_projects_scope(projects),
json,
total_failed,
)?;
Ok(ExitCode::SUCCESS)
}
fn print_summary(
responses: &[ReindexResponse],
scope: &str,
json: bool,
total_failed: usize,
) -> Result<(), Error> {
if json {
print_json(&responses);
} else {
if total_failed > 0 {
eprintln!("Warning: {} row(s) failed during reindex", total_failed);
for response in responses {
for failure in &response.failed {
eprintln!(" {} — {}", failure.id, failure.error);
}
}
}
println!("Total across {}:", scope);
let reindexed: usize = responses.iter().map(|r| r.reindexed).sum();
let skipped: usize = responses.iter().map(|r| r.skipped).sum();
println!(" Reindexed: {}", reindexed);
println!(" Skipped: {}", skipped);
println!(" Failed: {}", total_failed);
}
Ok(())
}
fn total_projects_scope(projects: &[String]) -> String {
if projects.len() == 1 {
format!("project {}", projects[0])
} else {
"all projects".to_string()
}
}
pub(crate) fn reindex_project<F>(
db: &Database,
embed: &mut F,
project_id: &str,
json: bool,
) -> Result<(usize, usize, Vec<ReindexFailure>), Error>
where
F: FnMut(&str) -> Result<Vec<f32>, Error>,
{
let rows = db.list_all_rows_for_project(project_id)?;
let mut reindexed: usize = 0;
let mut skipped: usize = 0;
let mut failed: Vec<ReindexFailure> = vec![];
let mut processed: usize = 0;
for (id, content, embedding) in rows {
processed += 1;
match classify_embedding(&embedding) {
crate::sqlite::embedding::EmbeddingClass::Real => {
}
crate::sqlite::embedding::EmbeddingClass::Unknown => {
skipped += 1;
if !json {
eprintln!(
" Skipping {} — unknown embedding (norm not in valid range)",
id
);
}
continue;
}
crate::sqlite::embedding::EmbeddingClass::Mock => {
match reindex_row(embed, &id, &content, db) {
Ok(()) => {
reindexed += 1;
}
Err(e) => {
failed.push(ReindexFailure {
id: id.clone(),
error: e.to_string(),
});
if !json {
eprintln!(" Failed {} — {}", id, e);
}
continue;
}
}
}
}
if !json && processed.is_multiple_of(PROGRESS_INTERVAL) {
println!(
" Progress: {} processed ({} reindexed, {} skipped, {} failed)",
processed,
reindexed,
skipped,
failed.len()
);
}
}
Ok((reindexed, skipped, failed))
}
fn reindex_row<F>(embed: &mut F, id: &str, content: &str, db: &Database) -> Result<(), Error>
where
F: FnMut(&str) -> Result<Vec<f32>, Error>,
{
let new_embedding = embed(content)?;
db.update_embedding(id, &new_embedding)?;
Ok(())
}