Skip to main content

sqlite_graphrag/commands/
read.rs

1//! Handler for the `read` 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 crate::storage::memories;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12pub struct ReadArgs {
13    /// Memory name as a positional argument. Alternative to `--name`.
14    #[arg(value_name = "NAME", conflicts_with = "name")]
15    pub name_positional: Option<String>,
16    /// Memory name to read. Returns NotFound (exit 4) if missing or soft-deleted.
17    #[arg(long)]
18    pub name: Option<String>,
19    #[arg(long, default_value = "global")]
20    pub namespace: Option<String>,
21    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
22    pub json: bool,
23    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
24    pub db: Option<String>,
25}
26
27#[derive(Serialize)]
28struct ReadResponse {
29    /// Canonical storage field. Preserved for compatibility with v2.0.0 clients.
30    id: i64,
31    /// Semantic alias of `id` for the contract documented in SKILL.md and AGENT_PROTOCOL.md.
32    memory_id: i64,
33    namespace: String,
34    name: String,
35    /// Semantic alias of `memory_type` for the documented contract.
36    #[serde(rename = "type")]
37    type_alias: String,
38    memory_type: String,
39    description: String,
40    body: String,
41    body_hash: String,
42    session_id: Option<String>,
43    source: String,
44    metadata: serde_json::Value,
45    /// Most recent memory version, useful for optimistic control via `--expected-updated-at`.
46    version: i64,
47    created_at: i64,
48    /// Timestamp RFC 3339 UTC paralelo a `created_at` para parsers ISO 8601.
49    created_at_iso: String,
50    updated_at: i64,
51    /// Timestamp RFC 3339 UTC paralelo a `updated_at` para parsers ISO 8601.
52    updated_at_iso: String,
53    /// Total execution time in milliseconds from handler start to serialisation.
54    elapsed_ms: u64,
55}
56
57fn epoch_to_iso(epoch: i64) -> String {
58    crate::tz::epoch_to_iso(epoch)
59}
60
61pub fn run(args: ReadArgs) -> Result<(), AppError> {
62    let inicio = std::time::Instant::now();
63    // Resolve name from positional or --name flag; both are optional, at least one is required.
64    let name = args.name_positional.or(args.name).ok_or_else(|| {
65        AppError::Validation("name required: pass as positional argument or via --name".to_string())
66    })?;
67    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
68    let paths = AppPaths::resolve(args.db.as_deref())?;
69    if !paths.db.exists() {
70        return Err(AppError::NotFound(
71            crate::i18n::errors_msg::database_not_found(&paths.db.display().to_string()),
72        ));
73    }
74    let conn = open_ro(&paths.db)?;
75
76    match memories::read_by_name(&conn, &namespace, &name)? {
77        Some(row) => {
78            // Resolver versão atual via tabela memory_versions (maior version para este memory_id).
79            let version: i64 = conn
80                .query_row(
81                    "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
82                    rusqlite::params![row.id],
83                    |r| r.get(0),
84                )
85                .unwrap_or(1);
86
87            let response = ReadResponse {
88                id: row.id,
89                memory_id: row.id,
90                namespace: row.namespace,
91                name: row.name,
92                type_alias: row.memory_type.clone(),
93                memory_type: row.memory_type,
94                description: row.description,
95                body: row.body,
96                body_hash: row.body_hash,
97                session_id: row.session_id,
98                source: row.source,
99                metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
100                    .unwrap_or(serde_json::Value::Null),
101                version,
102                created_at: row.created_at,
103                created_at_iso: epoch_to_iso(row.created_at),
104                updated_at: row.updated_at,
105                updated_at_iso: epoch_to_iso(row.updated_at),
106                elapsed_ms: inicio.elapsed().as_millis() as u64,
107            };
108            output::emit_json(&response)?;
109        }
110        None => {
111            return Err(AppError::NotFound(errors_msg::memory_not_found(
112                &name, &namespace,
113            )))
114        }
115    }
116
117    Ok(())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn epoch_to_iso_converte_zero_para_epoch_unix() {
126        let resultado = epoch_to_iso(0);
127        assert!(
128            resultado.starts_with("1970-01-01T00:00:00"),
129            "epoch 0 deve mapear para 1970-01-01T00:00:00, obtido: {resultado}"
130        );
131    }
132
133    #[test]
134    fn epoch_to_iso_converte_timestamp_conhecido() {
135        let resultado = epoch_to_iso(1_705_320_000);
136        assert!(
137            resultado.starts_with("2024-01-15"),
138            "timestamp 1705320000 deve mapear para 2024-01-15, obtido: {resultado}"
139        );
140    }
141
142    #[test]
143    fn epoch_to_iso_retorna_fallback_para_epoch_negativo_invalido() {
144        let resultado = epoch_to_iso(i64::MIN);
145        assert!(
146            !resultado.is_empty(),
147            "deve retornar string não vazia mesmo para epoch inválido"
148        );
149    }
150
151    #[test]
152    fn read_response_serializa_aliases_id_e_memory_id() {
153        let resp = ReadResponse {
154            id: 42,
155            memory_id: 42,
156            namespace: "global".to_string(),
157            name: "minha-mem".to_string(),
158            type_alias: "fact".to_string(),
159            memory_type: "fact".to_string(),
160            description: "desc".to_string(),
161            body: "corpo".to_string(),
162            body_hash: "abc123".to_string(),
163            session_id: None,
164            source: "agent".to_string(),
165            metadata: serde_json::json!({}),
166            version: 1,
167            created_at: 1_705_320_000,
168            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
169            updated_at: 1_705_320_000,
170            updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
171            elapsed_ms: 5,
172        };
173
174        let json = serde_json::to_value(&resp).expect("serialização falhou");
175        assert_eq!(json["id"], 42);
176        assert_eq!(json["memory_id"], 42);
177        assert_eq!(json["type"], "fact");
178        assert_eq!(json["memory_type"], "fact");
179        assert_eq!(json["elapsed_ms"], 5u64);
180        assert!(
181            json["session_id"].is_null(),
182            "session_id None deve serializar como null"
183        );
184        // metadata deve serializar como objeto JSON, não como string escapada
185        assert!(
186            json["metadata"].is_object(),
187            "metadata deve ser um objeto JSON"
188        );
189    }
190
191    #[test]
192    fn read_response_session_id_some_serializa_string() {
193        let resp = ReadResponse {
194            id: 1,
195            memory_id: 1,
196            namespace: "global".to_string(),
197            name: "mem".to_string(),
198            type_alias: "skill".to_string(),
199            memory_type: "skill".to_string(),
200            description: "d".to_string(),
201            body: "b".to_string(),
202            body_hash: "h".to_string(),
203            session_id: Some("sess-123".to_string()),
204            source: "agent".to_string(),
205            metadata: serde_json::json!({}),
206            version: 2,
207            created_at: 0,
208            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
209            updated_at: 0,
210            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
211            elapsed_ms: 0,
212        };
213
214        let json = serde_json::to_value(&resp).expect("serialização falhou");
215        assert_eq!(json["session_id"], "sess-123");
216    }
217
218    #[test]
219    fn read_response_elapsed_ms_esta_presente() {
220        let resp = ReadResponse {
221            id: 7,
222            memory_id: 7,
223            namespace: "ns".to_string(),
224            name: "n".to_string(),
225            type_alias: "procedure".to_string(),
226            memory_type: "procedure".to_string(),
227            description: "d".to_string(),
228            body: "b".to_string(),
229            body_hash: "h".to_string(),
230            session_id: None,
231            source: "agent".to_string(),
232            metadata: serde_json::json!({}),
233            version: 3,
234            created_at: 1000,
235            created_at_iso: "1970-01-01T00:16:40Z".to_string(),
236            updated_at: 2000,
237            updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
238            elapsed_ms: 123,
239        };
240
241        let json = serde_json::to_value(&resp).expect("serialização falhou");
242        assert_eq!(json["elapsed_ms"], 123u64);
243        assert!(json["created_at_iso"].is_string());
244        assert!(json["updated_at_iso"].is_string());
245    }
246
247    #[test]
248    fn read_response_metadata_object_nao_string_escapada() {
249        // P2-A: metadata deve serializar como objeto JSON, não como string escapada.
250        let resp = ReadResponse {
251            id: 3,
252            memory_id: 3,
253            namespace: "ns".to_string(),
254            name: "meta-test".to_string(),
255            type_alias: "fact".to_string(),
256            memory_type: "fact".to_string(),
257            description: "d".to_string(),
258            body: "b".to_string(),
259            body_hash: "h".to_string(),
260            session_id: None,
261            source: "agent".to_string(),
262            metadata: serde_json::json!({"chave": "valor", "numero": 42}),
263            version: 1,
264            created_at: 0,
265            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
266            updated_at: 0,
267            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
268            elapsed_ms: 1,
269        };
270
271        let json = serde_json::to_value(&resp).expect("serialização falhou");
272        // Must be object, not a JSON string containing escaped JSON.
273        assert!(json["metadata"].is_object());
274        assert_eq!(json["metadata"]["chave"], "valor");
275        assert_eq!(json["metadata"]["numero"], 42);
276    }
277
278    #[test]
279    fn read_response_metadata_fallback_para_null_em_json_invalido() {
280        // P2-A: fallback quando metadata é string inválida.
281        let raw = "json-invalido{{{";
282        let parsed =
283            serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
284        assert!(parsed.is_null());
285    }
286}