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