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 {
22 #[arg(
23 long,
24 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
25 )]
26 pub namespace: Option<String>,
27 #[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 #[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")]
47 pub json: bool,
48 #[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 memory_id: i64,
62 name: String,
63 namespace: String,
64 #[serde(rename = "type")]
66 type_field: String,
67 memory_type: String,
69 description: String,
70 snippet: String,
71 updated_at: i64,
72 updated_at_iso: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
78 deleted_at: Option<i64>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 deleted_at_iso: Option<String>,
82 body_length: usize,
84}
85
86#[derive(Serialize)]
87struct ListResponse {
88 items: Vec<ListItem>,
89 total_count: usize,
91 truncated: bool,
94 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 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 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}