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 {
22 #[arg(
24 value_name = "NAME",
25 conflicts_with = "name",
26 help = "Memory name (kebab-case slug); alternative to --name"
27 )]
28 pub name_positional: Option<String>,
29 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(
34 long,
35 conflicts_with_all = ["name", "name_positional"],
36 help = "Memory ID (integer) for direct lookup"
37 )]
38 pub id: Option<i64>,
39 #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
40 pub namespace: Option<String>,
42 #[arg(
44 long,
45 help = "Include graph context (entities + relationships) in response"
46 )]
47 pub with_graph: bool,
48 #[arg(
52 long,
53 value_enum,
54 default_value_t = ReadFormat::Json,
55 help = "Output format: json (default) or raw (pure body to stdout)"
56 )]
57 pub format: ReadFormat,
58 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
60 pub json: bool,
61 #[arg(long)]
63 pub db: Option<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default)]
69#[value(rename_all = "lowercase")]
70pub enum ReadFormat {
71 #[default]
73 Json,
74 Raw,
76}
77
78#[derive(Serialize)]
79struct ReadResponse {
80 id: i64,
82 memory_id: i64,
84 namespace: String,
85 name: String,
86 #[serde(rename = "type")]
88 type_alias: String,
89 memory_type: String,
90 description: String,
91 body: String,
92 body_hash: String,
93 session_id: Option<String>,
94 source: String,
95 metadata: serde_json::Value,
96 version: i64,
98 created_at: i64,
99 created_at_iso: String,
101 updated_at: i64,
102 updated_at_iso: String,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 entities: Option<Vec<ReadEntityBinding>>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 relationships: Option<Vec<ReadRelationshipBinding>>,
110 elapsed_ms: u64,
112}
113
114#[derive(Serialize)]
115struct ReadEntityBinding {
116 entity_id: i64,
117 name: String,
118 entity_type: String,
119}
120
121#[derive(Serialize)]
122struct ReadRelationshipBinding {
123 from: String,
124 to: String,
125 relation: String,
126 weight: f64,
127}
128
129fn epoch_to_iso(epoch: i64) -> String {
130 crate::tz::epoch_to_iso(epoch)
131}
132
133pub fn run(args: ReadArgs) -> Result<(), AppError> {
135 let start = std::time::Instant::now();
136 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
137 let paths = AppPaths::resolve(args.db.as_deref())?;
138 crate::storage::connection::ensure_db_ready(&paths)?;
139 let conn = open_ro(&paths.db)?;
140
141 let row_opt = if let Some(id) = args.id {
142 let r = memories::read_full(&conn, id)?;
143 if let Some(ref row) = r {
144 if row.namespace != namespace {
145 return Err(AppError::NotFound(format!(
146 "memory id {id} exists but belongs to namespace '{}', not '{namespace}'",
147 row.namespace
148 )));
149 }
150 }
151 if r.is_none() {
152 return Err(AppError::MemoryNotFoundById { id });
155 }
156 r
157 } else {
158 let name = args
159 .name_positional
160 .clone()
161 .or(args.name.clone())
162 .ok_or_else(|| {
163 AppError::Validation(
164 "name or --id required: pass name as positional argument, via --name, or use --id"
165 .to_string(),
166 )
167 })?;
168 memories::read_by_name(&conn, &namespace, &name)?
169 };
170
171 match row_opt {
172 Some(row) => {
173 if args.format == ReadFormat::Raw {
177 output::emit_raw(row.body.as_bytes());
178 return Ok(());
179 }
180 let version: i64 = conn
182 .query_row(
183 "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
184 rusqlite::params![row.id],
185 |r| r.get(0),
186 )
187 .unwrap_or(1);
188
189 let (entities, relationships) = if args.with_graph {
191 let mut ent_stmt = conn.prepare_cached(
192 "SELECT e.id, e.name, e.type FROM memory_entities me \
193 JOIN entities e ON e.id = me.entity_id \
194 WHERE me.memory_id = ?1",
195 )?;
196 let ents: Vec<ReadEntityBinding> = ent_stmt
197 .query_map(rusqlite::params![row.id], |r| {
198 Ok(ReadEntityBinding {
199 entity_id: r.get(0)?,
200 name: r.get(1)?,
201 entity_type: r.get(2)?,
202 })
203 })?
204 .filter_map(|r| r.ok())
205 .collect();
206 drop(ent_stmt);
207
208 let entity_ids: Vec<i64> = ents.iter().map(|e| e.entity_id).collect();
209 let rels: Vec<ReadRelationshipBinding> = if !entity_ids.is_empty() {
210 let placeholders: String = entity_ids
211 .iter()
212 .map(|id| id.to_string())
213 .collect::<Vec<_>>()
214 .join(",");
215 let sql = format!(
216 "SELECT e1.name, e2.name, r.relation, r.weight \
217 FROM relationships r \
218 JOIN entities e1 ON e1.id = r.source_id \
219 JOIN entities e2 ON e2.id = r.target_id \
220 WHERE r.source_id IN ({placeholders}) OR r.target_id IN ({placeholders})"
221 );
222 let mut rel_stmt = conn.prepare(&sql)?;
223 let result: Vec<ReadRelationshipBinding> = rel_stmt
224 .query_map([], |r| {
225 Ok(ReadRelationshipBinding {
226 from: r.get(0)?,
227 to: r.get(1)?,
228 relation: r.get(2)?,
229 weight: r.get(3)?,
230 })
231 })?
232 .filter_map(|r| r.ok())
233 .collect();
234 drop(rel_stmt);
235 result
236 } else {
237 vec![]
238 };
239 (Some(ents), Some(rels))
240 } else {
241 (None, None)
242 };
243
244 let response = ReadResponse {
245 id: row.id,
246 memory_id: row.id,
247 namespace: row.namespace,
248 name: row.name,
249 type_alias: row.memory_type.clone(),
250 memory_type: row.memory_type,
251 description: row.description,
252 body: row.body,
253 body_hash: row.body_hash,
254 session_id: row.session_id,
255 source: row.source,
256 metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
257 .unwrap_or(serde_json::Value::Null),
258 version,
259 created_at: row.created_at,
260 created_at_iso: epoch_to_iso(row.created_at),
261 updated_at: row.updated_at,
262 updated_at_iso: epoch_to_iso(row.updated_at),
263 entities,
264 relationships,
265 elapsed_ms: start.elapsed().as_millis() as u64,
266 };
267 output::emit_json(&response)?;
268 }
269 None => {
270 if let Some(name) = args.name_positional.as_deref().or(args.name.as_deref()) {
277 return Err(AppError::MemoryNotFound {
278 name: name.to_string(),
279 namespace: namespace.clone(),
280 });
281 }
282 if let Some(id) = args.id {
286 return Err(AppError::MemoryNotFoundById { id });
287 }
288 return Err(AppError::Validation(
292 "internal: read reached NotFound without name or id".to_string(),
293 ));
294 }
295 }
296
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
306 fn read_format_flag_parses_raw_and_defaults_json() {
307 use crate::cli::{Cli, Commands};
308 use clap::Parser;
309
310 let raw = Cli::try_parse_from(["sqlite-graphrag", "read", "my-mem", "--format", "raw"])
311 .expect("parse raw");
312 match raw.command {
313 Some(Commands::Read(a)) => assert_eq!(a.format, ReadFormat::Raw),
314 other => panic!("expected read, got {other:?}"),
315 }
316
317 let dflt = Cli::try_parse_from(["sqlite-graphrag", "read", "my-mem"]).expect("parse");
318 match dflt.command {
319 Some(Commands::Read(a)) => assert_eq!(a.format, ReadFormat::Json),
320 other => panic!("expected read, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn epoch_to_iso_converts_zero_to_unix_epoch() {
326 let result = epoch_to_iso(0);
333 let parsed = chrono::DateTime::parse_from_rfc3339(&result)
334 .unwrap_or_else(|e| panic!("epoch_to_iso(0) returned non-RFC3339 `{result}`: {e}"));
335 assert_eq!(
336 parsed.timestamp(),
337 chrono::DateTime::UNIX_EPOCH.timestamp(),
338 "epoch 0 must map to the Unix epoch instant, got: {result}"
339 );
340 }
341
342 #[test]
343 fn epoch_to_iso_converts_known_timestamp() {
344 let result = epoch_to_iso(1_705_320_000);
349 let parsed = chrono::DateTime::parse_from_rfc3339(&result).unwrap_or_else(|e| {
350 panic!("epoch_to_iso(1705320000) returned non-RFC3339 `{result}`: {e}")
351 });
352 let expected = chrono::DateTime::parse_from_rfc3339("2024-01-15T12:00:00+00:00")
353 .expect("static RFC3339 is valid");
354 assert_eq!(
355 parsed.timestamp(),
356 expected.timestamp(),
357 "timestamp 1705320000 must map to 2024-01-15T12:00:00Z, got: {result}"
358 );
359 }
360
361 #[test]
362 fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
363 let result = epoch_to_iso(i64::MIN);
364 assert!(
365 !result.is_empty(),
366 "must return a non-empty string even for invalid epoch"
367 );
368 }
369
370 #[test]
371 fn read_response_serializes_id_and_memory_id_aliases() {
372 let resp = ReadResponse {
373 id: 42,
374 memory_id: 42,
375 namespace: "global".to_string(),
376 name: "my-mem".to_string(),
377 type_alias: "fact".to_string(),
378 memory_type: "fact".to_string(),
379 description: "desc".to_string(),
380 body: "body".to_string(),
381 body_hash: "abc123".to_string(),
382 session_id: None,
383 source: "agent".to_string(),
384 metadata: serde_json::json!({}),
385 version: 1,
386 created_at: 1_705_320_000,
387 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
388 updated_at: 1_705_320_000,
389 updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
390 entities: None,
391 relationships: None,
392 elapsed_ms: 5,
393 };
394
395 let json = serde_json::to_value(&resp).expect("serialization failed");
396 assert_eq!(json["id"], 42);
397 assert_eq!(json["memory_id"], 42);
398 assert_eq!(json["type"], "fact");
399 assert_eq!(json["memory_type"], "fact");
400 assert_eq!(json["elapsed_ms"], 5u64);
401 assert!(
402 json["session_id"].is_null(),
403 "session_id None must serialize as null"
404 );
405 assert!(
407 json["metadata"].is_object(),
408 "metadata must be a JSON object"
409 );
410 }
411
412 #[test]
413 fn read_response_session_id_some_serializes_string() {
414 let resp = ReadResponse {
415 id: 1,
416 memory_id: 1,
417 namespace: "global".to_string(),
418 name: "mem".to_string(),
419 type_alias: "skill".to_string(),
420 memory_type: "skill".to_string(),
421 description: "d".to_string(),
422 body: "b".to_string(),
423 body_hash: "h".to_string(),
424 session_id: Some("sess-123".to_string()),
425 source: "agent".to_string(),
426 metadata: serde_json::json!({}),
427 version: 2,
428 created_at: 0,
429 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
430 updated_at: 0,
431 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
432 entities: None,
433 relationships: None,
434 elapsed_ms: 0,
435 };
436
437 let json = serde_json::to_value(&resp).expect("serialization failed");
438 assert_eq!(json["session_id"], "sess-123");
439 }
440
441 #[test]
442 fn read_response_elapsed_ms_is_present() {
443 let resp = ReadResponse {
444 id: 7,
445 memory_id: 7,
446 namespace: "ns".to_string(),
447 name: "n".to_string(),
448 type_alias: "procedure".to_string(),
449 memory_type: "procedure".to_string(),
450 description: "d".to_string(),
451 body: "b".to_string(),
452 body_hash: "h".to_string(),
453 session_id: None,
454 source: "agent".to_string(),
455 metadata: serde_json::json!({}),
456 version: 3,
457 created_at: 1000,
458 created_at_iso: "1970-01-01T00:16:40Z".to_string(),
459 updated_at: 2000,
460 updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
461 entities: None,
462 relationships: None,
463 elapsed_ms: 123,
464 };
465
466 let json = serde_json::to_value(&resp).expect("serialization failed");
467 assert_eq!(json["elapsed_ms"], 123u64);
468 assert!(json["created_at_iso"].is_string());
469 assert!(json["updated_at_iso"].is_string());
470 }
471
472 #[test]
473 fn read_response_metadata_object_not_escaped_string() {
474 let resp = ReadResponse {
476 id: 3,
477 memory_id: 3,
478 namespace: "ns".to_string(),
479 name: "meta-test".to_string(),
480 type_alias: "fact".to_string(),
481 memory_type: "fact".to_string(),
482 description: "d".to_string(),
483 body: "b".to_string(),
484 body_hash: "h".to_string(),
485 session_id: None,
486 source: "agent".to_string(),
487 metadata: serde_json::json!({"key": "value", "number": 42}),
488 version: 1,
489 created_at: 0,
490 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
491 updated_at: 0,
492 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
493 entities: None,
494 relationships: None,
495 elapsed_ms: 1,
496 };
497
498 let json = serde_json::to_value(&resp).expect("serialization failed");
499 assert!(json["metadata"].is_object());
501 assert_eq!(json["metadata"]["key"], "value");
502 assert_eq!(json["metadata"]["number"], 42);
503 }
504
505 #[test]
506 fn read_response_metadata_fallback_to_null_for_invalid_json() {
507 let raw = "invalid-json{{{";
509 let parsed =
510 serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
511 assert!(parsed.is_null());
512 }
513
514 #[test]
518 fn memory_not_found_structural_includes_name_and_namespace() {
519 let err = AppError::MemoryNotFound {
520 name: "atomwrite-projeto-contexto".to_string(),
521 namespace: "global".to_string(),
522 };
523 let msg = err.to_string();
524 assert!(msg.contains("atomwrite-projeto-contexto"), "got: {msg}");
525 assert!(msg.contains("global"), "got: {msg}");
526 assert!(
527 !msg.contains("unknown"),
528 "must not contain 'unknown': {msg}"
529 );
530 assert_eq!(err.exit_code(), 4);
531 assert!(err.is_permanent());
532 }
533
534 #[test]
535 fn memory_not_found_by_id_structural_includes_id() {
536 let err = AppError::MemoryNotFoundById { id: 42 };
537 let msg = err.to_string();
538 assert!(msg.contains("42"), "got: {msg}");
539 assert!(msg.contains("id=42"), "got: {msg}");
540 assert_eq!(err.exit_code(), 4);
541 }
542
543 #[test]
544 fn memory_not_found_pt_br_drops_english_fragments() {
545 use crate::i18n::Language;
548 let err = AppError::MemoryNotFound {
549 name: "mem-fantasma".to_string(),
550 namespace: "global".to_string(),
551 };
552 let pt = err.localized_message_for(Language::Portuguese);
553 assert!(!pt.contains("not found"), "pt-BR fragment leaked: {pt}");
554 assert!(pt.contains("mem-fantasma"), "name missing in pt: {pt}");
555 assert!(pt.contains("global"), "namespace missing in pt: {pt}");
556 }
557}