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)]
12pub struct ListArgs {
13    /// Namespace to list memories from. Defaults to "global".
14    #[arg(
15        long,
16        default_value = "global",
17        help = "Namespace to list memories from"
18    )]
19    pub namespace: Option<String>,
20    /// Filter by memory.type. Note: distinct from graph entity_type
21    /// (project/tool/person/file/concept/incident/decision/memory/dashboard/issue_tracker)
22    /// used in --entities-file.
23    #[arg(long, value_enum)]
24    pub r#type: Option<MemoryType>,
25    /// Maximum number of memories to return (default: 50).
26    #[arg(
27        long,
28        default_value = "50",
29        help = "Maximum number of memories to return"
30    )]
31    pub limit: usize,
32    /// Number of memories to skip before returning results.
33    #[arg(long, default_value = "0", help = "Number of memories to skip")]
34    pub offset: usize,
35    /// Output format: json (default), text, or markdown.
36    #[arg(long, value_enum, default_value = "json", help = "Output format")]
37    pub format: OutputFormat,
38    /// Include soft-deleted memories in the listing (deleted_at IS NOT NULL).
39    #[arg(long, default_value_t = false, help = "Include soft-deleted memories")]
40    pub include_deleted: bool,
41    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
42    pub json: bool,
43    /// Path to graphrag.sqlite (overrides SQLITE_GRAPHRAG_DB_PATH and default CWD).
44    #[arg(
45        long,
46        env = "SQLITE_GRAPHRAG_DB_PATH",
47        help = "Path to graphrag.sqlite"
48    )]
49    pub db: Option<String>,
50}
51
52#[derive(Serialize)]
53struct ListItem {
54    id: i64,
55    /// Semantic alias of `id` for the contract documented in SKILL.md and AGENT_PROTOCOL.md.
56    memory_id: i64,
57    name: String,
58    namespace: String,
59    #[serde(rename = "type")]
60    memory_type: String,
61    description: String,
62    snippet: String,
63    updated_at: i64,
64    /// Timestamp RFC 3339 UTC paralelo a `updated_at`.
65    updated_at_iso: String,
66}
67
68#[derive(Serialize)]
69struct ListResponse {
70    items: Vec<ListItem>,
71    /// Total execution time in milliseconds from handler start to serialisation.
72    elapsed_ms: u64,
73}
74
75pub fn run(args: ListArgs) -> Result<(), AppError> {
76    let inicio = std::time::Instant::now();
77    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
78    let paths = AppPaths::resolve(args.db.as_deref())?;
79    // v1.0.22 P1: padroniza exit code 4 com mensagem amigável quando DB não existe.
80    if !paths.db.exists() {
81        return Err(AppError::NotFound(
82            crate::i18n::errors_msg::database_not_found(&paths.db.display().to_string()),
83        ));
84    }
85    let conn = open_ro(&paths.db)?;
86
87    let memory_type_str = args.r#type.map(|t| t.as_str());
88    let rows = memories::list(
89        &conn,
90        &namespace,
91        memory_type_str,
92        args.limit,
93        args.offset,
94        args.include_deleted,
95    )?;
96
97    let items: Vec<ListItem> = rows
98        .into_iter()
99        .map(|r| {
100            let snippet: String = r.body.chars().take(200).collect();
101            let updated_at_iso = crate::tz::epoch_to_iso(r.updated_at);
102            ListItem {
103                id: r.id,
104                memory_id: r.id,
105                name: r.name,
106                namespace: r.namespace,
107                memory_type: r.memory_type,
108                description: r.description,
109                snippet,
110                updated_at: r.updated_at,
111                updated_at_iso,
112            }
113        })
114        .collect();
115
116    match args.format {
117        OutputFormat::Json => output::emit_json(&ListResponse {
118            items,
119            elapsed_ms: inicio.elapsed().as_millis() as u64,
120        })?,
121        OutputFormat::Text | OutputFormat::Markdown => {
122            for item in &items {
123                output::emit_text(&format!("{}: {}", item.name, item.snippet));
124            }
125        }
126    }
127    Ok(())
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn list_response_serializa_items_e_elapsed_ms() {
136        let resp = ListResponse {
137            items: vec![ListItem {
138                id: 1,
139                memory_id: 1,
140                name: "teste-memoria".to_string(),
141                namespace: "global".to_string(),
142                memory_type: "note".to_string(),
143                description: "descricao de teste".to_string(),
144                snippet: "corpo resumido".to_string(),
145                updated_at: 1_745_000_000,
146                updated_at_iso: "2025-04-19T00:00:00Z".to_string(),
147            }],
148            elapsed_ms: 7,
149        };
150        let json = serde_json::to_value(&resp).unwrap();
151        assert!(json["items"].is_array());
152        assert_eq!(json["items"].as_array().unwrap().len(), 1);
153        assert_eq!(json["items"][0]["name"], "teste-memoria");
154        assert_eq!(json["items"][0]["memory_id"], 1);
155        assert_eq!(json["elapsed_ms"], 7);
156    }
157
158    #[test]
159    fn list_response_items_vazio_serializa_array_vazio() {
160        let resp = ListResponse {
161            items: vec![],
162            elapsed_ms: 0,
163        };
164        let json = serde_json::to_value(&resp).unwrap();
165        assert!(json["items"].is_array());
166        assert_eq!(json["items"].as_array().unwrap().len(), 0);
167        assert_eq!(json["elapsed_ms"], 0);
168    }
169
170    #[test]
171    fn list_item_memory_id_equals_id() {
172        let item = ListItem {
173            id: 42,
174            memory_id: 42,
175            name: "memoria-alias".to_string(),
176            namespace: "projeto".to_string(),
177            memory_type: "fact".to_string(),
178            description: "desc".to_string(),
179            snippet: "snip".to_string(),
180            updated_at: 0,
181            updated_at_iso: "1970-01-01T00:00:00Z".to_string(),
182        };
183        let json = serde_json::to_value(&item).unwrap();
184        assert_eq!(
185            json["id"], json["memory_id"],
186            "id e memory_id devem ser iguais"
187        );
188    }
189
190    #[test]
191    fn snippet_truncado_em_200_chars() {
192        let body_longo: String = "a".repeat(300);
193        let snippet: String = body_longo.chars().take(200).collect();
194        assert_eq!(snippet.len(), 200, "snippet deve ter exatamente 200 chars");
195    }
196
197    #[test]
198    fn updated_at_iso_epoch_zero_gera_utc_valido() {
199        let iso = crate::tz::epoch_to_iso(0);
200        assert!(
201            iso.starts_with("1970-01-01T00:00:00"),
202            "epoch 0 deve mapear para 1970-01-01, obtido: {iso}"
203        );
204        assert!(
205            iso.contains('+') || iso.contains('-'),
206            "deve conter sinal de offset, obtido: {iso}"
207        );
208    }
209}