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(long, help = "Namespace (flag / XDG namespace.default / global)")]
24 pub namespace: Option<String>,
26 #[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 )]
35 pub limit: Option<usize>,
37 #[arg(long, default_value = "0", help = "Number of memories to skip")]
39 pub offset: usize,
40 #[arg(long, value_enum, default_value = "json", help = "Output format")]
42 pub format: OutputFormat,
43 #[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")]
48 pub json: bool,
49 #[arg(long, help = "Path to graphrag.sqlite")]
51 pub db: Option<String>,
52 #[arg(
55 long,
56 default_value_t = false,
57 help = "Print JSON Schema for list output and exit"
58 )]
59 pub print_schema: bool,
60}
61
62#[derive(Serialize, Clone)]
63struct ListItem {
64 id: i64,
65 memory_id: i64,
67 name: String,
68 namespace: String,
69 #[serde(rename = "type")]
71 type_field: String,
72 memory_type: String,
74 description: String,
75 snippet: String,
76 updated_at: i64,
77 updated_at_iso: String,
79 #[serde(skip_serializing_if = "Option::is_none")]
83 deleted_at: Option<i64>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 deleted_at_iso: Option<String>,
87 body_length: usize,
89}
90
91#[derive(Serialize)]
92struct ListResponse {
93 items: Vec<ListItem>,
94 memories: Vec<ListItem>,
95 total_count: usize,
97 truncated: bool,
100 #[serde(skip_serializing_if = "Option::is_none")]
104 truncation_warning: Option<String>,
105 elapsed_ms: u64,
107}
108
109pub fn run(args: ListArgs) -> Result<(), AppError> {
111 if args.print_schema {
112 return crate::print_schema::emit(crate::print_schema::SchemaId::List);
113 }
114 if args.limit == Some(0) {
115 return Err(AppError::Validation(
116 "--limit must be greater than zero".to_string(),
117 ));
118 }
119 let inicio = std::time::Instant::now();
120 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
121 let paths = AppPaths::resolve(args.db.as_deref())?;
122 crate::storage::connection::ensure_db_ready(&paths)?;
124 let conn = open_ro(&paths.db)?;
125
126 let effective_limit = args.limit.unwrap_or(match args.format {
127 OutputFormat::Json => usize::MAX,
128 _ => 50,
129 });
130
131 let memory_type_str = args.r#type.map(|t| t.as_str());
132 let rows = memories::list(
133 &conn,
134 &namespace,
135 memory_type_str,
136 effective_limit,
137 args.offset,
138 args.include_deleted,
139 )?;
140
141 let items: Vec<ListItem> = rows
142 .into_iter()
143 .map(|r| {
144 let body_length = r.body.len();
145 let snippet: String = r.body.chars().take(200).collect();
146 let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
147 let deleted_at_iso = r.deleted_at.map(crate::tz::epoch_to_iso);
148 ListItem {
149 id: r.id,
150 memory_id: r.id,
151 name: r.name,
152 namespace: r.namespace,
153 type_field: r.memory_type.clone(),
154 memory_type: r.memory_type,
155 description: r.description,
156 snippet,
157 updated_at: r.updated_at,
158 updated_at_iso,
159 deleted_at: r.deleted_at,
160 deleted_at_iso,
161 body_length,
162 }
163 })
164 .collect();
165
166 let total_count = memories::count(&conn, &namespace, memory_type_str, args.include_deleted)?;
167 let truncated = items.len() < total_count;
168
169 let truncation_warning = if truncated {
172 let returned = items.len();
173 Some(format!(
174 "list returned {returned} of {total_count} memories in namespace '{namespace}'; \
175 list paginates and undercounts — use `export --namespace {namespace} --json` for the authoritative inventory"
176 ))
177 } else {
178 None
179 };
180
181 match args.format {
182 OutputFormat::Json => {
183 let memories = items.clone();
184 output::emit_json(&ListResponse {
185 total_count,
186 truncated,
187 truncation_warning,
188 memories,
189 items,
190 elapsed_ms: inicio.elapsed().as_millis() as u64,
191 })?;
192 }
193 OutputFormat::Text | OutputFormat::Markdown => {
194 for item in &items {
195 output::emit_text(&format!("{}: {}", item.name, item.snippet));
196 }
197 if let Some(ref w) = truncation_warning {
198 output::emit_text(w);
199 }
200 }
201 }
202 Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn make_item(name: &str) -> ListItem {
210 ListItem {
211 id: 1,
212 memory_id: 1,
213 name: name.to_string(),
214 namespace: "global".to_string(),
215 type_field: "note".to_string(),
216 memory_type: "note".to_string(),
217 description: "desc".to_string(),
218 snippet: "snip".to_string(),
219 updated_at: 1_745_000_000,
220 updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
221 deleted_at: None,
222 deleted_at_iso: None,
223 body_length: 4,
224 }
225 }
226
227 #[test]
228 fn list_response_serializes_items_and_elapsed_ms() {
229 let resp = ListResponse {
230 items: vec![make_item("test-memory")],
231 memories: vec![make_item("test-memory")],
232 total_count: 1,
233 truncated: false,
234 truncation_warning: None,
235 elapsed_ms: 7,
236 };
237 let json = serde_json::to_value(&resp).unwrap();
238 assert!(json["items"].is_array());
239 assert_eq!(json["items"].as_array().unwrap().len(), 1);
240 assert_eq!(json["items"][0]["name"], "test-memory");
241 assert_eq!(json["items"][0]["memory_id"], 1);
242 assert_eq!(json["elapsed_ms"], 7);
243 assert!(json["items"][0].get("deleted_at").is_none());
245 assert!(json["items"][0].get("deleted_at_iso").is_none());
246 }
247
248 #[test]
249 fn list_item_with_deleted_at_serializes_both_fields() {
250 let item = ListItem {
251 id: 99,
252 memory_id: 99,
253 name: "soft-deleted-memory".to_string(),
254 namespace: "global".to_string(),
255 type_field: "note".to_string(),
256 memory_type: "note".to_string(),
257 description: "deleted".to_string(),
258 snippet: "snip".to_string(),
259 updated_at: 1_745_000_000,
260 updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
261 deleted_at: Some(1_745_100_000),
262 deleted_at_iso: Some("2025-04-20T03:46:40Z".to_string()),
263 body_length: 4,
264 };
265 let json = serde_json::to_value(&item).unwrap();
266 assert_eq!(json["deleted_at"], 1_745_100_000_i64);
267 assert_eq!(json["deleted_at_iso"], "2025-04-20T03:46:40Z");
268 }
269
270 #[test]
272 fn list_response_truncation_warning_present_when_truncated() {
273 let resp = ListResponse {
274 items: vec![make_item("a")],
275 memories: vec![make_item("a")],
276 total_count: 50,
277 truncated: true,
278 truncation_warning: Some("list returned 1 of 50 memories; use export".to_string()),
279 elapsed_ms: 1,
280 };
281 let json = serde_json::to_value(&resp).unwrap();
282 assert!(json["truncated"].as_bool().unwrap());
283 assert!(json["truncation_warning"]
284 .as_str()
285 .unwrap()
286 .contains("export"));
287 }
288
289 #[test]
290 fn list_response_truncation_warning_omitted_when_not_truncated() {
291 let resp = ListResponse {
292 items: vec![make_item("a")],
293 memories: vec![make_item("a")],
294 total_count: 1,
295 truncated: false,
296 truncation_warning: None,
297 elapsed_ms: 1,
298 };
299 let json = serde_json::to_value(&resp).unwrap();
300 assert!(
301 json.get("truncation_warning").is_none(),
302 "must be omitted when None"
303 );
304 }
305
306 #[test]
307 fn list_response_items_empty_serializes_empty_array() {
308 let resp = ListResponse {
309 items: vec![],
310 memories: vec![],
311 total_count: 0,
312 truncated: false,
313 truncation_warning: None,
314 elapsed_ms: 0,
315 };
316 let json = serde_json::to_value(&resp).unwrap();
317 assert!(json["items"].is_array());
318 assert_eq!(json["items"].as_array().unwrap().len(), 0);
319 assert_eq!(json["elapsed_ms"], 0);
320 }
321
322 #[test]
323 fn list_item_memory_id_equals_id() {
324 let item = ListItem {
325 id: 42,
326 memory_id: 42,
327 name: "memory-alias".to_string(),
328 namespace: "projeto".to_string(),
329 type_field: "fact".to_string(),
330 memory_type: "fact".to_string(),
331 description: "desc".to_string(),
332 snippet: "snip".to_string(),
333 updated_at: 0,
334 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
335 deleted_at: None,
336 deleted_at_iso: None,
337 body_length: 0,
338 };
339 let json = serde_json::to_value(&item).unwrap();
340 assert_eq!(
341 json["id"], json["memory_id"],
342 "id e memory_id devem ser iguais"
343 );
344 }
345
346 #[test]
347 fn snippet_truncated_to_200_chars() {
348 let body_longo: String = "a".repeat(300);
349 let snippet: String = body_longo.chars().take(200).collect();
350 assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
351 }
352
353 #[test]
354 fn list_item_emits_both_type_and_memory_type() {
355 let item = ListItem {
356 id: 1,
357 memory_id: 1,
358 name: "test".to_string(),
359 namespace: "global".to_string(),
360 type_field: "note".to_string(),
361 memory_type: "note".to_string(),
362 description: "desc".to_string(),
363 snippet: "snip".to_string(),
364 updated_at: 0,
365 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
366 deleted_at: None,
367 deleted_at_iso: None,
368 body_length: 0,
369 };
370 let json = serde_json::to_value(&item).unwrap();
371 assert_eq!(json["type"], "note", "serde rename must produce 'type'");
372 assert_eq!(
373 json["memory_type"], "note",
374 "memory_type must also be present"
375 );
376 }
377
378 #[test]
379 fn updated_at_iso_epoch_zero_yields_valid_utc() {
380 let iso = crate::tz::epoch_to_iso(0);
383 let parsed = chrono::DateTime::parse_from_rfc3339(&iso)
384 .unwrap_or_else(|e| panic!("expected RFC3339, got `{iso}`: {e}"));
385 assert_eq!(
386 parsed.timestamp(),
387 chrono::DateTime::UNIX_EPOCH.timestamp(),
388 "epoch 0 deve mapear para o instante Unix epoch, obtido: {iso}"
389 );
390 assert!(
391 iso.contains('+') || iso.contains('-'),
392 "must contain offset sign, got: {iso}"
393 );
394 }
395
396 #[test]
397 fn body_length_reflects_byte_count() {
398 let body = "hello world";
399 let item = ListItem {
400 id: 1,
401 memory_id: 1,
402 name: "test".to_string(),
403 namespace: "global".to_string(),
404 type_field: "note".to_string(),
405 memory_type: "note".to_string(),
406 description: "desc".to_string(),
407 snippet: body.chars().take(200).collect(),
408 updated_at: 0,
409 updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
410 deleted_at: None,
411 deleted_at_iso: None,
412 body_length: body.len(),
413 };
414 let json = serde_json::to_value(&item).unwrap();
415 assert_eq!(json["body_length"], body.len());
416 }
417}