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(
146 crate::i18n::validation::memory_id_in_other_namespace(
147 id,
148 &row.namespace,
149 &namespace,
150 ),
151 ));
152 }
153 }
154 if r.is_none() {
155 return Err(AppError::MemoryNotFoundById { id });
158 }
159 r
160 } else {
161 let name = args
162 .name_positional
163 .clone()
164 .or(args.name.clone())
165 .ok_or_else(|| AppError::Validation(crate::i18n::validation::name_or_id_required()))?;
166 memories::read_by_name(&conn, &namespace, &name)?
167 };
168
169 match row_opt {
170 Some(row) => {
171 if args.format == ReadFormat::Raw {
175 output::emit_raw(row.body.as_bytes());
176 return Ok(());
177 }
178 let version: i64 = conn
180 .query_row(
181 "SELECT COALESCE(MAX(version), 1) FROM memory_versions WHERE memory_id=?1",
182 rusqlite::params![row.id],
183 |r| r.get(0),
184 )
185 .unwrap_or(1);
186
187 let (entities, relationships) = if args.with_graph {
189 let mut ent_stmt = conn.prepare_cached(
190 "SELECT e.id, e.name, e.type FROM memory_entities me \
191 JOIN entities e ON e.id = me.entity_id \
192 WHERE me.memory_id = ?1",
193 )?;
194 let ents: Vec<ReadEntityBinding> = ent_stmt
195 .query_map(rusqlite::params![row.id], |r| {
196 Ok(ReadEntityBinding {
197 entity_id: r.get(0)?,
198 name: r.get(1)?,
199 entity_type: r.get(2)?,
200 })
201 })?
202 .filter_map(|r| r.ok())
203 .collect();
204 drop(ent_stmt);
205
206 let entity_ids: Vec<i64> = ents.iter().map(|e| e.entity_id).collect();
207 let rels: Vec<ReadRelationshipBinding> = if !entity_ids.is_empty() {
208 let placeholders: String = entity_ids
209 .iter()
210 .map(|id| id.to_string())
211 .collect::<Vec<_>>()
212 .join(",");
213 let sql = format!(
214 "SELECT e1.name, e2.name, r.relation, r.weight \
215 FROM relationships r \
216 JOIN entities e1 ON e1.id = r.source_id \
217 JOIN entities e2 ON e2.id = r.target_id \
218 WHERE r.source_id IN ({placeholders}) OR r.target_id IN ({placeholders})"
219 );
220 let mut rel_stmt = conn.prepare(&sql)?;
221 let result: Vec<ReadRelationshipBinding> = rel_stmt
222 .query_map([], |r| {
223 Ok(ReadRelationshipBinding {
224 from: r.get(0)?,
225 to: r.get(1)?,
226 relation: r.get(2)?,
227 weight: r.get(3)?,
228 })
229 })?
230 .filter_map(|r| r.ok())
231 .collect();
232 drop(rel_stmt);
233 result
234 } else {
235 vec![]
236 };
237 (Some(ents), Some(rels))
238 } else {
239 (None, None)
240 };
241
242 let response = ReadResponse {
243 id: row.id,
244 memory_id: row.id,
245 namespace: row.namespace,
246 name: row.name,
247 type_alias: row.memory_type.clone(),
248 memory_type: row.memory_type,
249 description: row.description,
250 body: row.body,
251 body_hash: row.body_hash,
252 session_id: row.session_id,
253 source: row.source,
254 metadata: serde_json::from_str::<serde_json::Value>(&row.metadata)
255 .unwrap_or(serde_json::Value::Null),
256 version,
257 created_at: row.created_at,
258 created_at_iso: epoch_to_iso(row.created_at),
259 updated_at: row.updated_at,
260 updated_at_iso: epoch_to_iso(row.updated_at),
261 entities,
262 relationships,
263 elapsed_ms: start.elapsed().as_millis() as u64,
264 };
265 output::emit_json(&response)?;
266 }
267 None => {
268 if let Some(name) = args.name_positional.as_deref().or(args.name.as_deref()) {
275 return Err(AppError::MemoryNotFound {
276 name: name.to_string(),
277 namespace: namespace.clone(),
278 });
279 }
280 if let Some(id) = args.id {
284 return Err(AppError::MemoryNotFoundById { id });
285 }
286 return Err(AppError::Validation(
290 "internal: read reached NotFound without name or id".to_string(),
291 ));
292 }
293 }
294
295 Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
304 fn read_format_flag_parses_raw_and_defaults_json() {
305 use crate::cli::{Cli, Commands};
306 use clap::Parser;
307
308 let raw = Cli::try_parse_from(["sqlite-graphrag", "read", "my-mem", "--format", "raw"])
309 .expect("parse raw");
310 match raw.command {
311 Some(Commands::Read(a)) => assert_eq!(a.format, ReadFormat::Raw),
312 other => panic!("expected read, got {other:?}"),
313 }
314
315 let dflt = Cli::try_parse_from(["sqlite-graphrag", "read", "my-mem"]).expect("parse");
316 match dflt.command {
317 Some(Commands::Read(a)) => assert_eq!(a.format, ReadFormat::Json),
318 other => panic!("expected read, got {other:?}"),
319 }
320 }
321
322 #[test]
323 fn epoch_to_iso_converts_zero_to_unix_epoch() {
324 let result = epoch_to_iso(0);
331 let parsed = chrono::DateTime::parse_from_rfc3339(&result)
332 .unwrap_or_else(|e| panic!("epoch_to_iso(0) returned non-RFC3339 `{result}`: {e}"));
333 assert_eq!(
334 parsed.timestamp(),
335 chrono::DateTime::UNIX_EPOCH.timestamp(),
336 "epoch 0 must map to the Unix epoch instant, got: {result}"
337 );
338 }
339
340 #[test]
341 fn epoch_to_iso_converts_known_timestamp() {
342 let result = epoch_to_iso(1_705_320_000);
347 let parsed = chrono::DateTime::parse_from_rfc3339(&result).unwrap_or_else(|e| {
348 panic!("epoch_to_iso(1705320000) returned non-RFC3339 `{result}`: {e}")
349 });
350 let expected = chrono::DateTime::parse_from_rfc3339("2024-01-15T12:00:00+00:00")
351 .expect("static RFC3339 is valid");
352 assert_eq!(
353 parsed.timestamp(),
354 expected.timestamp(),
355 "timestamp 1705320000 must map to 2024-01-15T12:00:00Z, got: {result}"
356 );
357 }
358
359 #[test]
360 fn epoch_to_iso_returns_fallback_for_invalid_negative_epoch() {
361 let result = epoch_to_iso(i64::MIN);
362 assert!(
363 !result.is_empty(),
364 "must return a non-empty string even for invalid epoch"
365 );
366 }
367
368 #[test]
369 fn read_response_serializes_id_and_memory_id_aliases() {
370 let resp = ReadResponse {
371 id: 42,
372 memory_id: 42,
373 namespace: "global".to_string(),
374 name: "my-mem".to_string(),
375 type_alias: "fact".to_string(),
376 memory_type: "fact".to_string(),
377 description: "desc".to_string(),
378 body: "body".to_string(),
379 body_hash: "abc123".to_string(),
380 session_id: None,
381 source: "agent".to_string(),
382 metadata: serde_json::json!({}),
383 version: 1,
384 created_at: 1_705_320_000,
385 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
386 updated_at: 1_705_320_000,
387 updated_at_iso: "2024-01-15T12:00:00Z".to_string(),
388 entities: None,
389 relationships: None,
390 elapsed_ms: 5,
391 };
392
393 let json = serde_json::to_value(&resp).expect("serialization failed");
394 assert_eq!(json["id"], 42);
395 assert_eq!(json["memory_id"], 42);
396 assert_eq!(json["type"], "fact");
397 assert_eq!(json["memory_type"], "fact");
398 assert_eq!(json["elapsed_ms"], 5u64);
399 assert!(
400 json["session_id"].is_null(),
401 "session_id None must serialize as null"
402 );
403 assert!(
405 json["metadata"].is_object(),
406 "metadata must be a JSON object"
407 );
408 }
409
410 #[test]
411 fn read_response_session_id_some_serializes_string() {
412 let resp = ReadResponse {
413 id: 1,
414 memory_id: 1,
415 namespace: "global".to_string(),
416 name: "mem".to_string(),
417 type_alias: "skill".to_string(),
418 memory_type: "skill".to_string(),
419 description: "d".to_string(),
420 body: "b".to_string(),
421 body_hash: "h".to_string(),
422 session_id: Some("sess-123".to_string()),
423 source: "agent".to_string(),
424 metadata: serde_json::json!({}),
425 version: 2,
426 created_at: 0,
427 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
428 updated_at: 0,
429 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
430 entities: None,
431 relationships: None,
432 elapsed_ms: 0,
433 };
434
435 let json = serde_json::to_value(&resp).expect("serialization failed");
436 assert_eq!(json["session_id"], "sess-123");
437 }
438
439 #[test]
440 fn read_response_elapsed_ms_is_present() {
441 let resp = ReadResponse {
442 id: 7,
443 memory_id: 7,
444 namespace: "ns".to_string(),
445 name: "n".to_string(),
446 type_alias: "procedure".to_string(),
447 memory_type: "procedure".to_string(),
448 description: "d".to_string(),
449 body: "b".to_string(),
450 body_hash: "h".to_string(),
451 session_id: None,
452 source: "agent".to_string(),
453 metadata: serde_json::json!({}),
454 version: 3,
455 created_at: 1000,
456 created_at_iso: "1970-01-01T00:16:40Z".to_string(),
457 updated_at: 2000,
458 updated_at_iso: "1970-01-01T00:33:20Z".to_string(),
459 entities: None,
460 relationships: None,
461 elapsed_ms: 123,
462 };
463
464 let json = serde_json::to_value(&resp).expect("serialization failed");
465 assert_eq!(json["elapsed_ms"], 123u64);
466 assert!(json["created_at_iso"].is_string());
467 assert!(json["updated_at_iso"].is_string());
468 }
469
470 #[test]
471 fn read_response_metadata_object_not_escaped_string() {
472 let resp = ReadResponse {
474 id: 3,
475 memory_id: 3,
476 namespace: "ns".to_string(),
477 name: "meta-test".to_string(),
478 type_alias: "fact".to_string(),
479 memory_type: "fact".to_string(),
480 description: "d".to_string(),
481 body: "b".to_string(),
482 body_hash: "h".to_string(),
483 session_id: None,
484 source: "agent".to_string(),
485 metadata: serde_json::json!({"key": "value", "number": 42}),
486 version: 1,
487 created_at: 0,
488 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
489 updated_at: 0,
490 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
491 entities: None,
492 relationships: None,
493 elapsed_ms: 1,
494 };
495
496 let json = serde_json::to_value(&resp).expect("serialization failed");
497 assert!(json["metadata"].is_object());
499 assert_eq!(json["metadata"]["key"], "value");
500 assert_eq!(json["metadata"]["number"], 42);
501 }
502
503 #[test]
504 fn read_response_metadata_fallback_to_null_for_invalid_json() {
505 let raw = "invalid-json{{{";
507 let parsed =
508 serde_json::from_str::<serde_json::Value>(raw).unwrap_or(serde_json::Value::Null);
509 assert!(parsed.is_null());
510 }
511
512 #[test]
516 fn memory_not_found_structural_includes_name_and_namespace() {
517 let err = AppError::MemoryNotFound {
518 name: "atomwrite-projeto-contexto".to_string(),
519 namespace: "global".to_string(),
520 };
521 let msg = err.to_string();
522 assert!(msg.contains("atomwrite-projeto-contexto"), "got: {msg}");
523 assert!(msg.contains("global"), "got: {msg}");
524 assert!(
525 !msg.contains("unknown"),
526 "must not contain 'unknown': {msg}"
527 );
528 assert_eq!(err.exit_code(), 4);
529 assert!(err.is_permanent());
530 }
531
532 #[test]
533 fn memory_not_found_by_id_structural_includes_id() {
534 let err = AppError::MemoryNotFoundById { id: 42 };
535 let msg = err.to_string();
536 assert!(msg.contains("42"), "got: {msg}");
537 assert!(msg.contains("id=42"), "got: {msg}");
538 assert_eq!(err.exit_code(), 4);
539 }
540
541 #[test]
542 fn memory_not_found_pt_br_drops_english_fragments() {
543 use crate::i18n::Language;
546 let err = AppError::MemoryNotFound {
547 name: "mem-fantasma".to_string(),
548 namespace: "global".to_string(),
549 };
550 let pt = err.localized_message_for(Language::Portuguese);
551 assert!(!pt.contains("not found"), "pt-BR fragment leaked: {pt}");
552 assert!(pt.contains("mem-fantasma"), "name missing in pt: {pt}");
553 assert!(pt.contains("global"), "namespace missing in pt: {pt}");
554 }
555}