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