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