1use 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 {
23 #[arg(
24 long,
25 help = "Namespace (flag / XDG namespace.default / global)"
26 )]
27 pub namespace: Option<String>,
29 #[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 pub limit: Option<usize>,
40 #[arg(long, default_value = "0", help = "Number of memories to skip")]
42 pub offset: usize,
43 #[arg(long, value_enum, default_value = "json", help = "Output format")]
45 pub format: OutputFormat,
46 #[arg(long, default_value_t = false, help = "Include soft-deleted memories")]
48 pub include_deleted: bool,
49 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
51 pub json: bool,
52 #[arg(
54 long,
55 help = "Path to graphrag.sqlite"
56 )]
57 pub db: Option<String>,
58 #[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 memory_id: i64,
69 name: String,
70 namespace: String,
71 #[serde(rename = "type")]
73 type_field: String,
74 memory_type: String,
76 description: String,
77 snippet: String,
78 updated_at: i64,
79 updated_at_iso: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
85 deleted_at: Option<i64>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 deleted_at_iso: Option<String>,
89 body_length: usize,
91}
92
93#[derive(Serialize)]
94struct ListResponse {
95 items: Vec<ListItem>,
96 memories: Vec<ListItem>,
97 total_count: usize,
99 truncated: bool,
102 #[serde(skip_serializing_if = "Option::is_none")]
106 truncation_warning: Option<String>,
107 elapsed_ms: u64,
109}
110
111pub 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 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 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 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 #[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 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}