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    #[serde(rename = "type")]
67    memory_type: String,
68    description: String,
69    snippet: String,
70    updated_at: i64,
71    /// RFC 3339 UTC timestamp parallel to `updated_at`.
72    updated_at_iso: String,
73    /// Unix epoch when the memory was soft-deleted, or omitted for active memories.
74    /// Surfaced only in `list --include-deleted --json` so LLM consumers can
75    /// distinguish active rows from soft-deleted ones in a single query (v1.0.37 H7+M9).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    deleted_at: Option<i64>,
78    /// RFC 3339 UTC mirror of `deleted_at`, omitted when `deleted_at` is None.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    deleted_at_iso: Option<String>,
81}
82
83#[derive(Serialize)]
84struct ListResponse {
85    items: Vec<ListItem>,
86    /// Total execution time in milliseconds from handler start to serialisation.
87    elapsed_ms: u64,
88}
89
90pub fn run(args: ListArgs) -> Result<(), AppError> {
91    let inicio = std::time::Instant::now();
92    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
93    let paths = AppPaths::resolve(args.db.as_deref())?;
94    // v1.0.22 P1: standardizes exit code 4 with a friendly message when the DB does not exist.
95    crate::storage::connection::ensure_db_ready(&paths)?;
96    let conn = open_ro(&paths.db)?;
97
98    let memory_type_str = args.r#type.map(|t| t.as_str());
99    let rows = memories::list(
100        &conn,
101        &namespace,
102        memory_type_str,
103        args.limit,
104        args.offset,
105        args.include_deleted,
106    )?;
107
108    let items: Vec<ListItem> = rows
109        .into_iter()
110        .map(|r| {
111            let snippet: String = r.body.chars().take(200).collect();
112            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
113            let deleted_at_iso = r.deleted_at.map(crate::tz::epoch_to_iso);
114            ListItem {
115                id: r.id,
116                memory_id: r.id,
117                name: r.name,
118                namespace: r.namespace,
119                memory_type: r.memory_type,
120                description: r.description,
121                snippet,
122                updated_at: r.updated_at,
123                updated_at_iso,
124                deleted_at: r.deleted_at,
125                deleted_at_iso,
126            }
127        })
128        .collect();
129
130    match args.format {
131        OutputFormat::Json => output::emit_json(&ListResponse {
132            items,
133            elapsed_ms: inicio.elapsed().as_millis() as u64,
134        })?,
135        OutputFormat::Text | OutputFormat::Markdown => {
136            for item in &items {
137                output::emit_text(&format!("{}: {}", item.name, item.snippet));
138            }
139        }
140    }
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn list_response_serializes_items_and_elapsed_ms() {
150        let resp = ListResponse {
151            items: vec![ListItem {
152                id: 1,
153                memory_id: 1,
154                name: "test-memory".to_string(),
155                namespace: "global".to_string(),
156                memory_type: "note".to_string(),
157                description: "descricao de teste".to_string(),
158                snippet: "corpo resumido".to_string(),
159                updated_at: 1_745_000_000,
160                updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
161                deleted_at: None,
162                deleted_at_iso: None,
163            }],
164            elapsed_ms: 7,
165        };
166        let json = serde_json::to_value(&resp).unwrap();
167        assert!(json["items"].is_array());
168        assert_eq!(json["items"].as_array().unwrap().len(), 1);
169        assert_eq!(json["items"][0]["name"], "test-memory");
170        assert_eq!(json["items"][0]["memory_id"], 1);
171        assert_eq!(json["elapsed_ms"], 7);
172        // deleted_at/deleted_at_iso must be omitted when None (skip_serializing_if)
173        assert!(json["items"][0].get("deleted_at").is_none());
174        assert!(json["items"][0].get("deleted_at_iso").is_none());
175    }
176
177    #[test]
178    fn list_item_with_deleted_at_serializes_both_fields() {
179        let item = ListItem {
180            id: 99,
181            memory_id: 99,
182            name: "soft-deleted-memory".to_string(),
183            namespace: "global".to_string(),
184            memory_type: "note".to_string(),
185            description: "deleted".to_string(),
186            snippet: "snip".to_string(),
187            updated_at: 1_745_000_000,
188            updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
189            deleted_at: Some(1_745_100_000),
190            deleted_at_iso: Some("2025-04-20T03:46:40Z".to_string()),
191        };
192        let json = serde_json::to_value(&item).unwrap();
193        assert_eq!(json["deleted_at"], 1_745_100_000_i64);
194        assert_eq!(json["deleted_at_iso"], "2025-04-20T03:46:40Z");
195    }
196
197    #[test]
198    fn list_response_items_empty_serializes_empty_array() {
199        let resp = ListResponse {
200            items: vec![],
201            elapsed_ms: 0,
202        };
203        let json = serde_json::to_value(&resp).unwrap();
204        assert!(json["items"].is_array());
205        assert_eq!(json["items"].as_array().unwrap().len(), 0);
206        assert_eq!(json["elapsed_ms"], 0);
207    }
208
209    #[test]
210    fn list_item_memory_id_equals_id() {
211        let item = ListItem {
212            id: 42,
213            memory_id: 42,
214            name: "memory-alias".to_string(),
215            namespace: "projeto".to_string(),
216            memory_type: "fact".to_string(),
217            description: "desc".to_string(),
218            snippet: "snip".to_string(),
219            updated_at: 0,
220            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
221            deleted_at: None,
222            deleted_at_iso: None,
223        };
224        let json = serde_json::to_value(&item).unwrap();
225        assert_eq!(
226            json["id"], json["memory_id"],
227            "id e memory_id devem ser iguais"
228        );
229    }
230
231    #[test]
232    fn snippet_truncated_to_200_chars() {
233        let body_longo: String = "a".repeat(300);
234        let snippet: String = body_longo.chars().take(200).collect();
235        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
236    }
237
238    #[test]
239    fn updated_at_iso_epoch_zero_yields_valid_utc() {
240        let iso = crate::tz::epoch_to_iso(0);
241        assert!(
242            iso.starts_with("1970-01-01T00:00:00"),
243            "epoch 0 deve mapear para 1970-01-01, obtido: {iso}"
244        );
245        assert!(
246            iso.contains('+') || iso.contains('-'),
247            "must contain offset sign, got: {iso}"
248        );
249    }
250}