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