1use crate::errors::AppError;
4use crate::output;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_ro;
7use crate::storage::memories;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11#[command(after_long_help = "EXAMPLES:\n \
12 # Read a memory by name (positional)\n \
13 sqlite-graphrag read onboarding\n\n \
14 # Read using the named flag form\n \
15 sqlite-graphrag read --name onboarding\n\n \
16 # Read by memory ID (integer emitted in JSON output of most commands)\n \
17 sqlite-graphrag read --id 42 --json\n\n \
18 # Read from a specific namespace\n \
19 sqlite-graphrag read onboarding --namespace my-project")]
20pub struct ReadArgs {
21 #[arg(
23 value_name = "NAME",
24 conflicts_with = "name",
25 help = "Memory name (kebab-case slug); alternative to --name"
26 )]
27 pub name_positional: Option<String>,
28 #[arg(long)]
30 pub name: Option<String>,
31 #[arg(
33 long,
34 conflicts_with_all = ["name", "name_positional"],
35 help = "Memory ID (integer) for direct lookup"
36 )]
37 pub id: Option<i64>,
38 #[arg(
39 long,
40 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
41 )]
42 pub namespace: Option<String>,
43 #[arg(
45 long,
46 help = "Include graph context (entities + relationships) in response"
47 )]
48 pub with_graph: bool,
49 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
50 pub json: bool,
51 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
52 pub db: Option<String>,
53}
54
55#[derive(Serialize)]
56struct ReadResponse {
57 id: i64,
59 memory_id: i64,
61 namespace: String,
62 name: String,
63 #[serde(rename = "type")]
65 type_alias: String,
66 memory_type: String,
67 description: String,
68 body: String,
69 body_hash: String,
70 session_id: Option<String>,
71 source: String,
72 metadata: serde_json::Value,
73 version: i64,
75 created_at: i64,
76 created_at_iso: String,
78 updated_at: i64,
79 updated_at_iso: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 entities: Option<Vec<ReadEntityBinding>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 relationships: Option<Vec<ReadRelationshipBinding>>,
87 elapsed_ms: u64,
89}
90
91#[derive(Serialize)]
92struct ReadEntityBinding {
93 entity_id: i64,
94 name: String,
95 entity_type: String,
96}
97
98#[derive(Serialize)]
99struct ReadRelationshipBinding {
100 from: String,
101 to: String,
102 relation: String,
103 weight: f64,
104}
105
106fn epoch_to_iso(epoch: i64) -> String {
107 crate::tz::epoch_to_iso(epoch)
108}
109
110pub fn run(args: ReadArgs) -> Result<(), AppError> {
111 let start = std::time::Instant::now();
112 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
113 let paths = AppPaths::resolve(args.db.as_deref())?;
114 crate::storage::connection::ensure_db_ready(&paths)?;
115 let conn = open_ro(&paths.db)?;
116
117 let row_opt = if let Some(id) = args.id {
118 let r = memories::read_full(&conn, id)?;
119 if let Some(ref row) = r {
120 if row.namespace != namespace {
121 return Err(AppError::NotFound(format!(
122 "memory id {id} exists but belongs to namespace '{}', not '{namespace}'",
123 row.namespace
124 )));
125 }
126 }
127 r
128 } else {
129 let name = args.name_positional.or(args.name).ok_or_else(|| {
130 AppError::Validation(
131 "name or --id required: pass name as positional argument, via --name, or use --id"
132 .to_string(),
133 )
134 })?;
135 memories::read_by_name(&conn, &namespace, &name)?
136 };
137
138 match row_opt {
139 Some(row) => {
140 let version: i64 = conn
142 .query_row(
143 "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
144 rusqlite::params![row.id],
145 |r| r.get(0),
146 )
147 .unwrap_or(1);
148
149 let (entities, relationships) = if args.with_graph {
151 let mut ent_stmt = conn.prepare_cached(
152 "SELECT e.id, e.name, e.type FROM memory_entities me \
153 JOIN entities e ON e.id = me.entity_id \
154 WHERE me.memory_id = ?1",
155 )?;
156 let ents: Vec<ReadEntityBinding> = ent_stmt
157 .query_map(rusqlite::params![row.id], |r| {
158 Ok(ReadEntityBinding {
159 entity_id: r.get(0)?,
160 name: r.get(1)?,
161 entity_type: r.get(2)?,
162 })
163 })?
164 .filter_map(|r| r.ok())
165 .collect();
166 drop(ent_stmt);
167
168 let entity_ids: Vec<i64> = ents.iter().map(|e| e.entity_id).collect();
169 let rels: Vec<ReadRelationshipBinding> = if !entity_ids.is_empty() {
170 let placeholders: String = entity_ids
171 .iter()
172 .map(|id| id.to_string())
173 .collect::<Vec<_>>()
174 .join(",");
175 let sql = format!(
176 "SELECT e1.name, e2.name, r.relation, r.weight \
177 FROM relationships r \
178 JOIN entities e1 ON e1.id = r.source_id \
179 JOIN entities e2 ON e2.id = r.target_id \
180 WHERE r.source_id IN ({placeholders}) OR r.target_id IN ({placeholders})"
181 );
182 let mut rel_stmt = conn.prepare(&sql)?;
183 let result: Vec<ReadRelationshipBinding> = rel_stmt
184 .query_map([], |r| {
185 Ok(ReadRelationshipBinding {
186 from: r.get(0)?,
187 to: r.get(1)?,
188 relation: r.get(2)?,
189 weight: r.get(3)?,
190 })
191 })?
192 .filter_map(|r| r.ok())
193 .collect();
194 drop(rel_stmt);
195 result
196 } else {
197 vec![]
198 };
199 (Some(ents), Some(rels))
200 } else {
201 (None, None)
202 };
203
204 let response = ReadResponse {
205 id: row.id,
206 memory_id: row.id,
207 namespace: row.namespace,
208 name: row.name,
209 type_alias: row.memory_type.clone(),
210 memory_type: row.memory_type,
211 description: row.description,
212 body: row.body,
213 body_hash: row.body_hash,
214 session_id: row.session_id,
215 source: row.source,
216 metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
217 .unwrap_or(serde_json::Value::Null),
218 version,
219 created_at: row.created_at,
220 created_at_iso: epoch_to_iso(row.created_at),
221 updated_at: row.updated_at,
222 updated_at_iso: epoch_to_iso(row.updated_at),
223 entities,
224 relationships,
225 elapsed_ms: start.elapsed().as_millis() as u64,
226 };
227 output::emit_json(&response)?;
228 }
229 None => {
230 let label = if let Some(id) = args.id {
231 format!("id={id}")
232 } else {
233 "unknown".to_string()
234 };
235 return Err(AppError::NotFound(format!(
236 "memory not found: {label} in namespace '{namespace}'"
237 )));
238 }
239 }
240
241 Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn epoch_to_iso_converts_zero_to_unix_epoch() {
250 let result = epoch_to_iso(0);
257 let parsed = chrono::DateTime::parse_from_rfc3339(&result)
258 .unwrap_or_else(|e| panic!("epoch_to_iso(0) returned non-RFC3339 `{result}`: {e}"));
259 assert_eq!(
260 parsed.timestamp(),
261 chrono::DateTime::UNIX_EPOCH.timestamp(),
262 "epoch 0 must map to the Unix epoch instant, got: {result}"
263 );
264 }
265
266 #[test]
267 fn epoch_to_iso_converts_known_timestamp() {
268 let result = epoch_to_iso(1_705_320_000);
273 let parsed = chrono::DateTime::parse_from_rfc3339(&result).unwrap_or_else(|e| {
274 panic!("epoch_to_iso(1705320000) returned non-RFC3339 `{result}`: {e}")
275 });
276 let expected = chrono::DateTime::parse_from_rfc3339("2024-01-15T12:00:00+00:00")
277 .expect("static RFC3339 is valid");
278 assert_eq!(
279 parsed.timestamp(),
280 expected.timestamp(),
281 "timestamp 1705320000 must map to 2024-01-15T12:00:00Z, got: {result}"
282 );
283 }
284
285 #[test]
286 fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
287 let result = epoch_to_iso(i64::MIN);
288 assert!(
289 !result.is_empty(),
290 "must return a non-empty string even for invalid epoch"
291 );
292 }
293
294 #[test]
295 fn read_response_serializes_id_and_memory_id_aliases() {
296 let resp = ReadResponse {
297 id: 42,
298 memory_id: 42,
299 namespace: "global".to_string(),
300 name: "my-mem".to_string(),
301 type_alias: "fact".to_string(),
302 memory_type: "fact".to_string(),
303 description: "desc".to_string(),
304 body: "body".to_string(),
305 body_hash: "abc123".to_string(),
306 session_id: None,
307 source: "agent".to_string(),
308 metadata: serde_json::json!({}),
309 version: 1,
310 created_at: 1_705_320_000,
311 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
312 updated_at: 1_705_320_000,
313 updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
314 entities: None,
315 relationships: None,
316 elapsed_ms: 5,
317 };
318
319 let json = serde_json::to_value(&resp).expect("serialization failed");
320 assert_eq!(json["id"], 42);
321 assert_eq!(json["memory_id"], 42);
322 assert_eq!(json["type"], "fact");
323 assert_eq!(json["memory_type"], "fact");
324 assert_eq!(json["elapsed_ms"], 5u64);
325 assert!(
326 json["session_id"].is_null(),
327 "session_id None must serialize as null"
328 );
329 assert!(
331 json["metadata"].is_object(),
332 "metadata must be a JSON object"
333 );
334 }
335
336 #[test]
337 fn read_response_session_id_some_serializes_string() {
338 let resp = ReadResponse {
339 id: 1,
340 memory_id: 1,
341 namespace: "global".to_string(),
342 name: "mem".to_string(),
343 type_alias: "skill".to_string(),
344 memory_type: "skill".to_string(),
345 description: "d".to_string(),
346 body: "b".to_string(),
347 body_hash: "h".to_string(),
348 session_id: Some("sess-123".to_string()),
349 source: "agent".to_string(),
350 metadata: serde_json::json!({}),
351 version: 2,
352 created_at: 0,
353 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
354 updated_at: 0,
355 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
356 entities: None,
357 relationships: None,
358 elapsed_ms: 0,
359 };
360
361 let json = serde_json::to_value(&resp).expect("serialization failed");
362 assert_eq!(json["session_id"], "sess-123");
363 }
364
365 #[test]
366 fn read_response_elapsed_ms_is_present() {
367 let resp = ReadResponse {
368 id: 7,
369 memory_id: 7,
370 namespace: "ns".to_string(),
371 name: "n".to_string(),
372 type_alias: "procedure".to_string(),
373 memory_type: "procedure".to_string(),
374 description: "d".to_string(),
375 body: "b".to_string(),
376 body_hash: "h".to_string(),
377 session_id: None,
378 source: "agent".to_string(),
379 metadata: serde_json::json!({}),
380 version: 3,
381 created_at: 1000,
382 created_at_iso: "1970-01-01T00:16:40Z".to_string(),
383 updated_at: 2000,
384 updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
385 entities: None,
386 relationships: None,
387 elapsed_ms: 123,
388 };
389
390 let json = serde_json::to_value(&resp).expect("serialization failed");
391 assert_eq!(json["elapsed_ms"], 123u64);
392 assert!(json["created_at_iso"].is_string());
393 assert!(json["updated_at_iso"].is_string());
394 }
395
396 #[test]
397 fn read_response_metadata_object_not_escaped_string() {
398 let resp = ReadResponse {
400 id: 3,
401 memory_id: 3,
402 namespace: "ns".to_string(),
403 name: "meta-test".to_string(),
404 type_alias: "fact".to_string(),
405 memory_type: "fact".to_string(),
406 description: "d".to_string(),
407 body: "b".to_string(),
408 body_hash: "h".to_string(),
409 session_id: None,
410 source: "agent".to_string(),
411 metadata: serde_json::json!({"key": "value", "number": 42}),
412 version: 1,
413 created_at: 0,
414 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
415 updated_at: 0,
416 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
417 entities: None,
418 relationships: None,
419 elapsed_ms: 1,
420 };
421
422 let json = serde_json::to_value(&resp).expect("serialization failed");
423 assert!(json["metadata"].is_object());
425 assert_eq!(json["metadata"]["key"], "value");
426 assert_eq!(json["metadata"]["number"], 42);
427 }
428
429 #[test]
430 fn read_response_metadata_fallback_to_null_for_invalid_json() {
431 let raw = "invalid-json{{{";
433 let parsed =
434 serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
435 assert!(parsed.is_null());
436 }
437}