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