Skip to main content

sqlite_graphrag/commands/
stats.rs

1//! Handler for the `stats` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct StatsArgs {
12    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
13    pub db: Option<String>,
14    /// Explicit JSON flag. Accepted as a no-op because output is already JSON by default.
15    #[arg(long, default_value_t = false)]
16    pub json: bool,
17    /// Output format: `json` or `text`. JSON is always emitted on stdout regardless of the value.
18    #[arg(long, value_parser = ["json", "text"], hide = true)]
19    pub format: Option<String>,
20}
21
22#[derive(Serialize)]
23struct StatsResponse {
24    memories: i64,
25    /// Alias de `memories` para contrato documentado em SKILL.md e AGENT_PROTOCOL.md.
26    memories_total: i64,
27    entities: i64,
28    /// Alias de `entities` para contrato documentado.
29    entities_total: i64,
30    relationships: i64,
31    /// Alias de `relationships` para contrato documentado.
32    relationships_total: i64,
33    /// Semantic alias of `relationships` per the contract in AGENT_PROTOCOL.md.
34    edges: i64,
35    /// Total indexed chunks (one row per chunk in `memory_chunks`).
36    chunks_total: i64,
37    /// Average length of the body field in active (non-deleted) memories.
38    avg_body_len: f64,
39    namespaces: Vec<String>,
40    db_size_bytes: u64,
41    /// Semantic alias of `db_size_bytes` for the documented contract.
42    db_bytes: u64,
43    schema_version: String,
44    /// Total execution time in milliseconds from handler start to serialisation.
45    elapsed_ms: u64,
46}
47
48pub fn run(args: StatsArgs) -> Result<(), AppError> {
49    let inicio = std::time::Instant::now();
50    let _ = args.json; // --json é no-op pois output já é JSON por default
51    let _ = args.format; // --format é no-op; JSON sempre emitido no stdout
52    let paths = AppPaths::resolve(args.db.as_deref())?;
53
54    if !paths.db.exists() {
55        return Err(AppError::NotFound(errors_msg::database_not_found(
56            &paths.db.display().to_string(),
57        )));
58    }
59
60    let conn = open_ro(&paths.db)?;
61
62    let memories: i64 = conn.query_row(
63        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
64        [],
65        |r| r.get(0),
66    )?;
67    let entities: i64 = conn.query_row("SELECT COUNT(*) FROM entities", [], |r| r.get(0))?;
68    let relationships: i64 =
69        conn.query_row("SELECT COUNT(*) FROM relationships", [], |r| r.get(0))?;
70
71    let mut stmt = conn.prepare(
72        "SELECT DISTINCT namespace FROM memories WHERE deleted_at IS NULL ORDER BY namespace",
73    )?;
74    let namespaces: Vec<String> = stmt
75        .query_map([], |r| r.get(0))?
76        .collect::<Result<Vec<_>, _>>()?;
77
78    let schema_version: String = conn
79        .query_row(
80            "SELECT value FROM schema_meta WHERE key='schema_version'",
81            [],
82            |r| r.get(0),
83        )
84        .unwrap_or_else(|_| "unknown".to_string());
85
86    let db_size_bytes = std::fs::metadata(&paths.db).map(|m| m.len()).unwrap_or(0);
87
88    // v1.0.21 P1-C: query usa tabela `memory_chunks` (correta).
89    // Se a tabela não existir (DB legado pré-chunking), o erro é "no such table"
90    // e o fallback retorna 0. Outros erros são logados via tracing para auditoria.
91    let chunks_total: i64 = match conn.query_row("SELECT COUNT(*) FROM memory_chunks", [], |r| {
92        r.get::<_, i64>(0)
93    }) {
94        Ok(n) => n,
95        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
96        Err(e) => {
97            tracing::warn!("falha ao contar memory_chunks: {e}");
98            0
99        }
100    };
101
102    let avg_body_len: f64 = conn
103        .query_row(
104            "SELECT COALESCE(AVG(LENGTH(body)), 0.0) FROM memories WHERE deleted_at IS NULL",
105            [],
106            |r| r.get(0),
107        )
108        .unwrap_or(0.0);
109
110    output::emit_json(&StatsResponse {
111        memories,
112        memories_total: memories,
113        entities,
114        entities_total: entities,
115        relationships,
116        relationships_total: relationships,
117        edges: relationships,
118        chunks_total,
119        avg_body_len,
120        namespaces,
121        db_size_bytes,
122        db_bytes: db_size_bytes,
123        schema_version,
124        elapsed_ms: inicio.elapsed().as_millis() as u64,
125    })?;
126
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn stats_response_serializa_todos_campos() {
136        let resp = StatsResponse {
137            memories: 10,
138            memories_total: 10,
139            entities: 5,
140            entities_total: 5,
141            relationships: 3,
142            relationships_total: 3,
143            edges: 3,
144            chunks_total: 20,
145            avg_body_len: 42.5,
146            namespaces: vec!["global".to_string(), "projeto".to_string()],
147            db_size_bytes: 8192,
148            db_bytes: 8192,
149            schema_version: "6".to_string(),
150            elapsed_ms: 7,
151        };
152        let json = serde_json::to_value(&resp).expect("serialização falhou");
153        assert_eq!(json["memories"], 10);
154        assert_eq!(json["memories_total"], 10);
155        assert_eq!(json["entities"], 5);
156        assert_eq!(json["entities_total"], 5);
157        assert_eq!(json["relationships"], 3);
158        assert_eq!(json["relationships_total"], 3);
159        assert_eq!(json["edges"], 3);
160        assert_eq!(json["chunks_total"], 20);
161        assert_eq!(json["db_size_bytes"], 8192u64);
162        assert_eq!(json["db_bytes"], 8192u64);
163        assert_eq!(json["schema_version"], "6");
164        assert_eq!(json["elapsed_ms"], 7u64);
165    }
166
167    #[test]
168    fn stats_response_namespaces_eh_array_de_strings() {
169        let resp = StatsResponse {
170            memories: 0,
171            memories_total: 0,
172            entities: 0,
173            entities_total: 0,
174            relationships: 0,
175            relationships_total: 0,
176            edges: 0,
177            chunks_total: 0,
178            avg_body_len: 0.0,
179            namespaces: vec!["ns1".to_string(), "ns2".to_string(), "ns3".to_string()],
180            db_size_bytes: 0,
181            db_bytes: 0,
182            schema_version: "unknown".to_string(),
183            elapsed_ms: 0,
184        };
185        let json = serde_json::to_value(&resp).expect("serialização falhou");
186        let arr = json["namespaces"]
187            .as_array()
188            .expect("namespaces deve ser array");
189        assert_eq!(arr.len(), 3);
190        assert_eq!(arr[0], "ns1");
191        assert_eq!(arr[1], "ns2");
192        assert_eq!(arr[2], "ns3");
193    }
194
195    #[test]
196    fn stats_response_namespaces_vazio_serializa_array_vazio() {
197        let resp = StatsResponse {
198            memories: 0,
199            memories_total: 0,
200            entities: 0,
201            entities_total: 0,
202            relationships: 0,
203            relationships_total: 0,
204            edges: 0,
205            chunks_total: 0,
206            avg_body_len: 0.0,
207            namespaces: vec![],
208            db_size_bytes: 0,
209            db_bytes: 0,
210            schema_version: "unknown".to_string(),
211            elapsed_ms: 0,
212        };
213        let json = serde_json::to_value(&resp).expect("serialização falhou");
214        let arr = json["namespaces"]
215            .as_array()
216            .expect("namespaces deve ser array");
217        assert!(arr.is_empty(), "namespaces vazio deve serializar como []");
218    }
219
220    #[test]
221    fn stats_response_aliases_memories_total_e_memories_iguais() {
222        let resp = StatsResponse {
223            memories: 42,
224            memories_total: 42,
225            entities: 7,
226            entities_total: 7,
227            relationships: 2,
228            relationships_total: 2,
229            edges: 2,
230            chunks_total: 0,
231            avg_body_len: 0.0,
232            namespaces: vec![],
233            db_size_bytes: 0,
234            db_bytes: 0,
235            schema_version: "6".to_string(),
236            elapsed_ms: 0,
237        };
238        let json = serde_json::to_value(&resp).expect("serialização falhou");
239        assert_eq!(json["memories"], json["memories_total"]);
240        assert_eq!(json["entities"], json["entities_total"]);
241        assert_eq!(json["relationships"], json["relationships_total"]);
242        assert_eq!(json["relationships"], json["edges"]);
243        assert_eq!(json["db_size_bytes"], json["db_bytes"]);
244    }
245}