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}
76
77#[derive(Serialize)]
78struct ListResponse {
79    items: Vec<ListItem>,
80    /// Total execution time in milliseconds from handler start to serialisation.
81    elapsed_ms: u64,
82}
83
84pub fn run(args: ListArgs) -> Result<(), AppError> {
85    let inicio = std::time::Instant::now();
86    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
87    let paths = AppPaths::resolve(args.db.as_deref())?;
88    // v1.0.22 P1: standardizes exit code 4 with a friendly message when the DB does not exist.
89    crate::storage::connection::ensure_db_ready(&paths)?;
90    let conn = open_ro(&paths.db)?;
91
92    let memory_type_str = args.r#type.map(|t| t.as_str());
93    let rows = memories::list(
94        &conn,
95        &namespace,
96        memory_type_str,
97        args.limit,
98        args.offset,
99        args.include_deleted,
100    )?;
101
102    let items: Vec<ListItem> = rows
103        .into_iter()
104        .map(|r| {
105            let snippet: String = r.body.chars().take(200).collect();
106            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
107            ListItem {
108                id: r.id,
109                memory_id: r.id,
110                name: r.name,
111                namespace: r.namespace,
112                memory_type: r.memory_type,
113                description: r.description,
114                snippet,
115                updated_at: r.updated_at,
116                updated_at_iso,
117            }
118        })
119        .collect();
120
121    match args.format {
122        OutputFormat::Json => output::emit_json(&ListResponse {
123            items,
124            elapsed_ms: inicio.elapsed().as_millis() as u64,
125        })?,
126        OutputFormat::Text | OutputFormat::Markdown => {
127            for item in &items {
128                output::emit_text(&format!("{}: {}", item.name, item.snippet));
129            }
130        }
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn list_response_serializes_items_and_elapsed_ms() {
141        let resp = ListResponse {
142            items: vec![ListItem {
143                id: 1,
144                memory_id: 1,
145                name: "test-memory".to_string(),
146                namespace: "global".to_string(),
147                memory_type: "note".to_string(),
148                description: "descricao de teste".to_string(),
149                snippet: "corpo resumido".to_string(),
150                updated_at: 1_745_000_000,
151                updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
152            }],
153            elapsed_ms: 7,
154        };
155        let json = serde_json::to_value(&resp).unwrap();
156        assert!(json["items"].is_array());
157        assert_eq!(json["items"].as_array().unwrap().len(), 1);
158        assert_eq!(json["items"][0]["name"], "test-memory");
159        assert_eq!(json["items"][0]["memory_id"], 1);
160        assert_eq!(json["elapsed_ms"], 7);
161    }
162
163    #[test]
164    fn list_response_items_empty_serializes_empty_array() {
165        let resp = ListResponse {
166            items: vec![],
167            elapsed_ms: 0,
168        };
169        let json = serde_json::to_value(&resp).unwrap();
170        assert!(json["items"].is_array());
171        assert_eq!(json["items"].as_array().unwrap().len(), 0);
172        assert_eq!(json["elapsed_ms"], 0);
173    }
174
175    #[test]
176    fn list_item_memory_id_equals_id() {
177        let item = ListItem {
178            id: 42,
179            memory_id: 42,
180            name: "memory-alias".to_string(),
181            namespace: "projeto".to_string(),
182            memory_type: "fact".to_string(),
183            description: "desc".to_string(),
184            snippet: "snip".to_string(),
185            updated_at: 0,
186            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
187        };
188        let json = serde_json::to_value(&item).unwrap();
189        assert_eq!(
190            json["id"], json["memory_id"],
191            "id e memory_id devem ser iguais"
192        );
193    }
194
195    #[test]
196    fn snippet_truncated_to_200_chars() {
197        let body_longo: String = "a".repeat(300);
198        let snippet: String = body_longo.chars().take(200).collect();
199        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
200    }
201
202    #[test]
203    fn updated_at_iso_epoch_zero_yields_valid_utc() {
204        let iso = crate::tz::epoch_to_iso(0);
205        assert!(
206            iso.starts_with("1970-01-01T00:00:00"),
207            "epoch 0 deve mapear para 1970-01-01, obtido: {iso}"
208        );
209        assert!(
210            iso.contains('+') || iso.contains('-'),
211            "must contain offset sign, got: {iso}"
212        );
213    }
214}