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);
251 assert!(
252 result.starts_with("1970-01-01T00:00:00"),
253 "epoch 0 must map to 1970-01-01T00:00:00, got: {result}"
254 );
255 }
256
257 #[test]
258 fn epoch_to_iso_converts_known_timestamp() {
259 let result = epoch_to_iso(1_705_320_000);
260 assert!(
261 result.starts_with("2024-01-15"),
262 "timestamp 1705320000 must map to 2024-01-15, got: {result}"
263 );
264 }
265
266 #[test]
267 fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
268 let result = epoch_to_iso(i64::MIN);
269 assert!(
270 !result.is_empty(),
271 "must return a non-empty string even for invalid epoch"
272 );
273 }
274
275 #[test]
276 fn read_response_serializes_id_and_memory_id_aliases() {
277 let resp = ReadResponse {
278 id: 42,
279 memory_id: 42,
280 namespace: "global".to_string(),
281 name: "my-mem".to_string(),
282 type_alias: "fact".to_string(),
283 memory_type: "fact".to_string(),
284 description: "desc".to_string(),
285 body: "body".to_string(),
286 body_hash: "abc123".to_string(),
287 session_id: None,
288 source: "agent".to_string(),
289 metadata: serde_json::json!({}),
290 version: 1,
291 created_at: 1_705_320_000,
292 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
293 updated_at: 1_705_320_000,
294 updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
295 entities: None,
296 relationships: None,
297 elapsed_ms: 5,
298 };
299
300 let json = serde_json::to_value(&resp).expect("serialization failed");
301 assert_eq!(json["id"], 42);
302 assert_eq!(json["memory_id"], 42);
303 assert_eq!(json["type"], "fact");
304 assert_eq!(json["memory_type"], "fact");
305 assert_eq!(json["elapsed_ms"], 5u64);
306 assert!(
307 json["session_id"].is_null(),
308 "session_id None must serialize as null"
309 );
310 assert!(
312 json["metadata"].is_object(),
313 "metadata must be a JSON object"
314 );
315 }
316
317 #[test]
318 fn read_response_session_id_some_serializes_string() {
319 let resp = ReadResponse {
320 id: 1,
321 memory_id: 1,
322 namespace: "global".to_string(),
323 name: "mem".to_string(),
324 type_alias: "skill".to_string(),
325 memory_type: "skill".to_string(),
326 description: "d".to_string(),
327 body: "b".to_string(),
328 body_hash: "h".to_string(),
329 session_id: Some("sess-123".to_string()),
330 source: "agent".to_string(),
331 metadata: serde_json::json!({}),
332 version: 2,
333 created_at: 0,
334 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
335 updated_at: 0,
336 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
337 entities: None,
338 relationships: None,
339 elapsed_ms: 0,
340 };
341
342 let json = serde_json::to_value(&resp).expect("serialization failed");
343 assert_eq!(json["session_id"], "sess-123");
344 }
345
346 #[test]
347 fn read_response_elapsed_ms_is_present() {
348 let resp = ReadResponse {
349 id: 7,
350 memory_id: 7,
351 namespace: "ns".to_string(),
352 name: "n".to_string(),
353 type_alias: "procedure".to_string(),
354 memory_type: "procedure".to_string(),
355 description: "d".to_string(),
356 body: "b".to_string(),
357 body_hash: "h".to_string(),
358 session_id: None,
359 source: "agent".to_string(),
360 metadata: serde_json::json!({}),
361 version: 3,
362 created_at: 1000,
363 created_at_iso: "1970-01-01T00:16:40Z".to_string(),
364 updated_at: 2000,
365 updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
366 entities: None,
367 relationships: None,
368 elapsed_ms: 123,
369 };
370
371 let json = serde_json::to_value(&resp).expect("serialization failed");
372 assert_eq!(json["elapsed_ms"], 123u64);
373 assert!(json["created_at_iso"].is_string());
374 assert!(json["updated_at_iso"].is_string());
375 }
376
377 #[test]
378 fn read_response_metadata_object_not_escaped_string() {
379 let resp = ReadResponse {
381 id: 3,
382 memory_id: 3,
383 namespace: "ns".to_string(),
384 name: "meta-test".to_string(),
385 type_alias: "fact".to_string(),
386 memory_type: "fact".to_string(),
387 description: "d".to_string(),
388 body: "b".to_string(),
389 body_hash: "h".to_string(),
390 session_id: None,
391 source: "agent".to_string(),
392 metadata: serde_json::json!({"key": "value", "number": 42}),
393 version: 1,
394 created_at: 0,
395 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
396 updated_at: 0,
397 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
398 entities: None,
399 relationships: None,
400 elapsed_ms: 1,
401 };
402
403 let json = serde_json::to_value(&resp).expect("serialization failed");
404 assert!(json["metadata"].is_object());
406 assert_eq!(json["metadata"]["key"], "value");
407 assert_eq!(json["metadata"]["number"], 42);
408 }
409
410 #[test]
411 fn read_response_metadata_fallback_to_null_for_invalid_json() {
412 let raw = "invalid-json{{{";
414 let parsed =
415 serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
416 assert!(parsed.is_null());
417 }
418}