Skip to main content

sqlite_graphrag/commands/
list.rs

1//! Handler for the `list` CLI subcommand.
2
3use crate::cli::MemoryType;
4use crate::errors::AppError;
5use crate::output::{self, OutputFormat};
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use crate::storage::memories;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # List up to 50 memories from the global namespace (default)\n  \
14    sqlite-graphrag list\n\n  \
15    # Filter by memory type and namespace\n  \
16    sqlite-graphrag list --type project --namespace my-project\n\n  \
17    # Paginate with limit and offset\n  \
18    sqlite-graphrag list --limit 20 --offset 40\n\n  \
19    # Include soft-deleted memories\n  \
20    sqlite-graphrag list --include-deleted")]
21/// List args.
22pub struct ListArgs {
23    #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
24    /// Namespace scope.
25    pub namespace: Option<String>,
26    /// Filter by memory.type. Note: distinct from graph entity_type
27    /// (project/tool/person/file/concept/incident/decision/memory/dashboard/issue_tracker/organization/location/date)
28    /// used in --entities-file.
29    #[arg(long, value_enum)]
30    pub r#type: Option<MemoryType>,
31    #[arg(
32        long,
33        help = "Maximum number of memories to return (default: 50 for text, all for JSON)"
34    )]
35    /// Maximum number of items.
36    pub limit: Option<usize>,
37    /// Number of memories to skip before returning results.
38    #[arg(long, default_value = "0", help = "Number of memories to skip")]
39    pub offset: usize,
40    /// Output format: json (default), text, or markdown.
41    #[arg(long, value_enum, default_value = "json", help = "Output format")]
42    pub format: OutputFormat,
43    /// Include soft-deleted memories in the listing (deleted_at IS NOT NULL).
44    #[arg(long, default_value_t = false, help = "Include soft-deleted memories")]
45    pub include_deleted: bool,
46    /// Emit machine-readable JSON on stdout.
47    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
48    pub json: bool,
49    /// Path to graphrag.sqlite. Overrides the XDG `db.path` setting.
50    #[arg(long, help = "Path to graphrag.sqlite")]
51    pub db: Option<String>,
52    /// Emit the JSON Schema for this command's stdout envelope and exit 0
53    /// without opening the database (agent-native R-AN-01).
54    #[arg(
55        long,
56        default_value_t = false,
57        help = "Print JSON Schema for list output and exit"
58    )]
59    pub print_schema: bool,
60}
61
62#[derive(Serialize, Clone)]
63struct ListItem {
64    id: i64,
65    /// Semantic alias of `id` for the contract documented in SKILL.md.
66    memory_id: i64,
67    name: String,
68    namespace: String,
69    /// Semantic alias for agents that parse `.type` in the JSON output.
70    #[serde(rename = "type")]
71    type_field: String,
72    /// Semantic alias for agents that parse `.memory_type` in the JSON output.
73    memory_type: String,
74    description: String,
75    snippet: String,
76    updated_at: i64,
77    /// RFC 3339 UTC timestamp parallel to `updated_at`.
78    updated_at_iso: String,
79    /// Unix epoch when the memory was soft-deleted, or omitted for active memories.
80    /// Surfaced only in `list --include-deleted --json` so LLM consumers can
81    /// distinguish active rows from soft-deleted ones in a single query (v1.0.37 H7+M9).
82    #[serde(skip_serializing_if = "Option::is_none")]
83    deleted_at: Option<i64>,
84    /// RFC 3339 UTC mirror of `deleted_at`, omitted when `deleted_at` is None.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    deleted_at_iso: Option<String>,
87    /// Byte length of the full memory body.
88    body_length: usize,
89}
90
91#[derive(Serialize)]
92struct ListResponse {
93    items: Vec<ListItem>,
94    memories: Vec<ListItem>,
95    /// Total number of matching memories in the namespace (ignoring limit/offset).
96    total_count: usize,
97    /// True when the returned item count is less than `total_count`, indicating
98    /// that more results exist beyond the applied limit.
99    truncated: bool,
100    /// GAP-SG-53: actionable hint emitted only when `truncated` is true, warning
101    /// that `list` paginates and that `export --namespace <ns> --json` is the
102    /// authoritative inventory for dedup/counting decisions.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    truncation_warning: Option<String>,
105    /// Total execution time in milliseconds from handler start to serialisation.
106    elapsed_ms: u64,
107}
108
109/// Run.
110pub fn run(args: ListArgs) -> Result<(), AppError> {
111    if args.print_schema {
112        return crate::print_schema::emit(crate::print_schema::SchemaId::List);
113    }
114    if args.limit == Some(0) {
115        return Err(AppError::Validation(
116            "--limit must be greater than zero".to_string(),
117        ));
118    }
119    let inicio = std::time::Instant::now();
120    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
121    let paths = AppPaths::resolve(args.db.as_deref())?;
122    // v1.0.22 P1: standardizes exit code 4 with a friendly message when the DB does not exist.
123    crate::storage::connection::ensure_db_ready(&paths)?;
124    let conn = open_ro(&paths.db)?;
125
126    let effective_limit = args.limit.unwrap_or(match args.format {
127        OutputFormat::Json => usize::MAX,
128        _ => 50,
129    });
130
131    let memory_type_str = args.r#type.map(|t| t.as_str());
132    let rows = memories::list(
133        &conn,
134        &namespace,
135        memory_type_str,
136        effective_limit,
137        args.offset,
138        args.include_deleted,
139    )?;
140
141    let items: Vec<ListItem> = rows
142        .into_iter()
143        .map(|r| {
144            let body_length = r.body.len();
145            let snippet: String = r.body.chars().take(200).collect();
146            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
147            let deleted_at_iso = r.deleted_at.map(crate::tz::epoch_to_iso);
148            ListItem {
149                id: r.id,
150                memory_id: r.id,
151                name: r.name,
152                namespace: r.namespace,
153                type_field: r.memory_type.clone(),
154                memory_type: r.memory_type,
155                description: r.description,
156                snippet,
157                updated_at: r.updated_at,
158                updated_at_iso,
159                deleted_at: r.deleted_at,
160                deleted_at_iso,
161                body_length,
162            }
163        })
164        .collect();
165
166    let total_count = memories::count(&conn, &namespace, memory_type_str, args.include_deleted)?;
167    let truncated = items.len() < total_count;
168
169    // GAP-SG-53: when pagination hides rows, tell the operator that `list` is
170    // not a reliable inventory and point them at `export` (full NDJSON).
171    let truncation_warning = if truncated {
172        let returned = items.len();
173        Some(format!(
174            "list returned {returned} of {total_count} memories in namespace '{namespace}'; \
175             list paginates and undercounts — use `export --namespace {namespace} --json` for the authoritative inventory"
176        ))
177    } else {
178        None
179    };
180
181    match args.format {
182        OutputFormat::Json => {
183            let memories = items.clone();
184            output::emit_json(&ListResponse {
185                total_count,
186                truncated,
187                truncation_warning,
188                memories,
189                items,
190                elapsed_ms: inicio.elapsed().as_millis() as u64,
191            })?;
192        }
193        OutputFormat::Text | OutputFormat::Markdown => {
194            for item in &items {
195                output::emit_text(&format!("{}: {}", item.name, item.snippet));
196            }
197            if let Some(ref w) = truncation_warning {
198                output::emit_text(w);
199            }
200        }
201    }
202    Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn make_item(name: &str) -> ListItem {
210        ListItem {
211            id: 1,
212            memory_id: 1,
213            name: name.to_string(),
214            namespace: "global".to_string(),
215            type_field: "note".to_string(),
216            memory_type: "note".to_string(),
217            description: "desc".to_string(),
218            snippet: "snip".to_string(),
219            updated_at: 1_745_000_000,
220            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
221            deleted_at: None,
222            deleted_at_iso: None,
223            body_length: 4,
224        }
225    }
226
227    #[test]
228    fn list_response_serializes_items_and_elapsed_ms() {
229        let resp = ListResponse {
230            items: vec![make_item("test-memory")],
231            memories: vec![make_item("test-memory")],
232            total_count: 1,
233            truncated: false,
234            truncation_warning: None,
235            elapsed_ms: 7,
236        };
237        let json = serde_json::to_value(&resp).unwrap();
238        assert!(json["items"].is_array());
239        assert_eq!(json["items"].as_array().unwrap().len(), 1);
240        assert_eq!(json["items"][0]["name"], "test-memory");
241        assert_eq!(json["items"][0]["memory_id"], 1);
242        assert_eq!(json["elapsed_ms"], 7);
243        // deleted_at/deleted_at_iso must be omitted when None (skip_serializing_if)
244        assert!(json["items"][0].get("deleted_at").is_none());
245        assert!(json["items"][0].get("deleted_at_iso").is_none());
246    }
247
248    #[test]
249    fn list_item_with_deleted_at_serializes_both_fields() {
250        let item = ListItem {
251            id: 99,
252            memory_id: 99,
253            name: "soft-deleted-memory".to_string(),
254            namespace: "global".to_string(),
255            type_field: "note".to_string(),
256            memory_type: "note".to_string(),
257            description: "deleted".to_string(),
258            snippet: "snip".to_string(),
259            updated_at: 1_745_000_000,
260            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
261            deleted_at: Some(1_745_100_000),
262            deleted_at_iso: Some("2025-04-20T03:46:40Z".to_string()),
263            body_length: 4,
264        };
265        let json = serde_json::to_value(&item).unwrap();
266        assert_eq!(json["deleted_at"], 1_745_100_000_i64);
267        assert_eq!(json["deleted_at_iso"], "2025-04-20T03:46:40Z");
268    }
269
270    // GAP-SG-53: truncation_warning present when truncated, omitted otherwise.
271    #[test]
272    fn list_response_truncation_warning_present_when_truncated() {
273        let resp = ListResponse {
274            items: vec![make_item("a")],
275            memories: vec![make_item("a")],
276            total_count: 50,
277            truncated: true,
278            truncation_warning: Some("list returned 1 of 50 memories; use export".to_string()),
279            elapsed_ms: 1,
280        };
281        let json = serde_json::to_value(&resp).unwrap();
282        assert!(json["truncated"].as_bool().unwrap());
283        assert!(json["truncation_warning"]
284            .as_str()
285            .unwrap()
286            .contains("export"));
287    }
288
289    #[test]
290    fn list_response_truncation_warning_omitted_when_not_truncated() {
291        let resp = ListResponse {
292            items: vec![make_item("a")],
293            memories: vec![make_item("a")],
294            total_count: 1,
295            truncated: false,
296            truncation_warning: None,
297            elapsed_ms: 1,
298        };
299        let json = serde_json::to_value(&resp).unwrap();
300        assert!(
301            json.get("truncation_warning").is_none(),
302            "must be omitted when None"
303        );
304    }
305
306    #[test]
307    fn list_response_items_empty_serializes_empty_array() {
308        let resp = ListResponse {
309            items: vec![],
310            memories: vec![],
311            total_count: 0,
312            truncated: false,
313            truncation_warning: None,
314            elapsed_ms: 0,
315        };
316        let json = serde_json::to_value(&resp).unwrap();
317        assert!(json["items"].is_array());
318        assert_eq!(json["items"].as_array().unwrap().len(), 0);
319        assert_eq!(json["elapsed_ms"], 0);
320    }
321
322    #[test]
323    fn list_item_memory_id_equals_id() {
324        let item = ListItem {
325            id: 42,
326            memory_id: 42,
327            name: "memory-alias".to_string(),
328            namespace: "projeto".to_string(),
329            type_field: "fact".to_string(),
330            memory_type: "fact".to_string(),
331            description: "desc".to_string(),
332            snippet: "snip".to_string(),
333            updated_at: 0,
334            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
335            deleted_at: None,
336            deleted_at_iso: None,
337            body_length: 0,
338        };
339        let json = serde_json::to_value(&item).unwrap();
340        assert_eq!(
341            json["id"], json["memory_id"],
342            "id e memory_id devem ser iguais"
343        );
344    }
345
346    #[test]
347    fn snippet_truncated_to_200_chars() {
348        let body_longo: String = "a".repeat(300);
349        let snippet: String = body_longo.chars().take(200).collect();
350        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
351    }
352
353    #[test]
354    fn list_item_emits_both_type_and_memory_type() {
355        let item = ListItem {
356            id: 1,
357            memory_id: 1,
358            name: "test".to_string(),
359            namespace: "global".to_string(),
360            type_field: "note".to_string(),
361            memory_type: "note".to_string(),
362            description: "desc".to_string(),
363            snippet: "snip".to_string(),
364            updated_at: 0,
365            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
366            deleted_at: None,
367            deleted_at_iso: None,
368            body_length: 0,
369        };
370        let json = serde_json::to_value(&item).unwrap();
371        assert_eq!(json["type"], "note", "serde rename must produce 'type'");
372        assert_eq!(
373            json["memory_type"], "note",
374            "memory_type must also be present"
375        );
376    }
377
378    #[test]
379    fn updated_at_iso_epoch_zero_yields_valid_utc() {
380        // v1.0.68 (test fix): timezone-agnostic — parse the ISO and compare
381        // the instant with the Unix epoch.
382        let iso = crate::tz::epoch_to_iso(0);
383        let parsed = chrono::DateTime::parse_from_rfc3339(&iso)
384            .unwrap_or_else(|e| panic!("expected RFC3339, got `{iso}`: {e}"));
385        assert_eq!(
386            parsed.timestamp(),
387            chrono::DateTime::UNIX_EPOCH.timestamp(),
388            "epoch 0 deve mapear para o instante Unix epoch, obtido: {iso}"
389        );
390        assert!(
391            iso.contains('+') || iso.contains('-'),
392            "must contain offset sign, got: {iso}"
393        );
394    }
395
396    #[test]
397    fn body_length_reflects_byte_count() {
398        let body = "hello world";
399        let item = ListItem {
400            id: 1,
401            memory_id: 1,
402            name: "test".to_string(),
403            namespace: "global".to_string(),
404            type_field: "note".to_string(),
405            memory_type: "note".to_string(),
406            description: "desc".to_string(),
407            snippet: body.chars().take(200).collect(),
408            updated_at: 0,
409            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
410            deleted_at: None,
411            deleted_at_iso: None,
412            body_length: body.len(),
413        };
414        let json = serde_json::to_value(&item).unwrap();
415        assert_eq!(json["body_length"], body.len());
416    }
417}