vipune 0.12.0

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
//! `vipune doctor --embeddings` and `vipune doctor --projects` handlers.
//!
//! `--embeddings`: reports per-project total / real / mock / unknown rows.
//! `--projects`: scans all projects for suspected split pairs (bare id vs owner/repo).

use crate::errors::Error;
use crate::output::{DoctorProjectsResponse, DoctorResponse, print_json};
use crate::sqlite::Database;
use crate::sqlite::embedding::classify_embedding;
use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::Path;
use std::process::ExitCode;

/// Wrap a rusqlite::Error, converting SQLITE_BUSY into the actionable MCP-server message.
fn wrap_rusqlite_busy<T>(result: Result<T, rusqlite::Error>) -> Result<T, Error> {
    match result {
        Ok(v) => Ok(v),
        Err(e) if e.to_string().contains("database is locked") => Err(Error::Config(
            "Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string()
        )),
        Err(e) => Err(Error::Config(e.to_string())),
    }
}

/// Wrap a database error, converting SQLITE_BUSY into the actionable MCP-server message.
fn wrap_busy<T>(result: Result<T, Error>) -> Result<T, Error> {
    match result {
        Ok(v) => Ok(v),
        Err(Error::SqliteModule(msg)) if msg.contains("database is locked") => {
            Err(Error::Config(
                "Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string()
            ))
        }
        Err(e) => Err(e),
    }
}

/// Run the embedding doctor check on the database.
///
/// # Arguments
///
/// * `db_path` - Path to the SQLite database
/// * `project_filter` - If Some, only check this project; if None, check all projects
/// * `json` - If true, output JSON; otherwise human-readable
///
/// # Errors
///
/// Returns error if the database cannot be opened or queried.
pub fn handle_doctor(
    db_path: &Path,
    project_filter: Option<&str>,
    json: bool,
) -> Result<ExitCode, Error> {
    // Open database
    let db = Database::open(db_path).map_err(|e| {
        let err_msg = e.to_string();
        if err_msg.contains("database is locked") {
            return Error::Config(
                "Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string()
            );
        }
        Error::Config(err_msg)
    })?;

    // Determine which projects to audit
    let all_project_ids: Vec<String> = wrap_busy(db.list_all_project_ids().map_err(Error::from))?;

    // When scoped to a single project, warn about other projects in the
    // database so the report is not misread as covering the whole store.
    if let Some(filter) = project_filter {
        if !json {
            let other_count = all_project_ids.iter().filter(|pid| *pid != filter).count();
            if let Some(hint) = scoped_project_hint(other_count) {
                println!("{}", hint);
            }
        }
    }

    let projects: Vec<String> = if let Some(filter) = project_filter {
        vec![filter.to_string()]
    } else {
        all_project_ids
    };

    if projects.is_empty() {
        if json {
            print_json(&[DoctorResponse {
                project_id: "(none)".to_string(),
                total_rows: 0,
                real_rows: 0,
                mock_rows: 0,
                unknown_rows: 0,
            }]);
        } else {
            println!("No projects found in database.");
        }
        return Ok(ExitCode::SUCCESS);
    }

    let mut responses: Vec<DoctorResponse> = vec![];

    for project_id in &projects {
        let result = wrap_busy(audit_project(&db, project_id))?;
        responses.push(DoctorResponse {
            project_id: project_id.clone(),
            total_rows: result.total,
            real_rows: result.real_count,
            mock_rows: result.mock_count,
            unknown_rows: result.unknown_count,
        });

        if !json {
            println!("Project: {}", project_id);
            println!("  Total rows: {}", result.total);
            println!("  Real:     {}", result.real_count);
            println!("  Mock:     {}", result.mock_count);
            println!("  Unknown:  {}", result.unknown_count);
            if result.mock_count > 0 || result.unknown_count > 0 {
                println!(
                    "  → Run 'vipune reindex' to repair mock rows ({} candidates)",
                    result.mock_count
                );
            }
        }
    }

    // Print JSON: single array of all project responses
    if json {
        print_json(&responses);
    }

    Ok(ExitCode::SUCCESS)
}

/// Build the hint shown when the embeddings audit is scoped to a single
/// project but other projects exist in the database, so the scoped report is
/// not misread as covering the whole store.
///
/// Returns `None` when there are no other projects (nothing to hint about).
pub(crate) fn scoped_project_hint(other_project_count: usize) -> Option<String> {
    if other_project_count == 0 {
        return None;
    }
    Some(format!(
        "Note: {} other project(s) in this database. Run 'vipune doctor --embeddings' without -p to audit them all.",
        other_project_count
    ))
}

