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