vipune 0.20.1

A minimal memory layer for AI agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! `reindex --force` CLI output contract (issue #221).
//!
//! `reindex --force` is a thin wrapper over the library's `crate::migration`
//! module: its stdout/stderr and exit codes must stay byte-identical to
//! v0.14.0. These tests drive the wrapper end-to-end (real engine, locally
//! cached model) and pin the rendered contract — the "Migrating from … to
//! …" / "Resuming interrupted migration to …" banners, the pre-flight
//! refusal block (rendered from `MigrationRefused`), the skipped-corrupted
//! note, and the JSON summary — while the pre-flight test below additionally
//! pins the BLOB invariance.
//!
//! The capturing tests spawn the compiled `vipune` binary as a child process
//! with piped stdout/stderr, so the rendered output is captured without any
//! in-process fd manipulation (which conflicts with the test harness's own
//! output reader).

#![cfg(test)]

use crate::memory::crud::test_fake_embedder;
use crate::sqlite::Database;
use crate::sqlite::identity;

/// Knobs the spawned child must not inherit — group 1: every knob
/// `Config::load()` parses, from the one authoritative list in
/// `tests_utils::CONFIG_ENV_VARS`.
///
/// The child inherits the parent's environment, so a single malformed value held
/// by a sibling test at spawn time (`-1`, `abc`) makes the child's
/// `Config::load()` fail and turns a clean pass into a non-zero exit. Removal
/// happens per child rather than by clearing in the parent: these tests never
/// read the knobs back (they pass `--db-path` and expect the default model), and
/// `Command::env_remove` mutates no process-global state. The launch itself is
/// not free, though: spawning reads the parent's environ to build the child's,
/// so `capture_force_output` holds the shared env mutex across the `spawn()`
/// call (and nothing after it), serialising that one instant against every
/// writer in this target.
const CONFIG_ENV_VARS_EXCLUDED: &[&str] = crate::config::tests_utils::CONFIG_ENV_VARS;

/// Group 2: the two knobs `project.rs::detect_project` reads on every
/// invocation (`main.rs` calls it before dispatch), which are outside the
/// `Config` knob list. Harmless for `--all-projects` today, but `VIPUNE_DEBUG`
/// makes `project.rs` `eprintln!` and these tests assert on stderr byte-for-byte.
const PROJECT_ENV_VARS_EXCLUDED: &[&str] = &["VIPUNE_PROJECT", "VIPUNE_DEBUG"];

fn create_test_db() -> (tempfile::TempDir, std::path::PathBuf) {
    let dir = tempfile::TempDir::new().unwrap();
    let path = dir.path().join("test.db");
    Database::open(&path).unwrap();
    (dir, path)
}

fn get_embedding(db: &Database, id: &str) -> Vec<f32> {
    db.list_all_rows_for_project("proj")
        .unwrap()
        .iter()
        .find(|(i, _, _)| i == id)
        .map(|(_, _, e)| e.clone())
        .unwrap()
}

/// Write a migration marker (marker-first step: touches ONLY the marker
/// column; the recorded identity is left untouched).
fn marker_only(conn: &rusqlite::Connection, target: &identity::ModelIdentity) {
    conn.execute(
        "INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
         VALUES (1, NULL, NULL, ?1)
         ON CONFLICT(id) DO UPDATE SET migration_marker = excluded.migration_marker",
        [crate::migration::migration_marker_for(target)],
    )
    .unwrap();
}

/// Path to the compiled `vipune` binary (the test harness builds it).
fn vipune_bin() -> std::path::PathBuf {
    let exe = std::env::current_exe().expect("current_exe");
    let mut path = exe;
    // Walk up from target/debug/deps/<name>-<hash> to target/debug/vipune.
    for _ in 0..4 {
        if let Some(parent) = path.parent() {
            let candidate = parent.join("vipune");
            if candidate.exists() {
                return candidate;
            }
            path = parent.to_path_buf();
        }
    }
    panic!("could not locate the vipune binary for subprocess capture");
}

/// Spawn `vipune reindex --force` as a child process with captured stdout
/// and stderr, and return its exit code plus both streams as strings.
///
/// Subprocess capture is used (rather than in-process fd swapping) because
/// the test harness's own output reader conflicts with in-process fd swaps.
///
/// The shared env mutex is held only across `spawn()` — the call that snapshots
/// the parent's environ, which races a sibling's `set_var` — and released
/// before `wait_with_output()`, so the child's whole run does not sit inside
/// the critical section every env-writer in this target must wait for.
///
/// Callers must not hold the env guard: `ENV_MUTEX` is not reentrant, so
/// acquiring it here while the caller holds it blocks on the caller's own lock.
fn capture_force_output(db_path: &std::path::Path, json: bool) -> (i32, String, String) {
    let mut cmd = std::process::Command::new(vipune_bin());
    cmd.arg("reindex")
        .arg("--force")
        .arg("--all-projects")
        .arg("--db-path")
        .arg(db_path);
    if json {
        cmd.arg("--json");
    }
    cmd.stdin(std::process::Stdio::null());
    cmd.stdout(std::process::Stdio::piped());
    cmd.stderr(std::process::Stdio::piped());
    for var in CONFIG_ENV_VARS_EXCLUDED
        .iter()
        .chain(PROJECT_ENV_VARS_EXCLUDED.iter())
    {
        cmd.env_remove(var);
    }

    let child = {
        let _lock = crate::config::tests_utils::lock_env_mutex();
        cmd.spawn().expect("spawn vipune reindex --force")
    };
    let output = child
        .wait_with_output()
        .expect("wait for vipune reindex --force");
    let code = output.status.code().unwrap_or(-1);
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    (code, stdout, stderr)
}

#[test]
fn test_force_json_success_output() {
    // Clean pass, JSON mode: stdout is exactly the JSON summary array and
    // stderr is empty. Pins the wrapper's JSON rendering contract.
    let (_dir, db_path) = create_test_db();
    let db = Database::open(&db_path).unwrap();
    db.insert(
        "proj",
        "content",
        &test_fake_embedder("c").unwrap(),
        None,
        "fact",
        "active",
    )
    .unwrap();
    drop(db);

    let (exit, out, err) = capture_force_output(&db_path, true);
    assert_eq!(exit, 0, "clean pass must exit 0");
    let parsed: Vec<serde_json::Value> =
        serde_json::from_str(out.trim()).expect("stdout must be the JSON summary");
    assert_eq!(parsed.len(), 1);
    let obj = parsed[0].as_object().expect("summary must be an object");
    assert_eq!(obj["project_id"].as_str(), Some("project proj"));
    assert_eq!(obj["reindexed"].as_u64(), Some(1));
    assert_eq!(obj["skipped"].as_u64(), Some(0));
    assert_eq!(obj["failed"].as_array().map(|a| a.len()), Some(0));
    assert!(
        err.trim().is_empty(),
        "clean pass must write nothing to stderr, got: {err:?}"
    );
}

#[test]
fn test_force_migration_banner_on_mismatch() {
    // Recorded identity differs from the target → the wrapper prints the
    // "Migrating from … to …" banner first, then the success line and the
    // human-mode summary. Pins the banner + success-line rendering.
    let (_dir, db_path) = create_test_db();
    let db = Database::open(&db_path).unwrap();
    db.insert(
        "proj",
        "content",
        &test_fake_embedder("c").unwrap(),
        None,
        "fact",
        "active",
    )
    .unwrap();
    let old = identity::ModelIdentity {
        model_id: "intfloat/multilingual-e5-small".to_string(),
        revision: "614241f622f53c4eeff9890bdc4f31cfecc418b3".to_string(),
    };
    db.conn()
        .execute(
            "INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
             VALUES (1, ?1, ?2, NULL)",
            (&old.model_id, &old.revision),
        )
        .unwrap();
    drop(db);

    let (exit, out, err) = capture_force_output(&db_path, false);
    assert_eq!(exit, 0, "clean pass must exit 0");
    assert_eq!(
        out.lines().next(),
        Some(
            "Migrating from intfloat/multilingual-e5-small@614241f622f53c4eeff9890bdc4f31cfecc418b3 to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a..."
        ),
        "banner must be the first stdout line, got: {out:?}"
    );
    assert!(
        out.contains(
            "Model identity updated to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a (marker cleared)."
        ),
        "success line missing, got: {out:?}"
    );
    assert!(out.contains("Total across project proj:"));
    assert!(out.contains("  Reindexed: 1"));
    assert!(out.contains("  Skipped:   0"));
    assert!(out.contains("  Failed:    0"));
    assert!(
        err.trim().is_empty(),
        "clean pass must write nothing to stderr, got: {err:?}"
    );

    // Post-state: identity recorded, marker cleared.
    let db = Database::open(&db_path).unwrap();
    assert_eq!(
        identity::read_identity(db.conn()).unwrap(),
        Some(identity::ModelIdentity::default_identity())
    );
    assert_eq!(identity::read_marker(db.conn()).unwrap(), None);
}

#[test]
fn test_force_resuming_banner_when_marker_present() {
    // A marker is already present (interrupted migration) → the wrapper
    // prints "Resuming interrupted migration to …" instead of the banner.
    let (_dir, db_path) = create_test_db();
    let db = Database::open(&db_path).unwrap();
    db.insert(
        "proj",
        "content",
        &test_fake_embedder("c").unwrap(),
        None,
        "fact",
        "active",
    )
    .unwrap();
    marker_only(db.conn(), &identity::ModelIdentity::default_identity());
    drop(db);

    let (exit, out, _err) = capture_force_output(&db_path, false);
    assert_eq!(exit, 0, "clean pass must exit 0");
    assert_eq!(
        out.lines().next(),
        Some(
            "Resuming interrupted migration to BAAI/bge-small-en-v1.5@5c38ec7c405ec4b44b94cc5a9bb96e735b38267a..."
        ),
        "resuming banner must be the first stdout line, got: {out:?}"
    );
}

#[test]
fn test_force_skipped_corrupted_note_and_success_exit() {
    // A corrupted (zero-vector) row is skipped, not failed: the wrapper
    // still exits 0 and prints the skipped-corrupted note to stderr.
    let (_dir, db_path) = create_test_db();
    let db = Database::open(&db_path).unwrap();
    db.insert(
        "proj",
        "content",
        &test_fake_embedder("c").unwrap(),
        None,
        "fact",
        "active",
    )
    .unwrap();
    db.insert("proj", "bad", &vec![0.0; 384], None, "fact", "active")
        .unwrap();
    drop(db);

    let (exit, _out, err) = capture_force_output(&db_path, false);
    assert_eq!(exit, 0, "clean pass must exit 0");
    assert_eq!(
        err.trim(),
        "1 row(s) had corrupted embeddings and were skipped",
        "skipped-corrupted note must be byte-identical"
    );
}

/// Drive the wrapper's per-row-failure path with a failing embed closure
/// through `crate::migration::migrate_model_with_embedder` (the same
/// lifecycle the wrapper delegates to). Pins the `MigrationIncomplete`
/// error shape the wrapper renders (failure count + marker-stays note) plus
/// the post-state: marker kept, old identity untouched, failed row's BLOB
/// stale.
#[test]
fn test_force_per_row_failure_error_shape_and_post_state() {
    let (_dir, db_path) = create_test_db();
    let mut db = Database::open(&db_path).unwrap();
    db.insert(
        "proj",
        "ok content",
        &test_fake_embedder("c").unwrap(),
        None,
        "fact",
        "active",
    )
    .unwrap();
    let fail_id = db
        .insert(
            "proj",
            "bad content",
            &test_fake_embedder("d").unwrap(),
            None,
            "fact",
            "active",
        )
        .unwrap();

    // `Config::default()` rather than `Config::load()`: `load()` parses every
    // `VIPUNE_*` knob and returns `Err` on a malformed value, which would make
    // this `unwrap()` panic whenever a sibling writer holds one. The migration
    // reads only `embedding_model`, and both it and `database_path` are set
    // below, so this test reads no `VIPUNE_*` knob (`Config::default()` still
    // resolves `HOME` through `dirs::home_dir()`, which no assertion here
    // depends on).
    let config = crate::config::Config {
        database_path: db_path.clone(),
        embedding_model: "BAAI/bge-small-en-v1.5".to_string(),
        ..Default::default()
    };
    let mut embed = |content: &str| {
        if content == "bad content" {
            Err(crate::sqlite::Error::Sqlite(
                "simulated embed failure".to_string(),
            ))
        } else {
            test_fake_embedder(content).map_err(|e| crate::sqlite::Error::Sqlite(e.to_string()))
        }
    };
    let mut count = |content: &str| -> Result<usize, crate::sqlite::Error> {
        Ok(content.split_whitespace().count())
    };
    let result =
        crate::migration::migrate_model_with_embedder(&mut db, &config, &mut embed, &mut count);
    let err = result.expect_err("a failing row must refuse the pass");
    match err {
        crate::sqlite::Error::MigrationIncomplete { report } => {
            assert_eq!(report.failures.len(), 1, "one failure in the report");
            assert_eq!(report.failures[0].id, fail_id);
        }
        other => panic!("expected MigrationIncomplete, got {other:?}"),
    }

    // Post-state: marker kept, old identity (unrecorded = default) kept,
    // failed row's BLOB stale, ok row re-embedded.
    assert!(
        identity::is_migrating(db.conn()).unwrap(),
        "marker must stay"
    );
    assert_eq!(
        identity::read_identity(db.conn()).unwrap(),
        None,
        "old identity (unrecorded default) must be kept"
    );
    assert_eq!(
        get_embedding(&db, &fail_id),
        test_fake_embedder("d").unwrap(),
        "failed row keeps its stale vector"
    );
}

#[test]
fn test_force_preflight_refusal_cli_output_and_blob_invariance() {
    // Pre-flight: a row over the 512-token limit once the target profile's
    // passage prefix is applied must refuse the start with the rendered
    // refusal block, exit 1, no marker, and byte-identical embedding BLOBs.
    // (The bin pre-flight test only checks the offending-id list; this
    // pins the CLI-level no-marker/no-row-changed invariance.)
    let (_dir, db_path) = create_test_db();
    let db = Database::open(&db_path).unwrap();

    // 600 words is safely over the 512-token limit for any prefix.
    let long_content: String = (0..600)
        .map(|i| format!("word{i}"))
        .collect::<Vec<_>>()
        .join(" ");
    let long_id = db
        .insert(
            "proj",
            &long_content,
            &test_fake_embedder("long").unwrap(),
            None,
            "fact",
            "active",
        )
        .unwrap();
    let short_id = db
        .insert(
            "proj",
            "short content",
            &test_fake_embedder("short").unwrap(),
            None,
            "fact",
            "active",
        )
        .unwrap();
    let long_blob_before = get_embedding(&db, &long_id);
    let short_blob_before = get_embedding(&db, &short_id);
    drop(db);

    let (exit, _out, err) = capture_force_output(&db_path, false);
    assert_eq!(exit, 1, "pre-flight refusal must exit 1");
    // The rendered refusal block: header, offending id, footer.
    assert!(
        err.contains(
            "Error: reindex --force refused to start: 1 row(s) exceed the 512-token limit once the '' passage prefix is prepended. Fix or remove these memories, then re-run `vipune reindex --force`:"
        ),
        "refusal header missing, got: {err:?}"
    );
    assert!(
        err.contains(&format!("  {long_id}")),
        "offending id missing from refusal block"
    );
    assert!(
        !err.contains(&format!("  {short_id}")),
        "short row must not be reported"
    );
    assert!(
        err.contains("No migration marker was written and no rows were changed."),
        "refusal footer missing"
    );

    // BLOB invariance + no marker at the CLI level.
    let db = Database::open(&db_path).unwrap();
    assert_eq!(
        get_embedding(&db, &long_id),
        long_blob_before,
        "long row BLOB must be byte-identical"
    );
    assert_eq!(
        get_embedding(&db, &short_id),
        short_blob_before,
        "short row BLOB must be byte-identical"
    );
    assert_eq!(
        identity::read_marker(db.conn()).unwrap(),
        None,
        "no marker on pre-flight refusal"
    );
}