struct AuditResult {
    total: usize,
    real_count: usize,
    mock_count: usize,
    unknown_count: usize,
}

fn audit_project(db: &Database, project_id: &str) -> Result<AuditResult, Error> {
    let rows = db.list_all_rows_for_project(project_id)?;

    let mut result = AuditResult {
        total: 0,
        real_count: 0,
        mock_count: 0,
        unknown_count: 0,
    };

    for (_id, _content, embedding) in rows {
        result.total += 1;
        match classify_embedding(&embedding) {
            crate::sqlite::embedding::EmbeddingClass::Real => result.real_count += 1,
            crate::sqlite::embedding::EmbeddingClass::Mock => result.mock_count += 1,
            crate::sqlite::embedding::EmbeddingClass::Unknown => result.unknown_count += 1,
        }
    }

    Ok(result)
}

/// Run the project split detection scan.
pub fn handle_doctor_projects(
    db_path: &Path,
    project_filter: Option<&str>,
    json: bool,
) -> Result<ExitCode, Error> {
    // Warn if -p was passed alongside --projects (silently ignored but user likely expects it to apply)
    if let Some(filter) = project_filter {
        eprintln!(
            "Warning: -p/--project is ignored for doctor --projects (scan must cover all projects to detect splits). Filter '{}' was not applied.",
            filter
        );
    }

    let response = collect_doctor_projects_response(db_path)?;

    if json {
        print_json(&response);
    } else {
        print_human_projects(&response);
    }

    Ok(ExitCode::SUCCESS)
}

/// Collect suspected split pairs from the database, returning the response struct.
///
/// Opens the database in read-only mode and runs the split-detection heuristic.
/// Does not print anything — caller decides how to render the output.
///
/// # Errors
///
/// Returns error if the database cannot be opened or queried.
pub(crate) fn collect_doctor_projects_response(
    db_path: &Path,
) -> Result<DoctorProjectsResponse, Error> {
    // Open database in READ-ONLY mode — this is a diagnostic that must not modify the DB.
    // Any accidental write will fail with SQLITE_READONLY instead of silently corrupting data.
    let db = Database::from_conn(wrap_rusqlite_busy(Connection::open_with_flags(
        db_path,
        OpenFlags::SQLITE_OPEN_READ_ONLY,
    ))?);

    // Gather all project ids with row counts
    let project_ids = wrap_busy(db.list_all_project_ids().map_err(Error::from))?;

    let mut counts: HashMap<String, usize> = HashMap::new();
    for pid in &project_ids {
        counts.insert(
            pid.clone(),
            wrap_busy(db.count_rows_for_project(pid).map_err(Error::from))?,
        );
    }

    // Detect suspected split pairs using pure heuristic function.
    let suspected_splits = detect_split_pairs(&project_ids, &counts);

    // Build response.
    Ok(DoctorProjectsResponse {
        suspected_splits: suspected_splits
            .iter()
            .map(
                |(bare, owned)| crate::output::DoctorProjectsSuspectedSplit {
                    pair: [bare.clone(), owned.clone()],
                    row_counts: [
                        *counts.get(bare).unwrap_or(&0),
                        *counts.get(owned).unwrap_or(&0),
                    ],
                },
            )
            .collect(),
    })
}

/// Print human-readable output for the projects doctor check.
fn print_human_projects(response: &DoctorProjectsResponse) {
    if response.suspected_splits.is_empty() {
        println!("No suspected project splits found.");
        return;
    }

    println!("Suspected project splits:");
    println!();

    for split in &response.suspected_splits {
        println!(
            "  '{}' ({} rows)  +  '{}' ({} rows)",
            split.pair[0], split.row_counts[0], split.pair[1], split.row_counts[1]
        );
    }

    println!();
    println!(
        "These are suspected pairs — confirm they represent the same repository before merging."
    );
    println!("Known false positives:");
    println!("  - a genuinely separate project whose directory name matches");
    println!("    another project's repo name (e.g. 'ci-runner' vs 'team/ci-runner')");
    println!("  - multi-slash project ids where the segment after the first '/'");
    println!("    also exists as a project id (e.g. 'a/b' vs 'c/a/b')");
    println!();
    println!("To merge confirmed pairs, run:");
    println!("  vipune project merge <from> <to>");
}

