1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::paths::AppPaths;
5use crate::storage::connection::open_ro;
6use serde::Serialize;
7use std::fs;
8use std::time::Instant;
9
10#[derive(clap::Args)]
11pub struct HealthArgs {
12 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
13 pub db: Option<String>,
14 #[arg(long, default_value_t = false)]
16 pub json: bool,
17 #[arg(long, value_parser = ["json", "text"], hide = true)]
19 pub format: Option<String>,
20}
21
22#[derive(Serialize)]
23struct HealthCounts {
24 memories: i64,
25 entities: i64,
26 relationships: i64,
27 vec_memories: i64,
28}
29
30#[derive(Serialize)]
31struct HealthCheck {
32 name: String,
33 ok: bool,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 detail: Option<String>,
36}
37
38#[derive(Serialize)]
39struct HealthResponse {
40 status: String,
41 integrity: String,
42 integrity_ok: bool,
43 schema_ok: bool,
44 vec_memories_ok: bool,
45 vec_entities_ok: bool,
46 vec_chunks_ok: bool,
47 fts_ok: bool,
48 model_ok: bool,
49 counts: HealthCounts,
50 db_path: String,
51 db_size_bytes: u64,
52 schema_version: u32,
56 missing_entities: Vec<String>,
59 wal_size_mb: f64,
61 journal_mode: String,
63 checks: Vec<HealthCheck>,
64 elapsed_ms: u64,
65}
66
67fn table_exists(conn: &rusqlite::Connection, table_name: &str) -> bool {
69 conn.query_row(
70 "SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'shadow') AND name = ?1",
71 rusqlite::params![table_name],
72 |r| r.get::<_, i64>(0),
73 )
74 .unwrap_or(0)
75 > 0
76}
77
78pub fn run(args: HealthArgs) -> Result<(), AppError> {
79 let inicio = Instant::now();
80 let _ = args.json; let _ = args.format; let paths = AppPaths::resolve(args.db.as_deref())?;
83
84 if !paths.db.exists() {
85 return Err(AppError::NotFound(erros::banco_nao_encontrado(
86 &paths.db.display().to_string(),
87 )));
88 }
89
90 let conn = open_ro(&paths.db)?;
91
92 let integrity: String = conn.query_row("PRAGMA integrity_check;", [], |r| r.get(0))?;
93 let integrity_ok = integrity == "ok";
94
95 if !integrity_ok {
96 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
97 output::emit_json(&HealthResponse {
98 status: "degraded".to_string(),
99 integrity: integrity.clone(),
100 integrity_ok: false,
101 schema_ok: false,
102 vec_memories_ok: false,
103 vec_entities_ok: false,
104 vec_chunks_ok: false,
105 fts_ok: false,
106 model_ok: false,
107 counts: HealthCounts {
108 memories: 0,
109 entities: 0,
110 relationships: 0,
111 vec_memories: 0,
112 },
113 db_path: paths.db.display().to_string(),
114 db_size_bytes,
115 schema_version: 0,
116 missing_entities: vec![],
117 wal_size_mb: 0.0,
118 journal_mode: "unknown".to_string(),
119 checks: vec![HealthCheck {
120 name: "integrity".to_string(),
121 ok: false,
122 detail: Some(integrity),
123 }],
124 elapsed_ms: inicio.elapsed().as_millis() as u64,
125 })?;
126 return Err(AppError::Database(rusqlite::Error::SqliteFailure(
127 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
128 Some("integrity check failed".to_string()),
129 )));
130 }
131
132 let memories_count: i64 = conn.query_row(
133 "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
134 [],
135 |r| r.get(0),
136 )?;
137 let entities_count: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
138 let relationships_count: i64 =
139 conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
140 let vec_memories_count: i64 =
141 conn.query_row("SELECT COUNT(*) FROM vec_memories", [], |r| r.get(0))?;
142
143 let status = "ok";
144
145 let schema_version: u32 = conn
146 .query_row(
147 "SELECT COALESCE(MAX(version), 0) FROM refinery_schema_history",
148 [],
149 |r| r.get::<_, i64>(0),
150 )
151 .unwrap_or(0) as u32;
152
153 let schema_ok = schema_version > 0;
154
155 let vec_memories_ok = table_exists(&conn, "vec_memories");
157 let vec_entities_ok = table_exists(&conn, "vec_entities");
158 let vec_chunks_ok = table_exists(&conn, "vec_chunks");
159 let fts_ok = table_exists(&conn, "fts_memories");
160
161 let mut missing_entities: Vec<String> = Vec::new();
163 let mut stmt = conn.prepare(
164 "SELECT DISTINCT me.entity_id
165 FROM memory_entities me
166 LEFT JOIN entities e ON e.id = me.entity_id
167 WHERE e.id IS NULL",
168 )?;
169 let orphans: Vec<i64> = stmt
170 .query_map([], |r| r.get(0))?
171 .collect::<Result<Vec<_>, _>>()?;
172 for id in orphans {
173 missing_entities.push(format!("entity_id={id}"));
174 }
175
176 let journal_mode: String = conn
177 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
178 .unwrap_or_else(|_| "unknown".to_string());
179
180 let wal_size_mb = fs::metadata(format!("{}-wal", paths.db.display()))
181 .map(|m| m.len() as f64 / 1024.0 / 1024.0)
182 .unwrap_or(0.0);
183
184 let db_size_bytes = fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
186
187 let model_dir = paths.models.join("models--intfloat--multilingual-e5-small");
189 let model_ok = model_dir.exists();
190
191 let mut checks: Vec<HealthCheck> = Vec::new();
193
194 checks.push(HealthCheck {
196 name: "integrity".to_string(),
197 ok: true,
198 detail: None,
199 });
200
201 checks.push(HealthCheck {
202 name: "schema_version".to_string(),
203 ok: schema_ok,
204 detail: if schema_ok {
205 None
206 } else {
207 Some(format!("schema_version={schema_version} (esperado >0)"))
208 },
209 });
210
211 checks.push(HealthCheck {
212 name: "vec_memories".to_string(),
213 ok: vec_memories_ok,
214 detail: if vec_memories_ok {
215 None
216 } else {
217 Some("tabela vec_memories ausente em sqlite_master".to_string())
218 },
219 });
220
221 checks.push(HealthCheck {
222 name: "vec_entities".to_string(),
223 ok: vec_entities_ok,
224 detail: if vec_entities_ok {
225 None
226 } else {
227 Some("tabela vec_entities ausente em sqlite_master".to_string())
228 },
229 });
230
231 checks.push(HealthCheck {
232 name: "vec_chunks".to_string(),
233 ok: vec_chunks_ok,
234 detail: if vec_chunks_ok {
235 None
236 } else {
237 Some("tabela vec_chunks ausente em sqlite_master".to_string())
238 },
239 });
240
241 checks.push(HealthCheck {
242 name: "fts_memories".to_string(),
243 ok: fts_ok,
244 detail: if fts_ok {
245 None
246 } else {
247 Some("tabela fts_memories ausente em sqlite_master".to_string())
248 },
249 });
250
251 checks.push(HealthCheck {
252 name: "model_onnx".to_string(),
253 ok: model_ok,
254 detail: if model_ok {
255 None
256 } else {
257 Some(format!(
258 "modelo ausente em {}; execute 'sqlite-graphrag models download'",
259 model_dir.display()
260 ))
261 },
262 });
263
264 let response = HealthResponse {
265 status: status.to_string(),
266 integrity,
267 integrity_ok,
268 schema_ok,
269 vec_memories_ok,
270 vec_entities_ok,
271 vec_chunks_ok,
272 fts_ok,
273 model_ok,
274 counts: HealthCounts {
275 memories: memories_count,
276 entities: entities_count,
277 relationships: relationships_count,
278 vec_memories: vec_memories_count,
279 },
280 db_path: paths.db.display().to_string(),
281 db_size_bytes,
282 schema_version,
283 missing_entities,
284 wal_size_mb,
285 journal_mode,
286 checks,
287 elapsed_ms: inicio.elapsed().as_millis() as u64,
288 };
289
290 output::emit_json(&response)?;
291
292 Ok(())
293}
294
295#[cfg(test)]
296mod testes {
297 use super::*;
298
299 #[test]
300 fn health_check_serializa_todos_os_campos_novos() {
301 let resposta = HealthResponse {
302 status: "ok".to_string(),
303 integrity: "ok".to_string(),
304 integrity_ok: true,
305 schema_ok: true,
306 vec_memories_ok: true,
307 vec_entities_ok: true,
308 vec_chunks_ok: true,
309 fts_ok: true,
310 model_ok: false,
311 counts: HealthCounts {
312 memories: 5,
313 entities: 3,
314 relationships: 2,
315 vec_memories: 5,
316 },
317 db_path: "/tmp/test.sqlite".to_string(),
318 db_size_bytes: 4096,
319 schema_version: 5,
320 elapsed_ms: 0,
321 missing_entities: vec![],
322 wal_size_mb: 0.0,
323 journal_mode: "wal".to_string(),
324 checks: vec![
325 HealthCheck {
326 name: "integrity".to_string(),
327 ok: true,
328 detail: None,
329 },
330 HealthCheck {
331 name: "model_onnx".to_string(),
332 ok: false,
333 detail: Some("modelo ausente".to_string()),
334 },
335 ],
336 };
337
338 let json = serde_json::to_value(&resposta).unwrap();
339 assert_eq!(json["status"], "ok");
340 assert_eq!(json["integrity_ok"], true);
341 assert_eq!(json["schema_ok"], true);
342 assert_eq!(json["vec_memories_ok"], true);
343 assert_eq!(json["vec_entities_ok"], true);
344 assert_eq!(json["vec_chunks_ok"], true);
345 assert_eq!(json["fts_ok"], true);
346 assert_eq!(json["model_ok"], false);
347 assert_eq!(json["db_size_bytes"], 4096u64);
348 assert!(json["checks"].is_array());
349 assert_eq!(json["checks"].as_array().unwrap().len(), 2);
350
351 let integrity_check = &json["checks"][0];
353 assert_eq!(integrity_check["name"], "integrity");
354 assert_eq!(integrity_check["ok"], true);
355 assert!(integrity_check.get("detail").is_none());
356
357 let model_check = &json["checks"][1];
359 assert_eq!(model_check["name"], "model_onnx");
360 assert_eq!(model_check["ok"], false);
361 assert_eq!(model_check["detail"], "modelo ausente");
362 }
363
364 #[test]
365 fn health_check_sem_detail_omite_campo() {
366 let check = HealthCheck {
367 name: "vec_memories".to_string(),
368 ok: true,
369 detail: None,
370 };
371 let json = serde_json::to_value(&check).unwrap();
372 assert!(
373 json.get("detail").is_none(),
374 "campo detail deve ser omitido quando None"
375 );
376 }
377
378 #[test]
379 fn health_check_com_detail_serializa_campo() {
380 let check = HealthCheck {
381 name: "fts_memories".to_string(),
382 ok: false,
383 detail: Some("tabela fts_memories ausente".to_string()),
384 };
385 let json = serde_json::to_value(&check).unwrap();
386 assert_eq!(json["detail"], "tabela fts_memories ausente");
387 }
388}