Skip to main content

sqlite_graphrag/commands/
read.rs

1//! Handler for the `read` CLI subcommand.
2
3use 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")]
20/// Read args.
21pub struct ReadArgs {
22    /// Memory name as a positional argument. Alternative to `--name`.
23    #[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    /// Memory name to read. Returns NotFound (exit 4) if missing or soft-deleted.
30    #[arg(long)]
31    pub name: Option<String>,
32    /// Memory ID (integer) for direct lookup. Conflicts with --name and positional NAME.
33    #[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    /// Namespace scope.
44    pub namespace: Option<String>,
45    /// Include linked entities and relationships in the response.
46    #[arg(
47        long,
48        help = "Include graph context (entities + relationships) in response"
49    )]
50    pub with_graph: bool,
51    /// Output format: `json` (default, full envelope) or `raw` (the pure memory
52    /// body to stdout, no JSON wrapper). GAP-SG-50: `raw` lets the body be piped
53    /// without a `jaq -r '.body'` round-trip.
54    #[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    /// Emit machine-readable JSON on stdout.
62    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
63    pub json: bool,
64    /// Path to the SQLite database file.
65    #[arg(long)]
66    pub db: Option<String>,
67}
68
69/// GAP-SG-50: output format for `read`. `Raw` emits the pure body; `Json`
70/// emits the full structured envelope.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default)]
72#[value(rename_all = "lowercase")]
73pub enum ReadFormat {
74    /// JSON variant.
75    #[default]
76    Json,
77    /// Raw variant.
78    Raw,
79}
80
81#[derive(Serialize)]
82struct ReadResponse {
83    /// Canonical storage field. Preserved for compatibility with v2.0.0 clients.
84    id: i64,
85    /// Semantic alias of `id` for the contract documented in SKILL.md.
86    memory_id: i64,
87    namespace: String,
88    name: String,
89    /// Semantic alias of `memory_type` for the documented contract.
90    #[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    /// Most recent memory version, useful for optimistic control via `--expected-updated-at`.
100    version: i64,
101    created_at: i64,
102    /// RFC 3339 UTC timestamp parallel to `created_at` for ISO 8601 parsers.
103    created_at_iso: String,
104    updated_at: i64,
105    /// RFC 3339 UTC timestamp parallel to `updated_at` for ISO 8601 parsers.
106    updated_at_iso: String,
107    /// Linked entities (opt-in via --with-graph).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    entities: Option<Vec<ReadEntityBinding>>,
110    /// Relationships from linked entities (opt-in via --with-graph).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    relationships: Option<Vec<ReadRelationshipBinding>>,
113    /// Total execution time in milliseconds from handler start to serialisation.
114    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
136/// Run.
137pub 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            // G55 S2: surface the requested id structurally so the message
156            // never drops it for the legacy `unknown` literal.
157            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            // GAP-SG-50: `--format raw` emits the pure body and returns early,
177            // before building the JSON envelope. The body is written verbatim so
178            // it can be redirected to a file or piped without parsing.
179            if args.format == ReadFormat::Raw {
180                output::emit_raw(row.body.as_bytes());
181                return Ok(());
182            }
183            // Resolve current version via memory_versions table (highest version for this memory_id).
184            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            // G22: optional graph context
193            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            // G55 S2: when the lookup target is a name, use the structural
274            // `MemoryNotFound { name, namespace }` variant so the message is
275            // guaranteed to carry the requested identifier. The legacy
276            // `NotFound(String)` path is only reached via the `--id` branch
277            // (which now emits `MemoryNotFoundById` structurally a few lines
278            // above) or when a future caller needs ad-hoc messages.
279            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            // Fallback: id lookup that did not match (defensive — the
286            // MemoryNotFoundById branch above already returned in the
287            // normal id-miss path).
288            if let Some(id) = args.id {
289                return Err(AppError::MemoryNotFoundById { id });
290            }
291            // Unreachable: the `else` branch above already validated that
292            // one of name/id is set. Keep a defensive message for future
293            // refactors that may restructure the lookup arms.
294            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    // GAP-SG-50: `read --format raw` must parse to ReadFormat::Raw; default is Json.
308    #[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        // v1.0.68 (test fix): parse the ISO back into a DateTime<FixedOffset>
330        // and compare with chrono::DateTime::UNIX_EPOCH so the assertion is
331        // timezone-agnostic.  The previous `starts_with("1970-01-01T00:00:00")`
332        // assertion leaked the global SQLITE_GRAPHRAG_DISPLAY_TZ from sibling
333        // tests in the same process and failed on hosts where the default
334        // timezone is non-UTC.
335        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        // v1.0.68 (test fix): 1_705_320_000 = 2024-01-15T12:00:00Z, not
348        // 2024-01-15T00:00:00Z (the previous test asserted the wrong instant).
349        // The fix uses parse + timestamp compare to be timezone-agnostic and
350        // to catch wrong-epoch regressions regardless of host TZ.
351        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        // metadata must serialize as a JSON object, not as an escaped string
409        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        // P2-A: metadata must serialize as a JSON object, not as an escaped string.
478        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        // Must be object, not a JSON string containing escaped JSON.
503        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        // P2-A: fallback when metadata is an invalid string.
511        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    // G55 S2 (v1.0.80): the structural `MemoryNotFound` variant must include
518    // the requested name and namespace in the message — never the legacy
519    // `unknown` literal that masked which lookup target failed.
520    #[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        // The pt-BR translation must not contain leftover English fragments
549        // like "not found" — that was the original G55 bug.
550        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}