/// Detect suspected project split pairs using the bare-id heuristic.
///
/// For each owned id (containing "/"), extract the segment after the first "/".
/// If that segment exists as a separate project_id, the pair is a suspected split.
/// Returns all matching pairs sorted by (segment, owned) — multiple owned ids
/// with the same segment are all reported independently.
///
/// # Arguments
///
/// * `project_ids` - Sorted list of all project ids in the database.
/// * `counts` - Map from project id to its row count.
///
/// # Returns
///
/// Sorted list of `(segment, owned)` pairs. Each pair is unique (owned ids are
/// distinct, so no deduplication is needed).
pub(crate) fn detect_split_pairs(
    project_ids: &[String],
    counts: &HashMap<String, usize>,
) -> Vec<(String, String)> {
    let mut suspected_splits: Vec<(String, String)> = Vec::new();

    for owned_str in project_ids {
        // Extract the segment after the first "/". Skip ids without "/".
        let (_, segment) = match owned_str.split_once('/') {
            Some(parts) => parts,
            None => continue,
        };
        // Check if the segment exists as a separate project_id.
        if counts.contains_key(segment) {
            suspected_splits.push((segment.to_string(), owned_str.clone()));
        }
    }

    // Sort by segment, then by owned id — deterministic output for identical input.
    suspected_splits.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
    suspected_splits
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sqlite::Database;
    use tempfile::TempDir;

    /// Helper: create a test database with known embeddings.
    fn setup_test_db() -> (TempDir, Database) {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test.db");
        let db = Database::open(&path).unwrap();
        (dir, db)
    }

    #[test]
    fn test_doctor_empty_database() {
        let (_dir, db) = setup_test_db();
        let result = audit_project(&db, "nonexistent-project").unwrap();
        assert_eq!(result.total, 0);
        assert_eq!(result.real_count, 0);
        assert_eq!(result.mock_count, 0);
        assert_eq!(result.unknown_count, 0);
    }

    #[test]
    fn test_doctor_classifies_real_embeddings() {
        let (_dir, db) = setup_test_db();
        // Insert a real (L2-normalised) embedding
        let mut vec = vec![0.0f32; 384];
        vec[0] = 1.0; // norm = 1.0 → Real
        db.insert("test-proj", "real memory", &vec, None, "fact", "active")
            .unwrap();

        let result = audit_project(&db, "test-proj").unwrap();
        assert_eq!(result.total, 1);
        assert_eq!(result.real_count, 1);
        assert_eq!(result.mock_count, 0);
        assert_eq!(result.unknown_count, 0);
    }

    #[test]
    fn test_doctor_classifies_mock_embeddings() {
        let (_dir, db) = setup_test_db();
        // Insert a mock-like embedding (uniform ones, norm ≈ 19.6)
        let vec = vec![1.0f32; 384];
        db.insert("test-proj", "mock memory", &vec, None, "fact", "active")
            .unwrap();

        let result = audit_project(&db, "test-proj").unwrap();
        assert_eq!(result.total, 1);
        assert_eq!(result.real_count, 0);
        assert_eq!(result.mock_count, 1);
        assert_eq!(result.unknown_count, 0);
    }

    #[test]
    fn test_scoped_project_hint_names_other_projects() {
        let hint = scoped_project_hint(14).expect("hint for 14 other projects");
        assert!(
            hint.contains("14 other project(s) in this database"),
            "hint must name the count of other projects: {hint}"
        );
        assert!(hint.contains("vipune doctor --embeddings"));
    }

    #[test]
    fn test_scoped_project_hint_singular() {
        let hint = scoped_project_hint(1).expect("hint for 1 other project");
        assert!(hint.contains("1 other project(s)"), "got: {hint}");
    }

    #[test]
    fn test_scoped_project_hint_none_when_no_other_projects() {
        assert!(scoped_project_hint(0).is_none());
    }

    #[test]
    fn test_doctor_mixed_classifications() {
        let (_dir, db) = setup_test_db();
        // Real: norm = 1.0
        let mut real_vec = vec![0.0f32; 384];
        real_vec[0] = 1.0;
        db.insert("proj", "real", &real_vec, None, "fact", "active")
            .unwrap();
        // Mock: norm ≈ 19.6
        let mock_vec = vec![1.0f32; 384];
        db.insert("proj", "mock", &mock_vec, None, "fact", "active")
            .unwrap();
        // Unknown: norm = 0
        let unknown_vec = vec![0.0f32; 384];
        db.insert("proj", "unknown", &unknown_vec, None, "fact", "active")
            .unwrap();

        let result = audit_project(&db, "proj").unwrap();
        assert_eq!(result.total, 3);
        assert_eq!(result.real_count, 1);
        assert_eq!(result.mock_count, 1);
        assert_eq!(result.unknown_count, 1);
    }
}