semantex-core 1.1.0

Core library for semantex semantic code search (indexing, embeddings, search)
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
450
//! Index state detection — determines whether a project's semantex index is ready,
//! currently being built, stale (schema mismatch), or absent.

use crate::config::SemantexConfig;
use crate::types::IndexMeta;
use std::path::Path;

/// The current state of a project's semantex index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexState {
    /// No index exists for this project.
    NotIndexed,
    /// An index build is currently in progress (lock held).
    Building,
    /// Index exists but has an outdated schema version — needs rebuild.
    Stale,
    /// Index exists and is ready for queries.
    Ready,
}

/// Detect the index state for a project at `project_path`.
///
/// Checks for `<project>/.semantex/meta.json` existence, validates schema version,
/// then probes the lock file with a non-blocking `flock` to distinguish
/// `Building` from `Ready`.
pub fn detect(project_path: &Path) -> IndexState {
    let semantex_dir = project_path.join(".semantex");
    let meta_path = semantex_dir.join("meta.json");

    if !meta_path.exists() {
        // No meta.json — check if a build is in progress via lock file
        let lock_path = semantex_dir.join(".semantex.lock");
        if is_locked(&lock_path) {
            return IndexState::Building;
        }
        return IndexState::NotIndexed;
    }

    // Meta exists — check schema version
    if is_stale(&meta_path) {
        return IndexState::Stale;
    }

    // Check if a rebuild is in progress
    let lock_path = semantex_dir.join(".semantex.lock");
    if is_locked(&lock_path) {
        return IndexState::Building;
    }

    IndexState::Ready
}

/// Detect the index state, additionally treating an embedder-fingerprint change
/// as `Stale` so it AUTO-REBUILDS (S8). Identical to [`detect`] except the
/// staleness test also compares the persisted `meta.embedder_fingerprint` to the
/// fingerprint the active config's embedder resolves to.
///
/// The expected fingerprint is computed from the model registry
/// (`ModelRegistry::resolve_embedder_fingerprint`, mirroring `builder.rs`). If
/// the registry CANNOT resolve a fingerprint (any error — a malformed
/// `models.toml`, an unknown embedder id, etc.), this falls back to the
/// schema-only [`detect`] and NEVER returns `Stale` spuriously on a registry
/// error: a transient config problem must not nuke a perfectly good index.
pub fn detect_for_config(project_path: &Path, config: &SemantexConfig) -> IndexState {
    let base = detect(project_path);
    // Only the Ready arm can be "downgraded" to Stale by a fingerprint change —
    // NotIndexed/Building/Stale already short-circuit any rebuild decision.
    if base != IndexState::Ready {
        return base;
    }
    // Resolve the expected fingerprint; on ANY registry error, keep the
    // schema-only verdict (Ready) — never spuriously Stale.
    let Ok(expected) =
        crate::model::ModelRegistry::resolve_embedder_fingerprint(config, Some(project_path))
    else {
        return base;
    };
    let meta_path = project_path.join(".semantex").join("meta.json");
    if is_stale_for_embedder(&meta_path, &expected) {
        return IndexState::Stale;
    }
    base
}

/// Returns the age of the index in seconds since its last update, or `None` if
/// no index exists or the timestamp cannot be parsed.
pub fn index_age_secs(project_path: &Path) -> Option<u64> {
    let meta_path = project_path.join(".semantex").join("meta.json");
    let content = std::fs::read_to_string(meta_path).ok()?;
    let meta: crate::types::IndexMeta = serde_json::from_str(&content).ok()?;
    // updated_at is stored as Unix epoch seconds (plain integer string)
    let updated_epoch: u64 = meta.updated_at.trim().parse().ok()?;
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .ok()?
        .as_secs();
    Some(now.saturating_sub(updated_epoch))
}

/// Check if the index has an outdated schema version.
///
/// Exposed so callers that already know `meta.json` is present can re-validate
/// without re-running the full `detect` pass (used by the MCP warm-state fast
/// path to cheaply enforce the staleness invariant).
pub fn is_stale(meta_path: &Path) -> bool {
    let Ok(content) = std::fs::read_to_string(meta_path) else {
        return true; // unreadable meta.json → treat as stale
    };
    let meta: IndexMeta = match serde_json::from_str(&content) {
        Ok(m) => m,
        Err(_) => return true, // unparseable meta → treat as stale
    };
    meta.schema_version != IndexMeta::CURRENT_SCHEMA_VERSION
}

