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