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(long, help = "Namespace (flag / XDG namespace.default / global)")]
40    /// Namespace scope.
41    pub namespace: Option<String>,
42    /// Include linked entities and relationships in the response.
43    #[arg(
44        long,
45        help = "Include graph context (entities + relationships) in response"
46    )]
47    pub with_graph: bool,
48    /// Output format: `json` (default, full envelope) or `raw` (the pure memory
49    /// body to stdout, no JSON wrapper). GAP-SG-50: `raw` lets the body be piped
50    /// without a `jaq -r '.body'` round-trip.
51    #[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    /// Emit machine-readable JSON on stdout.
59    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
60    pub json: bool,
61    /// Path to the SQLite database file.
62    #[arg(long)]
63    pub db: Option<String>,
64}
65
66/// GAP-SG-50: output format for `read`. `Raw` emits the pure body; `Json`
67/// emits the full structured envelope.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default)]
69#[value(rename_all = "lowercase")]
70pub enum ReadFormat {
71    /// JSON variant.
72    #[default]
73    Json,
74    /// Raw variant.
75    Raw,
76}
77
78#[derive(Serialize)]
79struct ReadResponse {
80    /// Canonical storage field. Preserved for compatibility with v2.0.0 clients.
81    id: i64,
82    /// Semantic alias of `id` for the contract documented in SKILL.md.
83    memory_id: i64,
84    namespace: String,
85    name: String,
86    /// Semantic alias of `memory_type` for the documented contract.
87    #[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    /// Most recent memory version, useful for optimistic control via `--expected-updated-at`.
97    version: i64,
98    created_at: i64,
99    /// RFC 3339 UTC timestamp parallel to `created_at` for ISO 8601 parsers.
100    created_at_iso: String,
101    updated_at: i64,
102    /// RFC 3339 UTC timestamp parallel to `updated_at` for ISO 8601 parsers.
103    updated_at_iso: String,
104    /// Linked entities (opt-in via --with-graph).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    entities: Option<Vec<ReadEntityBinding>>,
107    /// Relationships from linked entities (opt-in via --with-graph).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    relationships: Option<Vec<ReadRelationshipBinding>>,
110    /// Total execution time in milliseconds from handler start to serialisation.
111    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
133/// Run.
134pub 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            // G55 S2: surface the requested id structurally so the message
153            // never drops it for the legacy `unknown` literal.
154            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            // GAP-SG-50: `--format raw` emits the pure body and returns early,
174            // before building the JSON envelope. The body is written verbatim so
175            // it can be redirected to a file or piped without parsing.
176            if args.format == ReadFormat::Raw {
177                output::emit_raw(row.body.as_bytes());
178                return Ok(());
179            }
180            // Resolve current version via memory_versions table (highest version for this memory_id).
181            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            // G22: optional graph context
190            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            // G55 S2: when the lookup target is a name, use the structural
271            // `MemoryNotFound { name, namespace }` variant so the message is
272            // guaranteed to carry the requested identifier. The legacy
273            // `NotFound(String)` path is only reached via the `--id` branch
274            // (which now emits `MemoryNotFoundById` structurally a few lines
275            // above) or when a future caller needs ad-hoc messages.
276            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            // Fallback: id lookup that did not match (defensive — the
283            // MemoryNotFoundById branch above already returned in the
284            // normal id-miss path).
285            if let Some(id) = args.id {
286                return Err(AppError::MemoryNotFoundById { id });
287            }
288            // Unreachable: the `else` branch above already validated that
289            // one of name/id is set. Keep a defensive message for future
290            // refactors that may restructure the lookup arms.
291            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    // GAP-SG-50: `read --format raw` must parse to ReadFormat::Raw; default is Json.
305    #[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        // v1.0.68 (test fix): parse the ISO back into a DateTime<FixedOffset>
327        // and compare with chrono::DateTime::UNIX_EPOCH so the assertion is
328        // timezone-agnostic.  The previous `starts_with("1970-01-01T00:00:00")`
329        // assertion leaked the global SQLITE_GRAPHRAG_DISPLAY_TZ from sibling
330        // tests in the same process and failed on hosts where the default
331        // timezone is non-UTC.
332        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        // v1.0.68 (test fix): 1_705_320_000 = 2024-01-15T12:00:00Z, not
345        // 2024-01-15T00:00:00Z (the previous test asserted the wrong instant).
346        // The fix uses parse + timestamp compare to be timezone-agnostic and
347        // to catch wrong-epoch regressions regardless of host TZ.
348        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        // metadata must serialize as a JSON object, not as an escaped string
406        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        // P2-A: metadata must serialize as a JSON object, not as an escaped string.
475        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        // Must be object, not a JSON string containing escaped JSON.
500        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        // P2-A: fallback when metadata is an invalid string.
508        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    // G55 S2 (v1.0.80): the structural `MemoryNotFound` variant must include
515    // the requested name and namespace in the message — never the legacy
516    // `unknown` literal that masked which lookup target failed.
517    #[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        // The pt-BR translation must not contain leftover English fragments
546        // like "not found" — that was the original G55 bug.
547        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}