/// Check staleness for a SPECIFIC embedder: stale if [`is_stale`] (schema
/// mismatch / unreadable meta) OR the persisted `meta.embedder_fingerprint`
/// differs from `expected_fingerprint`. The latter means the index was embedded
/// under a different vector space (e.g. a model swap or toggling
/// `SEMANTEX_DENSE_CONTEXT`), so it must be re-embedded (S8 auto-rebuild path).
///
/// PURE — takes the expected fingerprint as a string; the caller (which has the
/// config) supplies it, so this stays free of any model/registry import and is
/// cheap to call.
pub fn is_stale_for_embedder(meta_path: &Path, expected_fingerprint: &str) -> bool {
    if is_stale(meta_path) {
        return true;
    }
    // is_stale already proved meta is readable + parseable + schema-current.
    let Ok(content) = std::fs::read_to_string(meta_path) else {
        return true;
    };
    match serde_json::from_str::<IndexMeta>(&content) {
        Ok(meta) => meta.embedder_fingerprint != expected_fingerprint,
        Err(_) => true,
    }
}

/// Try to acquire a non-blocking exclusive lock on the file.
/// Returns `true` if the file is currently locked by another process.
/// Uses `File::try_lock()` (stabilized in Rust 1.84) for cross-platform support.
///
/// Exposed so warm-state fast paths can cheaply re-validate that no concurrent
/// rebuild is in progress without re-running the full `detect` pass. A single
/// `flock` syscall — sub-microsecond on warm cache.
pub fn is_locked(lock_path: &Path) -> bool {
    let Ok(file) = std::fs::File::open(lock_path) else {
        return false;
    };

    match file.try_lock() {
        Err(std::fs::TryLockError::WouldBlock) => {
            // Another process holds the lock.
            true
        }
        // Ok: we acquired the lock — nobody else holds it (released on drop).
        // Error: other error (NFS, unsupported FS) — assume not locked.
        Ok(()) | Err(std::fs::TryLockError::Error(_)) => false,
    }
}

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

    #[test]
    fn test_not_indexed() {
        let tmp = TempDir::new().unwrap();
        assert_eq!(detect(tmp.path()), IndexState::NotIndexed);
    }

    #[test]
    fn test_ready() {
        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        let meta = serde_json::json!({
            "schema_version": IndexMeta::CURRENT_SCHEMA_VERSION,
            "project_path": tmp.path(),
            "created_at": "2026-01-01T00:00:00Z",
            "updated_at": "2026-01-01T00:00:00Z",
            "file_count": 10,
            "chunk_count": 50,
            "embedding_model": "test",
            "embedding_dim": 48,
            "use_bm25_stemmer": true,
            "dense_backend": "coderank-hnsw",
            "embedder_fingerprint": "test",
        });
        std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
        assert_eq!(detect(tmp.path()), IndexState::Ready);
    }

    #[test]
    fn test_stale_schema() {
        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        let meta = serde_json::json!({
            "schema_version": 1,
            "project_path": tmp.path(),
            "created_at": "2026-01-01T00:00:00Z",
            "updated_at": "2026-01-01T00:00:00Z",
            "file_count": 10,
            "chunk_count": 50,
            "embedding_model": "test",
            "embedding_dim": 48
        });
        std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
        assert_eq!(detect(tmp.path()), IndexState::Stale);
    }

    #[test]
    fn test_unreadable_meta_treated_as_stale() {
        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        // Write invalid JSON to meta.json
        std::fs::write(semantex_dir.join("meta.json"), "not valid json").unwrap();
        assert_eq!(detect(tmp.path()), IndexState::Stale);
    }

    #[test]
    fn test_building_with_lock() {
        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        let lock_path = semantex_dir.join(".semantex.lock");
        let lock_file = std::fs::File::create(&lock_path).unwrap();

        // Hold an exclusive lock (cross-platform)
        lock_file.lock().expect("Failed to acquire test lock");

        assert_eq!(detect(tmp.path()), IndexState::Building);

        // Lock released when lock_file drops
    }

    /// Regression: a pre-v0.3 index (schema_version=7) must be detected as
    /// `Stale` after the v8 bump, so the MCP/CLI rebuild path runs and creates
    /// the v0.3 auxiliary tables (`chunk_annotations`, `pattern_matches`,
    /// `chunk_centrality`) before M5/M6 handlers query them.
    #[test]
    fn test_pre_v0_3_schema_v7_is_stale() {
        // Sanity: this test only makes sense as long as the current schema
        // is past v7. If we ever roll back, the test should be updated.
        const { assert!(IndexMeta::CURRENT_SCHEMA_VERSION > 7) };

        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();

        let meta = IndexMeta {
            schema_version: 7,
            project_path: tmp.path().to_path_buf(),
            created_at: "0".to_string(),
            updated_at: "0".to_string(),
            file_count: 0,
            chunk_count: 0,
            embedding_model: "test".to_string(),
            embedding_dim: 48,
            use_bm25_stemmer: true,
            dense_backend: "coderank-hnsw".to_string(),
            embedder_fingerprint: "test".to_string(),
        };
        let meta_json = serde_json::to_string(&meta).unwrap();
        std::fs::write(semantex_dir.join("meta.json"), meta_json).unwrap();

        assert_eq!(detect(tmp.path()), IndexState::Stale);
    }

    /// Helper: write a current-schema meta.json carrying `fingerprint` into
    /// `<project>/.semantex/` and return the project dir.
    fn write_meta_with_fp(tmp: &TempDir, fingerprint: &str) {
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        let meta = IndexMeta {
            schema_version: IndexMeta::CURRENT_SCHEMA_VERSION,
            project_path: tmp.path().to_path_buf(),
            created_at: "0".to_string(),
            updated_at: "0".to_string(),
            file_count: 1,
            chunk_count: 1,
            embedding_model: "test".to_string(),
            embedding_dim: 48,
            use_bm25_stemmer: true,
            dense_backend: "coderank-hnsw".to_string(),
            embedder_fingerprint: fingerprint.to_string(),
        };
        std::fs::write(
            semantex_dir.join("meta.json"),
            serde_json::to_string(&meta).unwrap(),
        )
        .unwrap();
    }

    #[test]
    fn is_stale_for_embedder_true_on_fingerprint_mismatch() {
        let tmp = TempDir::new().unwrap();
        write_meta_with_fp(&tmp, "OLDFP");
        let meta_path = tmp.path().join(".semantex").join("meta.json");
        // Schema is current but the fingerprint differs → stale.
        assert!(is_stale_for_embedder(&meta_path, "NEWFP"));
    }

    #[test]
    fn is_stale_for_embedder_false_on_fingerprint_match() {
        let tmp = TempDir::new().unwrap();
        write_meta_with_fp(&tmp, "SAMEFP");
        let meta_path = tmp.path().join(".semantex").join("meta.json");
        assert!(!is_stale_for_embedder(&meta_path, "SAMEFP"));
    }

    #[test]
    fn is_stale_for_embedder_true_when_schema_stale_regardless_of_fp() {
        // Schema mismatch dominates: even a "matching" fingerprint is stale.
        let tmp = TempDir::new().unwrap();
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::create_dir_all(&semantex_dir).unwrap();
        let meta = serde_json::json!({
            "schema_version": 1,
            "project_path": tmp.path(),
            "created_at": "0", "updated_at": "0",
            "file_count": 1, "chunk_count": 1,
            "embedding_model": "test", "embedding_dim": 48,
            "use_bm25_stemmer": true,
            "dense_backend": "coderank-hnsw",
            "embedder_fingerprint": "SAMEFP",
        });
        std::fs::write(semantex_dir.join("meta.json"), meta.to_string()).unwrap();
        let meta_path = semantex_dir.join("meta.json");
        assert!(is_stale_for_embedder(&meta_path, "SAMEFP"));
    }

    #[test]
    fn detect_for_config_stale_on_fingerprint_mismatch() {
        // A meta.json with a fingerprint that does NOT match the config's active
        // embedder must come back Stale (S8 auto-rebuild trigger).
        let tmp = TempDir::new().unwrap();
        write_meta_with_fp(&tmp, "definitely-not-the-real-fingerprint");
        let cfg = crate::config::SemantexConfig::default();
        assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Stale);
    }

    #[test]
    fn detect_for_config_ready_on_fingerprint_match() {
        // Stamp the REAL fingerprint the default config resolves to → Ready.
        // `resolve_embedder_fingerprint` reads SEMANTEX_DENSE_CONTEXT, so hold the
        // env lock to avoid racing the ctx-flip test (which mutates that var).
        let _g = DENSE_CONTEXT_ENV_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let tmp = TempDir::new().unwrap();
        let cfg = crate::config::SemantexConfig::default();
        let expected =
            crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
                .expect("default embedder must resolve a fingerprint");
        write_meta_with_fp(&tmp, &expected);
        assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Ready);
    }

    #[test]
    fn detect_for_config_falls_back_to_schema_only_on_registry_error() {
        // A malformed project models.toml makes the registry fail to resolve a
        // fingerprint. detect_for_config MUST NOT spuriously return Stale on a
        // registry error — it falls back to schema-only detect (Ready here).
        let tmp = TempDir::new().unwrap();
        // Stamp a fingerprint that would MISMATCH the default, to prove the
        // verdict comes from the schema-only fallback (Ready) and not from a
        // fingerprint comparison (which would say Stale).
        write_meta_with_fp(&tmp, "some-fingerprint-that-would-mismatch");
        let semantex_dir = tmp.path().join(".semantex");
        std::fs::write(
            semantex_dir.join("models.toml"),
            // Invalid: not valid TOML for the manifest schema.
            "this is = = not valid toml [[[",
        )
        .unwrap();
        let cfg = crate::config::SemantexConfig::default();
        // Sanity: the registry really does error for this project.
        assert!(
            crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
                .is_err(),
            "test precondition: malformed models.toml must fail registry resolution"
        );
        assert_eq!(detect_for_config(tmp.path(), &cfg), IndexState::Ready);
    }

    /// Serialize SEMANTEX_DENSE_CONTEXT env mutation across tests (process-global
    /// env is not thread-safe). Local to this module — no other test touches this
    /// var, so a private lock suffices.
    static DENSE_CONTEXT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    #[test]
    fn detect_for_config_stale_when_dense_context_differs_from_build() {
        // F5 core: an index built under one SEMANTEX_DENSE_CONTEXT setting must
        // be detected Stale (→ auto-rebuild) when the runtime flag is flipped,
        // because the flag is part of the embedder fingerprint (it changes the
        // embedded text → the vector space).
        let _g = DENSE_CONTEXT_ENV_LOCK
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let prior = std::env::var("SEMANTEX_DENSE_CONTEXT").ok();

        let tmp = TempDir::new().unwrap();
        let cfg = crate::config::SemantexConfig::default();

        // Build-time fingerprint: dense_context OFF.
        // SAFETY: guarded by DENSE_CONTEXT_ENV_LOCK.
        unsafe { std::env::remove_var("SEMANTEX_DENSE_CONTEXT") };
        let fp_off =
            crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
                .expect("resolve fp (ctx off)");
        write_meta_with_fp(&tmp, &fp_off);
        // Same setting → Ready.
        assert_eq!(
            detect_for_config(tmp.path(), &cfg),
            IndexState::Ready,
            "same dense_context setting must be Ready"
        );

        // Flip the runtime flag ON → the expected fingerprint changes → Stale.
        // SAFETY: guarded by DENSE_CONTEXT_ENV_LOCK.
        unsafe { std::env::set_var("SEMANTEX_DENSE_CONTEXT", "1") };
        let fp_on =
            crate::model::ModelRegistry::resolve_embedder_fingerprint(&cfg, Some(tmp.path()))
                .expect("resolve fp (ctx on)");
        assert_ne!(
            fp_off, fp_on,
            "dense_context must change the resolved fingerprint"
        );
        let verdict = detect_for_config(tmp.path(), &cfg);

        // Restore env BEFORE asserting so a failure can't leak the var.
        // SAFETY: guarded by DENSE_CONTEXT_ENV_LOCK.
        unsafe {
            match prior {
                Some(v) => std::env::set_var("SEMANTEX_DENSE_CONTEXT", v),
                None => std::env::remove_var("SEMANTEX_DENSE_CONTEXT"),
            }
        }

        assert_eq!(
            verdict,
            IndexState::Stale,
            "flipping SEMANTEX_DENSE_CONTEXT must mark the index Stale (auto-rebuild)"
        );
    }
}