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)]
58struct ListItem {
59    id: i64,
60    /// Semantic alias of `id` for the contract documented in SKILL.md and AGENT_PROTOCOL.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    /// 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    /// Total execution time in milliseconds from handler start to serialisation.
95    elapsed_ms: u64,
96}
97
98pub fn run(args: ListArgs) -> Result<(), AppError> {
99    if args.limit == Some(0) {
100        return Err(AppError::Validation(
101            "--limit must be greater than zero".to_string(),
102        ));
103    }
104    let inicio = std::time::Instant::now();
105    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
106    let paths = AppPaths::resolve(args.db.as_deref())?;
107    // v1.0.22 P1: standardizes exit code 4 with a friendly message when the DB does not exist.
108    crate::storage::connection::ensure_db_ready(&paths)?;
109    let conn = open_ro(&paths.db)?;
110
111    let effective_limit = args.limit.unwrap_or(match args.format {
112        OutputFormat::Json => usize::MAX,
113        _ => 50,
114    });
115
116    let memory_type_str = args.r#type.map(|t| t.as_str());
117    let rows = memories::list(
118        &conn,
119        &namespace,
120        memory_type_str,
121        effective_limit,
122        args.offset,
123        args.include_deleted,
124    )?;
125
126    let items: Vec<ListItem> = rows
127        .into_iter()
128        .map(|r| {
129            let body_length = r.body.len();
130            let snippet: String = r.body.chars().take(200).collect();
131            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
132            let deleted_at_iso = r.deleted_at.map(crate::tz::epoch_to_iso);
133            ListItem {
134                id: r.id,
135                memory_id: r.id,
136                name: r.name,
137                namespace: r.namespace,
138                type_field: r.memory_type.clone(),
139                memory_type: r.memory_type,
140                description: r.description,
141                snippet,
142                updated_at: r.updated_at,
143                updated_at_iso,
144                deleted_at: r.deleted_at,
145                deleted_at_iso,
146                body_length,
147            }
148        })
149        .collect();
150
151    let total_count = items.len();
152    let truncated = args.limit.is_some_and(|lim| items.len() >= lim);
153
154    match args.format {
155        OutputFormat::Json => output::emit_json(&ListResponse {
156            total_count,
157            truncated,
158            items,
159            elapsed_ms: inicio.elapsed().as_millis() as u64,
160        })?,
161        OutputFormat::Text | OutputFormat::Markdown => {
162            for item in &items {
163                output::emit_text(&format!("{}: {}", item.name, item.snippet));
164            }
165        }
166    }
167    Ok(())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn make_item(name: &str) -> ListItem {
175        ListItem {
176            id: 1,
177            memory_id: 1,
178            name: name.to_string(),
179            namespace: "global".to_string(),
180            type_field: "note".to_string(),
181            memory_type: "note".to_string(),
182            description: "desc".to_string(),
183            snippet: "snip".to_string(),
184            updated_at: 1_745_000_000,
185            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
186            deleted_at: None,
187            deleted_at_iso: None,
188            body_length: 4,
189        }
190    }
191
192    #[test]
193    fn list_response_serializes_items_and_elapsed_ms() {
194        let resp = ListResponse {
195            items: vec![make_item("test-memory")],
196            total_count: 1,
197            truncated: false,
198            elapsed_ms: 7,
199        };
200        let json = serde_json::to_value(&resp).unwrap();
201        assert!(json["items"].is_array());
202        assert_eq!(json["items"].as_array().unwrap().len(), 1);
203        assert_eq!(json["items"][0]["name"], "test-memory");
204        assert_eq!(json["items"][0]["memory_id"], 1);
205        assert_eq!(json["elapsed_ms"], 7);
206        // deleted_at/deleted_at_iso must be omitted when None (skip_serializing_if)
207        assert!(json["items"][0].get("deleted_at").is_none());
208        assert!(json["items"][0].get("deleted_at_iso").is_none());
209    }
210
211    #[test]
212    fn list_item_with_deleted_at_serializes_both_fields() {
213        let item = ListItem {
214            id: 99,
215            memory_id: 99,
216            name: "soft-deleted-memory".to_string(),
217            namespace: "global".to_string(),
218            type_field: "note".to_string(),
219            memory_type: "note".to_string(),
220            description: "deleted".to_string(),
221            snippet: "snip".to_string(),
222            updated_at: 1_745_000_000,
223            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
224            deleted_at: Some(1_745_100_000),
225            deleted_at_iso: Some("2025-04-20T03:46:40Z".to_string()),
226            body_length: 4,
227        };
228        let json = serde_json::to_value(&item).unwrap();
229        assert_eq!(json["deleted_at"], 1_745_100_000_i64);
230        assert_eq!(json["deleted_at_iso"], "2025-04-20T03:46:40Z");
231    }
232
233    #[test]
234    fn list_response_items_empty_serializes_empty_array() {
235        let resp = ListResponse {
236            items: vec![],
237            total_count: 0,
238            truncated: false,
239            elapsed_ms: 0,
240        };
241        let json = serde_json::to_value(&resp).unwrap();
242        assert!(json["items"].is_array());
243        assert_eq!(json["items"].as_array().unwrap().len(), 0);
244        assert_eq!(json["elapsed_ms"], 0);
245    }
246
247    #[test]
248    fn list_item_memory_id_equals_id() {
249        let item = ListItem {
250            id: 42,
251            memory_id: 42,
252            name: "memory-alias".to_string(),
253            namespace: "projeto".to_string(),
254            type_field: "fact".to_string(),
255            memory_type: "fact".to_string(),
256            description: "desc".to_string(),
257            snippet: "snip".to_string(),
258            updated_at: 0,
259            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
260            deleted_at: None,
261            deleted_at_iso: None,
262            body_length: 0,
263        };
264        let json = serde_json::to_value(&item).unwrap();
265        assert_eq!(
266            json["id"], json["memory_id"],
267            "id e memory_id devem ser iguais"
268        );
269    }
270
271    #[test]
272    fn snippet_truncated_to_200_chars() {
273        let body_longo: String = "a".repeat(300);
274        let snippet: String = body_longo.chars().take(200).collect();
275        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
276    }
277
278    #[test]
279    fn list_item_emits_both_type_and_memory_type() {
280        let item = ListItem {
281            id: 1,
282            memory_id: 1,
283            name: "test".to_string(),
284            namespace: "global".to_string(),
285            type_field: "note".to_string(),
286            memory_type: "note".to_string(),
287            description: "desc".to_string(),
288            snippet: "snip".to_string(),
289            updated_at: 0,
290            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
291            deleted_at: None,
292            deleted_at_iso: None,
293            body_length: 0,
294        };
295        let json = serde_json::to_value(&item).unwrap();
296        assert_eq!(json["type"], "note", "serde rename must produce 'type'");
297        assert_eq!(
298            json["memory_type"], "note",
299            "memory_type must also be present"
300        );
301    }
302
303    #[test]
304    fn updated_at_iso_epoch_zero_yields_valid_utc() {
305        let iso = crate::tz::epoch_to_iso(0);
306        assert!(
307            iso.starts_with("1970-01-01T00:00:00"),
308            "epoch 0 deve mapear para 1970-01-01, obtido: {iso}"
309        );
310        assert!(
311            iso.contains('+') || iso.contains('-'),
312            "must contain offset sign, got: {iso}"
313        );
314    }
315
316    #[test]
317    fn body_length_reflects_byte_count() {
318        let body = "hello world";
319        let item = ListItem {
320            id: 1,
321            memory_id: 1,
322            name: "test".to_string(),
323            namespace: "global".to_string(),
324            type_field: "note".to_string(),
325            memory_type: "note".to_string(),
326            description: "desc".to_string(),
327            snippet: body.chars().take(200).collect(),
328            updated_at: 0,
329            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
330            deleted_at: None,
331            deleted_at_iso: None,
332            body_length: body.len(),
333        };
334        let json = serde_json::to_value(&item).unwrap();
335        assert_eq!(json["body_length"], body.len());
336    }
337}