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