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