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