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    /// Maximum number of memories to return (default: 50).
33    #[arg(
34        long,
35        default_value = "50",
36        help = "Maximum number of memories to return"
37    )]
38    pub limit: usize,
39    /// Number of memories to skip before returning results.
40    #[arg(long, default_value = "0", help = "Number of memories to skip")]
41    pub offset: usize,
42    /// Output format: json (default), text, or markdown.
43    #[arg(long, value_enum, default_value = "json", help = "Output format")]
44    pub format: OutputFormat,
45    /// Include soft-deleted memories in the listing (deleted_at IS NOT NULL).
46    #[arg(long, default_value_t = false, help = "Include soft-deleted memories")]
47    pub include_deleted: bool,
48    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
49    pub json: bool,
50    /// Path to graphrag.sqlite (overrides SQLITE_GRAPHRAG_DB_PATH and default CWD).
51    #[arg(
52        long,
53        env = "SQLITE_GRAPHRAG_DB_PATH",
54        help = "Path to graphrag.sqlite"
55    )]
56    pub db: Option<String>,
57}
58
59#[derive(Serialize)]
60struct ListItem {
61    id: i64,
62    /// Semantic alias of `id` for the contract documented in SKILL.md and AGENT_PROTOCOL.md.
63    memory_id: i64,
64    name: String,
65    namespace: String,
66    /// Semantic alias for agents that parse `.type` in the JSON output.
67    #[serde(rename = "type")]
68    type_field: String,
69    /// Semantic alias for agents that parse `.memory_type` in the JSON output.
70    memory_type: String,
71    description: String,
72    snippet: String,
73    updated_at: i64,
74    /// RFC 3339 UTC timestamp parallel to `updated_at`.
75    updated_at_iso: String,
76    /// Unix epoch when the memory was soft-deleted, or omitted for active memories.
77    /// Surfaced only in `list --include-deleted --json` so LLM consumers can
78    /// distinguish active rows from soft-deleted ones in a single query (v1.0.37 H7+M9).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    deleted_at: Option<i64>,
81    /// RFC 3339 UTC mirror of `deleted_at`, omitted when `deleted_at` is None.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    deleted_at_iso: Option<String>,
84}
85
86#[derive(Serialize)]
87struct ListResponse {
88    items: Vec<ListItem>,
89    /// Total execution time in milliseconds from handler start to serialisation.
90    elapsed_ms: u64,
91}
92
93pub fn run(args: ListArgs) -> Result<(), AppError> {
94    let inicio = std::time::Instant::now();
95    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
96    let paths = AppPaths::resolve(args.db.as_deref())?;
97    // v1.0.22 P1: standardizes exit code 4 with a friendly message when the DB does not exist.
98    crate::storage::connection::ensure_db_ready(&paths)?;
99    let conn = open_ro(&paths.db)?;
100
101    let memory_type_str = args.r#type.map(|t| t.as_str());
102    let rows = memories::list(
103        &conn,
104        &namespace,
105        memory_type_str,
106        args.limit,
107        args.offset,
108        args.include_deleted,
109    )?;
110
111    let items: Vec<ListItem> = rows
112        .into_iter()
113        .map(|r| {
114            let snippet: String = r.body.chars().take(200).collect();
115            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
116            let deleted_at_iso = r.deleted_at.map(crate::tz::epoch_to_iso);
117            ListItem {
118                id: r.id,
119                memory_id: r.id,
120                name: r.name,
121                namespace: r.namespace,
122                type_field: r.memory_type.clone(),
123                memory_type: r.memory_type,
124                description: r.description,
125                snippet,
126                updated_at: r.updated_at,
127                updated_at_iso,
128                deleted_at: r.deleted_at,
129                deleted_at_iso,
130            }
131        })
132        .collect();
133
134    match args.format {
135        OutputFormat::Json => output::emit_json(&ListResponse {
136            items,
137            elapsed_ms: inicio.elapsed().as_millis() as u64,
138        })?,
139        OutputFormat::Text | OutputFormat::Markdown => {
140            for item in &items {
141                output::emit_text(&format!("{}: {}", item.name, item.snippet));
142            }
143        }
144    }
145    Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn list_response_serializes_items_and_elapsed_ms() {
154        let resp = ListResponse {
155            items: vec![ListItem {
156                id: 1,
157                memory_id: 1,
158                name: "test-memory".to_string(),
159                namespace: "global".to_string(),
160                type_field: "note".to_string(),
161                memory_type: "note".to_string(),
162                description: "descricao de teste".to_string(),
163                snippet: "corpo resumido".to_string(),
164                updated_at: 1_745_000_000,
165                updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
166                deleted_at: None,
167                deleted_at_iso: None,
168            }],
169            elapsed_ms: 7,
170        };
171        let json = serde_json::to_value(&resp).unwrap();
172        assert!(json["items"].is_array());
173        assert_eq!(json["items"].as_array().unwrap().len(), 1);
174        assert_eq!(json["items"][0]["name"], "test-memory");
175        assert_eq!(json["items"][0]["memory_id"], 1);
176        assert_eq!(json["elapsed_ms"], 7);
177        // deleted_at/deleted_at_iso must be omitted when None (skip_serializing_if)
178        assert!(json["items"][0].get("deleted_at").is_none());
179        assert!(json["items"][0].get("deleted_at_iso").is_none());
180    }
181
182    #[test]
183    fn list_item_with_deleted_at_serializes_both_fields() {
184        let item = ListItem {
185            id: 99,
186            memory_id: 99,
187            name: "soft-deleted-memory".to_string(),
188            namespace: "global".to_string(),
189            type_field: "note".to_string(),
190            memory_type: "note".to_string(),
191            description: "deleted".to_string(),
192            snippet: "snip".to_string(),
193            updated_at: 1_745_000_000,
194            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
195            deleted_at: Some(1_745_100_000),
196            deleted_at_iso: Some("2025-04-20T03:46:40Z".to_string()),
197        };
198        let json = serde_json::to_value(&item).unwrap();
199        assert_eq!(json["deleted_at"], 1_745_100_000_i64);
200        assert_eq!(json["deleted_at_iso"], "2025-04-20T03:46:40Z");
201    }
202
203    #[test]
204    fn list_response_items_empty_serializes_empty_array() {
205        let resp = ListResponse {
206            items: vec![],
207            elapsed_ms: 0,
208        };
209        let json = serde_json::to_value(&resp).unwrap();
210        assert!(json["items"].is_array());
211        assert_eq!(json["items"].as_array().unwrap().len(), 0);
212        assert_eq!(json["elapsed_ms"], 0);
213    }
214
215    #[test]
216    fn list_item_memory_id_equals_id() {
217        let item = ListItem {
218            id: 42,
219            memory_id: 42,
220            name: "memory-alias".to_string(),
221            namespace: "projeto".to_string(),
222            type_field: "fact".to_string(),
223            memory_type: "fact".to_string(),
224            description: "desc".to_string(),
225            snippet: "snip".to_string(),
226            updated_at: 0,
227            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
228            deleted_at: None,
229            deleted_at_iso: None,
230        };
231        let json = serde_json::to_value(&item).unwrap();
232        assert_eq!(
233            json["id"], json["memory_id"],
234            "id e memory_id devem ser iguais"
235        );
236    }
237
238    #[test]
239    fn snippet_truncated_to_200_chars() {
240        let body_longo: String = "a".repeat(300);
241        let snippet: String = body_longo.chars().take(200).collect();
242        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
243    }
244
245    #[test]
246    fn list_item_emits_both_type_and_memory_type() {
247        let item = ListItem {
248            id: 1,
249            memory_id: 1,
250            name: "test".to_string(),
251            namespace: "global".to_string(),
252            type_field: "note".to_string(),
253            memory_type: "note".to_string(),
254            description: "desc".to_string(),
255            snippet: "snip".to_string(),
256            updated_at: 0,
257            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
258            deleted_at: None,
259            deleted_at_iso: None,
260        };
261        let json = serde_json::to_value(&item).unwrap();
262        assert_eq!(json["type"], "note", "serde rename must produce 'type'");
263        assert_eq!(
264            json["memory_type"], "note",
265            "memory_type must also be present"
266        );
267    }
268
269    #[test]
270    fn updated_at_iso_epoch_zero_yields_valid_utc() {
271        let iso = crate::tz::epoch_to_iso(0);
272        assert!(
273            iso.starts_with("1970-01-01T00:00:00"),
274            "epoch 0 deve mapear para 1970-01-01, obtido: {iso}"
275        );
276        assert!(
277            iso.contains('+') || iso.contains('-'),
278            "must contain offset sign, got: {iso}"
279        );
280    }
281}