Skip to main content

sqlite_graphrag/commands/
health.rs

1//! Handler for the `health` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use serde::Serialize;
8use std::fs;
9use std::time::Instant;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Check database health (connectivity, integrity, vector index)\n  \
14    sqlite-graphrag health\n\n  \
15    # Check health of a database at a custom path\n  \
16    sqlite-graphrag health --db /path/to/graphrag.sqlite\n\n  \
17    # Use SQLITE_GRAPHRAG_DB_PATH env var\n  \
18    SQLITE_GRAPHRAG_DB_PATH=/data/graphrag.sqlite sqlite-graphrag health")]
19pub struct HealthArgs {
20    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
21    pub db: Option<String>,
22    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
23    #[arg(long, default_value_t = false)]
24    pub json: bool,
25    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
26    #[arg(long, value_parser = ["json", "text"], hide = true)]
27    pub format: Option<String>,
28}
29
30#[derive(Serialize)]
31struct HealthCounts {
32    memories: i64,
33    /// Alias of `memories` for the documented contract in SKILL.md.
34    memories_total: i64,
35    entities: i64,
36    relationships: i64,
37    vec_memories: i64,
38}
39
40#[derive(Serialize)]
41struct HealthCheck {
42    name: String,
43    ok: bool,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    detail: Option<String>,
46}
47
48#[derive(Serialize)]
49struct HealthResponse {
50    status: String,
51    integrity: String,
52    integrity_ok: bool,
53    schema_ok: bool,
54    vec_memories_ok: bool,
55    vec_memories_missing: i64,
56    vec_memories_orphaned: i64,
57    vec_entities_ok: bool,
58    vec_chunks_ok: bool,
59    fts_ok: bool,
60    /// Whether a live FTS5 MATCH query against fts_memories succeeded.
61    fts_query_ok: bool,
62    model_ok: bool,
63    counts: HealthCounts,
64    db_path: String,
65    db_size_bytes: u64,
66    /// MAX(version) from refinery_schema_history — number of the last applied migration.
67    /// Distinct from PRAGMA schema_version (SQLite DDL counter) and PRAGMA user_version
68    /// (canonical SCHEMA_USER_VERSION from __debug_schema).
69    schema_version: u32,
70    /// List of entities referenced by memories but absent from the entities table.
71    /// Empty in a healthy DB. Per the contract documented in SKILL.md.
72    missing_entities: Vec<String>,
73    /// WAL file size in MB (0.0 if WAL does not exist or journal_mode != wal).
74    wal_size_mb: f64,
75    /// SQLite journaling mode (wal, delete, truncate, persist, memory, off).
76    journal_mode: String,
77    /// SQLite version string, e.g. `"3.46.0"`.
78    sqlite_version: String,
79    /// Fraction of relationships that use the `mentions` relation type (0.0–1.0).
80    /// Omitted when there are no relationships in the database.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    mentions_ratio: Option<f64>,
83    /// Human-readable warning when `mentions` relationships dominate the graph (ratio > 0.5).
84    /// Omitted when the ratio is within acceptable bounds or there are no relationships.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    mentions_warning: Option<String>,
87    /// The relation type with the highest edge count in the namespace.
88    /// Omitted when there are no relationships in the database.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    top_relation: Option<String>,
91    /// Fraction of all edges occupied by `top_relation` (0.0–1.0).
92    /// Omitted when there are no relationships in the database.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    top_relation_ratio: Option<f64>,
95    /// Fraction of relationships that use the `applies_to` relation type (0.0–1.0).
96    /// Omitted when there are no relationships or when `applies_to` is absent.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    applies_to_ratio: Option<f64>,
99    /// Human-readable warning when a single relation type occupies more than 40 % of edges.
100    /// Omitted when concentration is within acceptable bounds or there are no relationships.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    relation_concentration_warning: Option<String>,
103    /// Number of entities whose name differs from its normalized kebab-case form.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    non_normalized_count: Option<i64>,
106    /// Warning when non-normalized entities are detected.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    normalization_warning: Option<String>,
109    /// Number of entities with degree exceeding the super-hub threshold (default 50).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    super_hub_count: Option<i64>,
112    /// Warning listing top super-hub entity names.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    super_hub_warning: Option<String>,
115    /// Name of the entity with the highest connection count in the namespace.
116    /// Omitted when there are no entities in the database.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    top_hub_entity: Option<String>,
119    /// Number of connections (degree) of `top_hub_entity`.
120    /// Omitted when there are no entities in the database.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    top_hub_degree: Option<i64>,
123    /// Human-readable warning when `top_hub_entity` exceeds 50 connections.
124    /// Omitted when degree is within acceptable bounds or there are no entities.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    hub_warning: Option<String>,
127    checks: Vec<HealthCheck>,
128    elapsed_ms: u64,
129}
130
131/// Checks whether a table (including virtual ones) exists in sqlite_master.
132fn table_exists(conn: &rusqlite::Connection, table_name: &str) -> bool {
133    conn.query_row(
134        "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'shadow') AND name = ?1",
135        rusqlite::params![table_name],
136        |r| r.get::<_, i64>(0),
137    )
138    .unwrap_or(0)
139        > 0
140}
141
142pub fn run(args: HealthArgs) -> Result<(), AppError> {
143    let start = Instant::now();
144    let _ = args.json; // --json is a no-op because output is already JSON by default
145    let _ = args.format; // --format is a no-op; JSON is always emitted on stdout
146    let paths = AppPaths::resolve(args.db.as_deref())?;
147
148    crate::storage::connection::ensure_db_ready(&paths)?;
149
150    let conn = open_ro(&paths.db)?;
151
152    let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
153    let integrity_ok = integrity == "ok";
154    tracing::info!(target: "health", integrity_ok = %integrity_ok, "PRAGMA integrity_check complete");
155
156    if !integrity_ok {
157        let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
158        output::emit_json(&HealthResponse {
159            status: "degraded".to_string(),
160            integrity: integrity.clone(),
161            integrity_ok: false,
162            schema_ok: false,
163            vec_memories_ok: false,
164            vec_memories_missing: 0,
165            vec_memories_orphaned: 0,
166            vec_entities_ok: false,
167            vec_chunks_ok: false,
168            fts_ok: false,
169            fts_query_ok: false,
170            model_ok: false,
171            counts: HealthCounts {
172                memories: 0,
173                memories_total: 0,
174                entities: 0,
175                relationships: 0,
176                vec_memories: 0,
177            },
178            db_path: paths.db.display().to_string(),
179            db_size_bytes,
180            schema_version: 0,
181            sqlite_version: "unknown".to_string(),
182            missing_entities: vec![],
183            wal_size_mb: 0.0,
184            journal_mode: "unknown".to_string(),
185            mentions_ratio: None,
186            mentions_warning: None,
187            top_relation: None,
188            top_relation_ratio: None,
189            applies_to_ratio: None,
190            relation_concentration_warning: None,
191            non_normalized_count: None,
192            normalization_warning: None,
193            super_hub_count: None,
194            super_hub_warning: None,
195            top_hub_entity: None,
196            top_hub_degree: None,
197            hub_warning: None,
198            checks: vec![HealthCheck {
199                name: "integrity".to_string(),
200                ok: false,
201                detail: Some(integrity),
202            }],
203            elapsed_ms: start.elapsed().as_millis() as u64,
204        })?;
205        return Err(AppError::Database(rusqlite::Error::SqliteFailure(
206            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
207            Some("integrity check failed".to_string()),
208        )));
209    }
210
211    let memories_count: i64 = conn.query_row(
212        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
213        [],
214        |r| r.get(0),
215    )?;
216    let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
217    let relationships_count: i64 =
218        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
219    let vec_memories_count: i64 =
220        conn.query_row("SELECT COUNT(*) FROM vec_memories", [], |r| r.get(0))?;
221
222    let mentions_count: i64 = conn.query_row(
223        "SELECT COUNT(*) FROM relationships WHERE relation = 'mentions'",
224        [],
225        |r| r.get(0),
226    )?;
227    let (mentions_ratio, mentions_warning) = if relationships_count > 0 {
228        let ratio = mentions_count as f64 / relationships_count as f64;
229        let warning = if ratio > 0.5 {
230            Some(format!(
231                "mentions relationships dominate graph at {:.1}% ({}/{} total); consider running prune-relations --relation mentions --dry-run",
232                ratio * 100.0,
233                mentions_count,
234                relationships_count
235            ))
236        } else {
237            None
238        };
239        (Some(ratio), warning)
240    } else {
241        (None, None)
242    };
243
244    // Relation concentration: find the most frequent relation type and check threshold.
245    let (top_relation, top_relation_ratio, applies_to_ratio, relation_concentration_warning) =
246        if relationships_count > 0 {
247            // Identify the relation with the highest edge count.
248            let (top_rel, top_count): (String, i64) = conn
249                .query_row(
250                    "SELECT relation, COUNT(*) AS cnt
251                     FROM relationships
252                     GROUP BY relation
253                     ORDER BY cnt DESC
254                     LIMIT 1",
255                    [],
256                    |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
257                )
258                .unwrap_or_else(|_| ("unknown".to_string(), 0));
259
260            let top_ratio = top_count as f64 / relationships_count as f64;
261
262            // Compute applies_to ratio separately (may be 0 if absent).
263            let applies_count: i64 = conn
264                .query_row(
265                    "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
266                    [],
267                    |r| r.get(0),
268                )
269                .unwrap_or(0);
270            let at_ratio = if applies_count > 0 {
271                Some(applies_count as f64 / relationships_count as f64)
272            } else {
273                None
274            };
275
276            let concentration_warning = if top_ratio > 0.40 {
277                Some(format!(
278                    "relation '{}' dominates graph at {:.1}% ({}/{} total); consider running prune-relations --relation {} --dry-run",
279                    top_rel,
280                    top_ratio * 100.0,
281                    top_count,
282                    relationships_count,
283                    top_rel,
284                ))
285            } else {
286                None
287            };
288
289            (
290                Some(top_rel),
291                Some(top_ratio),
292                at_ratio,
293                concentration_warning,
294            )
295        } else {
296            (None, None, None, None)
297        };
298
299    let status = "ok";
300
301    let schema_version: u32 = conn
302        .query_row(
303            "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
304            [],
305            |r| r.get::<_, i64>(0),
306        )
307        .unwrap_or(0) as u32;
308
309    let schema_ok = schema_version > 0;
310
311    // Checks vector tables via sqlite_master
312    let vec_memories_ok = table_exists(&conn, "vec_memories");
313    let vec_entities_ok = table_exists(&conn, "vec_entities");
314    let vec_chunks_ok = table_exists(&conn, "vec_chunks");
315
316    let vec_memories_missing: i64 = if vec_memories_ok {
317        conn.query_row(
318            "SELECT COUNT(*) FROM memories m LEFT JOIN vec_memories v ON v.memory_id = m.id WHERE v.memory_id IS NULL AND m.deleted_at IS NULL",
319            [], |r| r.get(0),
320        ).unwrap_or(0)
321    } else {
322        0
323    };
324
325    let vec_memories_orphaned: i64 = if vec_memories_ok {
326        conn.query_row(
327            "SELECT COUNT(*) FROM vec_memories v LEFT JOIN memories m ON m.id = v.memory_id WHERE m.id IS NULL",
328            [], |r| r.get(0),
329        ).unwrap_or(0)
330    } else {
331        0
332    };
333
334    tracing::info!(target: "health", vec_memories_ok = %vec_memories_ok, vec_entities_ok = %vec_entities_ok, vec_missing = vec_memories_missing, vec_orphaned = vec_memories_orphaned, "vector table checks complete");
335    let fts_ok = table_exists(&conn, "fts_memories");
336
337    // Verifies that FTS5 can execute a MATCH query (catches index corruption distinct from table absence).
338    let fts_query_ok = if fts_ok {
339        conn.query_row(
340            "SELECT COUNT(*) FROM fts_memories WHERE fts_memories MATCH 'a' LIMIT 1",
341            [],
342            |r| r.get::<_, i64>(0),
343        )
344        .is_ok()
345    } else {
346        false
347    };
348
349    tracing::info!(target: "health", fts_ok = %fts_ok, fts_query_ok = %fts_query_ok, "FTS5 checks complete");
350
351    // Captures the SQLite runtime version for observability.
352    let sqlite_version: String = conn
353        .query_row("SELECT sqlite_version()", [], |r| r.get(0))
354        .unwrap_or_else(|_| "unknown".to_string());
355
356    // Detects orphan entities referenced by memories but absent from the entities table.
357    let mut missing_entities: Vec<String> = Vec::with_capacity(4);
358    let mut stmt = conn.prepare_cached(
359        "SELECT DISTINCT me.entity_id
360         FROM memory_entities me
361         LEFT JOIN entities e ON e.id = me.entity_id
362         WHERE e.id IS NULL",
363    )?;
364    let orphans: Vec<i64> = stmt
365        .query_map([], |r| r.get(0))?
366        .collect::<Result<Vec<_>, _>>()?;
367    for id in orphans {
368        missing_entities.push(format!("entity_id={id}"));
369    }
370
371    let journal_mode: String = conn
372        .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
373        .unwrap_or_else(|_| "unknown".to_string());
374
375    let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
376        .map(|m| m.len() as f64 / 1024.0 / 1024.0)
377        .unwrap_or(0.0);
378
379    // Database file size in bytes
380    let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
381
382    // Checks whether the ONNX model is present in the cache
383    let model_dir = paths.models.join("models--intfloat--multilingual-e5-small");
384    let model_ok = model_dir.exists();
385    tracing::info!(target: "health", model_ok = %model_ok, "embedding model check complete");
386
387    // Builds the checks array for detailed diagnostics
388    let mut checks: Vec<HealthCheck> = Vec::with_capacity(8);
389
390    // At this point integrity_ok is always true (corrupt DB returned early above).
391    checks.push(HealthCheck {
392        name: "integrity".to_string(),
393        ok: true,
394        detail: None,
395    });
396
397    checks.push(HealthCheck {
398        name: "schema_version".to_string(),
399        ok: schema_ok,
400        detail: if schema_ok {
401            None
402        } else {
403            Some(format!("schema_version={schema_version} (expected >0)"))
404        },
405    });
406
407    checks.push(HealthCheck {
408        name: "vec_memories".to_string(),
409        ok: vec_memories_ok,
410        detail: if vec_memories_ok {
411            None
412        } else {
413            Some("vec_memories table missing from sqlite_master".to_string())
414        },
415    });
416
417    checks.push(HealthCheck {
418        name: "vec_entities".to_string(),
419        ok: vec_entities_ok,
420        detail: if vec_entities_ok {
421            None
422        } else {
423            Some("vec_entities table missing from sqlite_master".to_string())
424        },
425    });
426
427    checks.push(HealthCheck {
428        name: "vec_chunks".to_string(),
429        ok: vec_chunks_ok,
430        detail: if vec_chunks_ok {
431            None
432        } else {
433            Some("vec_chunks table missing from sqlite_master".to_string())
434        },
435    });
436
437    checks.push(HealthCheck {
438        name: "fts_memories".to_string(),
439        ok: fts_ok,
440        detail: if fts_ok {
441            None
442        } else {
443            Some("fts_memories table missing from sqlite_master".to_string())
444        },
445    });
446
447    checks.push(HealthCheck {
448        name: "fts_query".to_string(),
449        ok: fts_query_ok,
450        detail: if fts_query_ok {
451            None
452        } else {
453            Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string())
454        },
455    });
456
457    checks.push(HealthCheck {
458        name: "model_onnx".to_string(),
459        ok: model_ok,
460        detail: if model_ok {
461            None
462        } else {
463            Some(format!(
464                "model missing at {}; run 'sqlite-graphrag models download'",
465                model_dir.display()
466            ))
467        },
468    });
469
470    // G24: detect non-normalized entity names
471    let (non_normalized_count, normalization_warning) = {
472        let mut stmt = conn.prepare_cached("SELECT name FROM entities")?;
473        let names: Vec<String> = stmt
474            .query_map([], |r| r.get(0))?
475            .filter_map(|r| r.ok())
476            .collect();
477        let count = names
478            .iter()
479            .filter(|n| crate::parsers::normalize_entity_name(n) != **n)
480            .count() as i64;
481        let warning = if count > 0 {
482            Some(format!(
483                "run 'normalize-entities --yes' to fix {count} non-normalized entities"
484            ))
485        } else {
486            None
487        };
488        (Some(count), warning)
489    };
490
491    // G25: detect super-hub entities (degree > 50)
492    let (super_hub_count, super_hub_warning) = {
493        let mut stmt = conn.prepare_cached(
494            "SELECT e.name, COUNT(r.id) as deg FROM entities e \
495             LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id \
496             GROUP BY e.id HAVING deg > 50 ORDER BY deg DESC LIMIT 5",
497        )?;
498        let hubs: Vec<(String, i64)> = stmt
499            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
500            .filter_map(|r| r.ok())
501            .collect();
502        let count = hubs.len() as i64;
503        let warning = if count > 0 {
504            let names: Vec<String> = hubs
505                .iter()
506                .map(|(n, d)| format!("{n} (degree {d})"))
507                .collect();
508            Some(format!("super-hubs detected: {}", names.join(", ")))
509        } else {
510            None
511        };
512        (Some(count), warning)
513    };
514
515    // G25 (extended): identify the single highest-degree entity for programmatic use.
516    let (top_hub_entity, top_hub_degree, hub_warning) = {
517        let result: Option<(String, i64)> = conn
518            .query_row(
519                "SELECT e.name, COUNT(r.id) AS degree
520                 FROM entities e
521                 LEFT JOIN relationships r ON e.id = r.source_id OR e.id = r.target_id
522                 GROUP BY e.id
523                 ORDER BY degree DESC
524                 LIMIT 1",
525                [],
526                |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)),
527            )
528            .ok();
529        match result {
530            Some((name, degree)) => {
531                let warning = if degree > 50 {
532                    Some(format!(
533                        "entity '{name}' has {degree} connections; consider splitting or using --max-neighbors-per-hop"
534                    ))
535                } else {
536                    None
537                };
538                (Some(name), Some(degree), warning)
539            }
540            None => (None, None, None),
541        }
542    };
543
544    let response = HealthResponse {
545        status: status.to_string(),
546        integrity,
547        integrity_ok,
548        schema_ok,
549        vec_memories_ok,
550        vec_memories_missing,
551        vec_memories_orphaned,
552        vec_entities_ok,
553        vec_chunks_ok,
554        fts_ok,
555        fts_query_ok,
556        model_ok,
557        counts: HealthCounts {
558            memories: memories_count,
559            memories_total: memories_count,
560            entities: entities_count,
561            relationships: relationships_count,
562            vec_memories: vec_memories_count,
563        },
564        db_path: paths.db.display().to_string(),
565        db_size_bytes,
566        schema_version,
567        sqlite_version,
568        missing_entities,
569        wal_size_mb,
570        journal_mode,
571        mentions_ratio,
572        mentions_warning,
573        top_relation,
574        top_relation_ratio,
575        applies_to_ratio,
576        relation_concentration_warning,
577        non_normalized_count,
578        normalization_warning,
579        super_hub_count,
580        super_hub_warning,
581        top_hub_entity,
582        top_hub_degree,
583        hub_warning,
584        checks,
585        elapsed_ms: start.elapsed().as_millis() as u64,
586    };
587
588    output::emit_json(&response)?;
589
590    Ok(())
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn health_check_serializes_all_new_fields() {
599        let response = HealthResponse {
600            status: "ok".to_string(),
601            integrity: "ok".to_string(),
602            integrity_ok: true,
603            schema_ok: true,
604            vec_memories_ok: true,
605            vec_memories_missing: 0,
606            vec_memories_orphaned: 0,
607            vec_entities_ok: true,
608            vec_chunks_ok: true,
609            fts_ok: true,
610            fts_query_ok: true,
611            model_ok: false,
612            counts: HealthCounts {
613                memories: 5,
614                memories_total: 5,
615                entities: 3,
616                relationships: 2,
617                vec_memories: 5,
618            },
619            db_path: "/tmp/test.sqlite".to_string(),
620            db_size_bytes: 4096,
621            schema_version: 6,
622            sqlite_version: "3.46.0".to_string(),
623            elapsed_ms: 0,
624            missing_entities: vec![],
625            wal_size_mb: 0.0,
626            journal_mode: "wal".to_string(),
627            mentions_ratio: None,
628            mentions_warning: None,
629            top_relation: None,
630            top_relation_ratio: None,
631            applies_to_ratio: None,
632            relation_concentration_warning: None,
633            non_normalized_count: None,
634            normalization_warning: None,
635            super_hub_count: None,
636            super_hub_warning: None,
637            top_hub_entity: None,
638            top_hub_degree: None,
639            hub_warning: None,
640            checks: vec![
641                HealthCheck {
642                    name: "integrity".to_string(),
643                    ok: true,
644                    detail: None,
645                },
646                HealthCheck {
647                    name: "model_onnx".to_string(),
648                    ok: false,
649                    detail: Some("model missing".to_string()),
650                },
651            ],
652        };
653
654        let json = serde_json::to_value(&response).unwrap();
655        assert_eq!(json["status"], "ok");
656        assert_eq!(json["integrity_ok"], true);
657        assert_eq!(json["schema_ok"], true);
658        assert_eq!(json["vec_memories_ok"], true);
659        assert_eq!(json["vec_entities_ok"], true);
660        assert_eq!(json["vec_chunks_ok"], true);
661        assert_eq!(json["fts_ok"], true);
662        assert_eq!(json["model_ok"], false);
663        assert_eq!(json["db_size_bytes"], 4096u64);
664        assert!(json["checks"].is_array());
665        assert_eq!(json["checks"].as_array().unwrap().len(), 2);
666
667        // Verifies that detail is absent when ok=true (skip_serializing_if)
668        let integrity_check = &json["checks"][0];
669        assert_eq!(integrity_check["name"], "integrity");
670        assert_eq!(integrity_check["ok"], true);
671        assert!(integrity_check.get("detail").is_none());
672
673        // Verifies that detail is present when ok=false
674        let model_check = &json["checks"][1];
675        assert_eq!(model_check["name"], "model_onnx");
676        assert_eq!(model_check["ok"], false);
677        assert_eq!(model_check["detail"], "model missing");
678    }
679
680    #[test]
681    fn health_check_without_detail_omits_field() {
682        let check = HealthCheck {
683            name: "vec_memories".to_string(),
684            ok: true,
685            detail: None,
686        };
687        let json = serde_json::to_value(&check).unwrap();
688        assert!(
689            json.get("detail").is_none(),
690            "detail field must be omitted when None"
691        );
692    }
693
694    #[test]
695    fn health_check_with_detail_serializes_field() {
696        let check = HealthCheck {
697            name: "fts_memories".to_string(),
698            ok: false,
699            detail: Some("fts_memories table missing from sqlite_master".to_string()),
700        };
701        let json = serde_json::to_value(&check).unwrap();
702        assert_eq!(
703            json["detail"],
704            "fts_memories table missing from sqlite_master"
705        );
706    }
707
708    #[test]
709    fn health_response_fts_query_ok_and_sqlite_version_serialize() {
710        // Verifies that fts_query_ok and sqlite_version appear in the serialized JSON
711        // with the expected keys and values.
712        let response = HealthResponse {
713            status: "ok".to_string(),
714            integrity: "ok".to_string(),
715            integrity_ok: true,
716            schema_ok: true,
717            vec_memories_ok: true,
718            vec_memories_missing: 0,
719            vec_memories_orphaned: 0,
720            vec_entities_ok: true,
721            vec_chunks_ok: true,
722            fts_ok: true,
723            fts_query_ok: true,
724            model_ok: true,
725            counts: HealthCounts {
726                memories: 0,
727                memories_total: 0,
728                entities: 0,
729                relationships: 0,
730                vec_memories: 0,
731            },
732            db_path: "/tmp/test.sqlite".to_string(),
733            db_size_bytes: 0,
734            schema_version: 1,
735            sqlite_version: "3.45.1".to_string(),
736            elapsed_ms: 0,
737            missing_entities: vec![],
738            wal_size_mb: 0.0,
739            journal_mode: "wal".to_string(),
740            mentions_ratio: None,
741            mentions_warning: None,
742            top_relation: None,
743            top_relation_ratio: None,
744            applies_to_ratio: None,
745            relation_concentration_warning: None,
746            non_normalized_count: None,
747            normalization_warning: None,
748            super_hub_count: None,
749            super_hub_warning: None,
750            top_hub_entity: None,
751            top_hub_degree: None,
752            hub_warning: None,
753            checks: vec![],
754        };
755
756        let json = serde_json::to_value(&response).unwrap();
757
758        // fts_query_ok must appear at the top level
759        assert_eq!(
760            json["fts_query_ok"], true,
761            "fts_query_ok must be present and true in serialized JSON"
762        );
763
764        // sqlite_version must appear at the top level with the exact string
765        assert_eq!(
766            json["sqlite_version"], "3.45.1",
767            "sqlite_version must be present and match the provided string"
768        );
769
770        // Verify fts_query_ok=false path includes the expected detail message
771        let check_fail = HealthCheck {
772            name: "fts_query".to_string(),
773            ok: false,
774            detail: Some("FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'".to_string()),
775        };
776        let check_json = serde_json::to_value(&check_fail).unwrap();
777        assert_eq!(check_json["name"], "fts_query");
778        assert_eq!(check_json["ok"], false);
779        assert_eq!(
780            check_json["detail"],
781            "FTS5 MATCH query failed — run 'sqlite-graphrag fts rebuild'"
782        );
783    }
784
785    fn make_full_response(
786        top_relation: Option<String>,
787        top_relation_ratio: Option<f64>,
788        applies_to_ratio: Option<f64>,
789        relation_concentration_warning: Option<String>,
790    ) -> HealthResponse {
791        HealthResponse {
792            status: "ok".to_string(),
793            integrity: "ok".to_string(),
794            integrity_ok: true,
795            schema_ok: true,
796            vec_memories_ok: true,
797            vec_memories_missing: 0,
798            vec_memories_orphaned: 0,
799            vec_entities_ok: true,
800            vec_chunks_ok: true,
801            fts_ok: true,
802            fts_query_ok: true,
803            model_ok: true,
804            counts: HealthCounts {
805                memories: 10,
806                memories_total: 10,
807                entities: 5,
808                relationships: 20,
809                vec_memories: 10,
810            },
811            db_path: "/tmp/test.sqlite".to_string(),
812            db_size_bytes: 8192,
813            schema_version: 3,
814            sqlite_version: "3.46.0".to_string(),
815            elapsed_ms: 1,
816            missing_entities: vec![],
817            wal_size_mb: 0.0,
818            journal_mode: "wal".to_string(),
819            mentions_ratio: None,
820            mentions_warning: None,
821            top_relation,
822            top_relation_ratio,
823            applies_to_ratio,
824            relation_concentration_warning,
825            non_normalized_count: None,
826            normalization_warning: None,
827            super_hub_count: None,
828            super_hub_warning: None,
829            top_hub_entity: None,
830            top_hub_degree: None,
831            hub_warning: None,
832            checks: vec![],
833        }
834    }
835
836    #[test]
837    fn health_concentration_fields_omitted_when_no_relationships() {
838        // Represents a DB with zero relationships.
839        let resp = make_full_response(None, None, None, None);
840        let json = serde_json::to_value(&resp).unwrap();
841        assert!(
842            json.get("top_relation").is_none(),
843            "top_relation must be omitted when None"
844        );
845        assert!(
846            json.get("top_relation_ratio").is_none(),
847            "top_relation_ratio must be omitted when None"
848        );
849        assert!(
850            json.get("applies_to_ratio").is_none(),
851            "applies_to_ratio must be omitted when None"
852        );
853        assert!(
854            json.get("relation_concentration_warning").is_none(),
855            "relation_concentration_warning must be omitted when None"
856        );
857    }
858
859    #[test]
860    fn health_concentration_fields_present_with_data() {
861        let resp = make_full_response(
862            Some("mentions".to_string()),
863            Some(0.60),
864            Some(0.10),
865            Some("relation 'mentions' dominates graph at 60.0%".to_string()),
866        );
867        let json = serde_json::to_value(&resp).unwrap();
868        assert_eq!(json["top_relation"], "mentions");
869        assert!((json["top_relation_ratio"].as_f64().unwrap() - 0.60).abs() < 1e-9);
870        assert!((json["applies_to_ratio"].as_f64().unwrap() - 0.10).abs() < 1e-9);
871        assert!(json["relation_concentration_warning"]
872            .as_str()
873            .unwrap()
874            .contains("60.0%"));
875    }
876
877    #[test]
878    fn health_concentration_warning_absent_when_ratio_below_threshold() {
879        // top_relation_ratio of 0.39 is below the 0.40 threshold — no warning.
880        let resp = make_full_response(Some("uses".to_string()), Some(0.39), None, None);
881        let json = serde_json::to_value(&resp).unwrap();
882        assert_eq!(json["top_relation"], "uses");
883        assert!(
884            json.get("relation_concentration_warning").is_none(),
885            "warning must be absent when ratio <= 0.40"
886        );
887    }
888
889    #[test]
890    fn health_concentration_warning_present_at_threshold() {
891        // Exactly at 0.41 (above 0.40) — warning must appear.
892        let resp = make_full_response(
893            Some("depends_on".to_string()),
894            Some(0.41),
895            None,
896            Some("relation 'depends_on' dominates graph at 41.0%".to_string()),
897        );
898        let json = serde_json::to_value(&resp).unwrap();
899        assert!(
900            json["relation_concentration_warning"].is_string(),
901            "warning must be present when top_relation_ratio > 0.40"
902        );
903    }
904
905    #[test]
906    fn health_applies_to_ratio_omitted_when_none() {
907        // applies_to_ratio is None when there are no applies_to edges.
908        let resp = make_full_response(Some("related".to_string()), Some(0.30), None, None);
909        let json = serde_json::to_value(&resp).unwrap();
910        assert!(
911            json.get("applies_to_ratio").is_none(),
912            "applies_to_ratio must be omitted when None"
913        );
914    }
915}