use std::{fs, io::Write};
#[path = "vdr_support/mod.rs"]
mod vdr_support;
use vdr_support::{FakeEmbeddingServer, assert_success, image_id_for, run, write_caption};
#[test]
fn vdr_sync_retries_transient_429_then_succeeds() {
let server = FakeEmbeddingServer::start_with_statuses(vec![429, 200]);
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("retry.png"), b"retry image bytes").expect("write image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
assert!(synced.contains("indexed 1"), "got: {synced}");
assert_eq!(server.request_count(), 2);
}
#[test]
fn vdr_sync_indexes_heic_image_paths() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.heic"), b"dog image bytes").expect("write heic image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let status = assert_success(run(&config, &["vdr", "status", "--json"], server.url()));
assert!(synced.contains("indexed 1"), "got: {synced}");
let status: serde_json::Value = serde_json::from_str(&status).expect("status json");
assert_eq!(status["active_images"], 1);
assert_eq!(status["active_vectors"], 1);
}
#[test]
fn vdr_late_interaction_ranks_with_multivector_maxsim() {
let server = FakeEmbeddingServer::start_multivector();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
fs::write(images.join("cat.png"), b"cat image bytes").expect("write cat image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
let (cat_id, cat_path) = image_id_for(&config, "cat.png");
write_caption(
&config,
&dog_id,
&dog_path,
"Dog Park",
"puppy playing outside",
);
write_caption(
&config,
&cat_id,
&cat_path,
"Cat Sofa",
"kitten sleeping indoors",
);
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
assert!(
synced.contains("indexed 4"),
"expected paired vectors, got: {synced}"
);
let search = assert_success(run(
&config,
&[
"search",
"--mode",
"embedding",
"puppy playing",
"--json",
"--limit",
"2",
],
server.url(),
));
let rows: Vec<serde_json::Value> = search
.lines()
.map(|line| serde_json::from_str(line).expect("json result"))
.collect();
assert_eq!(rows.len(), 2, "expected two rows, got: {search}");
assert!(
rows[0]["path"].as_str().expect("path").ends_with("dog.png"),
"dog should rank first, got: {search}"
);
let top = rows[0]["score"].as_f64().expect("score");
let bottom = rows[1]["score"].as_f64().expect("score");
assert!((top - 1.0).abs() < 1e-6, "expected maxsim 1.0, got {top}");
assert!(
(bottom - 0.5).abs() < 1e-6,
"expected maxsim 0.5, got {bottom}"
);
}
#[test]
fn vdr_embedding_search_matches_image_or_caption_embeddings() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
fs::write(images.join("cat.png"), b"cat image bytes").expect("write cat image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["folder", "add", images.to_str().expect("utf8")],
server.url(),
));
assert_success(run(&config, &["bootstrap"], server.url()));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
let (cat_id, cat_path) = image_id_for(&config, "cat.png");
write_caption(
&config,
&dog_id,
&dog_path,
"Dog Park",
"puppy playing outside",
);
write_caption(
&config,
&cat_id,
&cat_path,
"Cat Sofa",
"kitten sleeping indoors",
);
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
assert!(
synced.contains("indexed 4"),
"expected paired vectors, got: {synced}"
);
let search = assert_success(run(
&config,
&[
"search",
"--mode",
"embedding",
"dog",
"--json",
"--limit",
"2",
],
server.url(),
));
let rows: Vec<serde_json::Value> = search
.lines()
.map(|line| serde_json::from_str(line).expect("json result"))
.collect();
assert_eq!(
rows.len(),
2,
"expected two embedding search rows, got: {search}"
);
assert_eq!(rows[0]["source"], "embedding");
assert!(
rows[0]["path"].as_str().expect("path").ends_with("dog.png"),
"dog should rank first, got: {search}"
);
assert!(
rows[0]["matched_field"] == "embedding_image"
|| rows[0]["matched_field"] == "embedding_caption",
"matched field should identify the embedding modality, got: {search}"
);
}
#[test]
fn vdr_sync_prunes_deleted_and_indexes_added_images() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
let old = images.join("old-dog.png");
fs::write(&old, b"dog image bytes").expect("write old image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["folder", "add", images.to_str().expect("utf8")],
server.url(),
));
assert_success(run(&config, &["bootstrap"], server.url()));
let (old_id, old_path) = image_id_for(&config, "old-dog.png");
write_caption(
&config,
&old_id,
&old_path,
"Old Dog",
"puppy should disappear",
);
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
fs::remove_file(&old).expect("delete old image");
fs::write(images.join("new.png"), b"new fresh image").expect("write new image");
assert_success(run(&config, &["bootstrap", "--prune"], server.url()));
let (new_id, new_path) = image_id_for(&config, "new.png");
write_caption(
&config,
&new_id,
&new_path,
"Fresh New",
"new searchable image",
);
let synced = assert_success(run(
&config,
&["vdr", "sync", "--prune", "--dimensions", "4"],
server.url(),
));
let status = assert_success(run(&config, &["vdr", "status", "--json"], server.url()));
let search = assert_success(run(
&config,
&["search", "--mode", "embedding", "new", "--json"],
server.url(),
));
let old_search = assert_success(run(
&config,
&["search", "--mode", "embedding", "dog", "--json"],
server.url(),
));
assert!(
synced.contains("indexed 2"),
"new image + caption indexed, got: {synced}"
);
let status: serde_json::Value = serde_json::from_str(&status).expect("status json");
assert_eq!(status["active_images"], 1);
assert_eq!(status["active_vectors"], 2);
assert!(
search.contains("new.png"),
"new image should be searchable, got: {search}"
);
assert!(
!old_search.contains("old-dog.png"),
"deleted image should not be searchable, got: {old_search}"
);
}
#[test]
fn forget_deactivates_vdr_vectors_for_image() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
let image = images.join("dog.png");
fs::write(&image, b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let forgot = assert_success(run(
&config,
&["forget", "--file", dog_path.to_str().expect("utf8")],
server.url(),
));
let status = assert_success(run(&config, &["vdr", "status", "--json"], server.url()));
assert!(forgot.contains("forgot 1 image"), "got: {forgot}");
let status: serde_json::Value = serde_json::from_str(&status).expect("status json");
assert_eq!(status["active_images"], 0);
assert_eq!(status["active_vectors"], 0);
}
#[test]
fn dedup_similar_reports_vdr_image_vector_groups_jsonl() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog-a.png"), b"dog image one").expect("write dog a");
fs::write(images.join("dog-b.png"), b"dog image two").expect("write dog b");
fs::write(images.join("cat.png"), b"cat image").expect("write cat");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let stdout = assert_success(run(
&config,
&["dedup", "--similar", "--threshold", "0.99", "--json"],
server.url(),
));
let rows: Vec<serde_json::Value> = stdout
.lines()
.map(|line| serde_json::from_str(line).expect("json group"))
.collect();
assert_eq!(rows.len(), 1, "expected one similar group, got: {stdout}");
assert_eq!(rows[0]["kind"], "similar");
assert!(
rows[0]["representative"]["path"]
.as_str()
.expect("path")
.ends_with("dog-a.png")
);
let duplicates = rows[0]["duplicates"].as_array().expect("duplicates");
assert_eq!(duplicates.len(), 1);
assert!(
duplicates[0]["path"]
.as_str()
.expect("path")
.ends_with("dog-b.png")
);
assert_eq!(duplicates[0]["score"], 1.0);
assert!(
!stdout.contains("cat.png"),
"cat image must not be in dog duplicate group, got: {stdout}"
);
}
#[test]
fn vdr_sync_second_run_skips_already_indexed_images() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let requests_after_first_sync = server.request_count();
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
assert!(
synced.contains("indexed 0"),
"second sync should skip, got: {synced}"
);
assert_eq!(server.request_count(), requests_after_first_sync);
}
#[test]
fn vdr_sync_fails_when_server_returns_different_model() {
let server = FakeEmbeddingServer::start_with_response_model("different-model");
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let output = run(
&config,
&[
"vdr",
"sync",
"--model",
"requested-model",
"--dimensions",
"4",
],
server.url(),
);
assert!(
!output.status.success(),
"sync should fail on model mismatch"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(
"embedding server returned model different-model but requested-model was requested"
),
"expected model mismatch, got: {stderr}"
);
assert!(
stderr.contains("pass --model/--dimensions matching the running server"),
"expected remediation hint, got: {stderr}"
);
}
#[test]
fn vdr_sync_updates_vector_path_after_image_record_path_changes() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
let new_path = images.join("renamed-dog.png");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--model", "test-model", "--dimensions", "4"],
server.url(),
));
let requests_after_first_sync = server.request_count();
let raw_images = fs::read_to_string(config.join("images.jsonl")).expect("images jsonl");
let mut renamed_record: serde_json::Value = raw_images
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("image record"))
.find(|record: &serde_json::Value| record["id"] == dog_id)
.expect("dog record");
renamed_record["path"] = serde_json::Value::String(new_path.to_string_lossy().to_string());
let mut images_jsonl = fs::OpenOptions::new()
.append(true)
.open(config.join("images.jsonl"))
.expect("open images jsonl");
writeln!(images_jsonl, "{renamed_record}").expect("append renamed image record");
write_caption(&config, &dog_id, &new_path, "Dog", "puppy");
let synced = assert_success(run(
&config,
&["vdr", "sync", "--model", "test-model", "--dimensions", "4"],
server.url(),
));
let search = assert_success(run(
&config,
&["search", "--mode", "embedding", "dog", "--json"],
server.url(),
));
assert!(
synced.contains("indexed 0"),
"path-only update should not re-index, got: {synced}"
);
assert_eq!(server.request_count(), requests_after_first_sync + 1);
assert!(
search.contains("renamed-dog.png"),
"embedding search should return latest path, got: {search}"
);
let rows: Vec<serde_json::Value> = search
.lines()
.map(|line| serde_json::from_str(line).expect("json result"))
.collect();
assert!(
rows.iter()
.all(|row| !row["path"].as_str().expect("path").ends_with("/dog.png")),
"embedding search should not return stale path, got: {search}"
);
}
#[test]
fn vdr_sync_reindexes_when_file_sha_changes() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
let image = images.join("dog.png");
fs::write(&image, b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
fs::write(&image, b"dog image bytes changed").expect("modify image bytes");
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
assert!(
synced.contains("indexed 2"),
"changed same-path image should be re-indexed, got: {synced}"
);
}
#[test]
fn vdr_sync_reindexes_when_caption_changes() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
write_caption(
&config,
&dog_id,
&dog_path,
"Fresh New",
"new searchable image",
);
let synced = assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let search = assert_success(run(
&config,
&["search", "--mode", "embedding", "new", "--json"],
server.url(),
));
assert!(
synced.contains("indexed 1"),
"caption-only change should index one caption vector, got: {synced}"
);
assert!(
search.contains("dog.png"),
"updated caption should be searchable, got: {search}"
);
}
#[test]
fn embedding_search_uses_latest_index_model_and_dimensions() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(&config, &dog_id, &dog_path, "Dog", "puppy");
assert_success(run(
&config,
&["vdr", "sync", "--model", "custom-jina", "--dimensions", "4"],
server.url(),
));
let search = assert_success(run(
&config,
&["search", "--mode", "embedding", "dog", "--json"],
server.url(),
));
assert!(search.contains("dog.png"), "got: {search}");
}
#[test]
fn keyword_search_without_embedding_preserves_fuzzy_ranking() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("login.png"), b"login").expect("write image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (id, path) = image_id_for(&config, "login.png");
write_caption(&config, &id, &path, "Login Dialog", "A settings screen");
let stdout = assert_success(run(&config, &["search", "login", "--json"], server.url()));
let first: serde_json::Value =
serde_json::from_str(stdout.lines().next().expect("one keyword search result"))
.expect("json result");
assert_eq!(first["source"], "fuzzy");
assert!(first["path"].as_str().expect("path").ends_with("login.png"));
assert_eq!(
server.request_count(),
0,
"keyword search must not call embedding server"
);
}
#[test]
fn default_search_uses_hybrid_keyword_and_embedding_results() {
let server = FakeEmbeddingServer::start();
let temp = tempfile::tempdir().expect("tempdir");
let config = temp.path().join("state");
let images = temp.path().join("images");
fs::create_dir_all(&images).expect("create images");
fs::write(images.join("literal-login.png"), b"login image bytes").expect("write login image");
fs::write(images.join("dog.png"), b"dog image bytes").expect("write dog image");
assert_success(run(&config, &["init"], server.url()));
assert_success(run(
&config,
&["bootstrap", "--path", images.to_str().expect("utf8")],
server.url(),
));
let (login_id, login_path) = image_id_for(&config, "literal-login.png");
let (dog_id, dog_path) = image_id_for(&config, "dog.png");
write_caption(
&config,
&login_id,
&login_path,
"Login Dialog",
"A settings screen",
);
write_caption(
&config,
&dog_id,
&dog_path,
"Animal Photo",
"puppy playing outside",
);
assert_success(run(
&config,
&["vdr", "sync", "--dimensions", "4"],
server.url(),
));
let stdout = assert_success(run(
&config,
&["search", "dog", "--json", "--limit", "5"],
server.url(),
));
let rows: Vec<serde_json::Value> = stdout
.lines()
.map(|line| serde_json::from_str(line).expect("json result"))
.collect();
assert!(
rows.iter().any(
|row| row["path"].as_str().expect("path").ends_with("dog.png")
&& (row["source"] == "embedding" || row["source"] == "hybrid")
),
"default search should include embedding result, got: {stdout}"
);
}