Skip to main content

sqlite_graphrag/
output.rs

1use crate::errors::AppError;
2use serde::Serialize;
3
4#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
5pub enum OutputFormat {
6    #[default]
7    Json,
8    Text,
9    Markdown,
10}
11
12#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
13pub enum JsonOutputFormat {
14    #[default]
15    Json,
16}
17
18pub fn emit_json<T: Serialize>(value: &T) -> Result<(), AppError> {
19    let json = serde_json::to_string_pretty(value)?;
20    println!("{json}");
21    Ok(())
22}
23
24pub fn emit_json_compact<T: Serialize>(value: &T) -> Result<(), AppError> {
25    let json = serde_json::to_string(value)?;
26    println!("{json}");
27    Ok(())
28}
29
30pub fn emit_text(msg: &str) {
31    println!("{msg}");
32}
33
34pub fn emit_progress(msg: &str) {
35    tracing::info!(message = msg);
36}
37
38/// Emite mensagem de progresso bilíngue respeitando `--lang` ou `SQLITE_GRAPHRAG_LANG`.
39/// Uso: `output::emit_progress_i18n("Computing embedding...", "Calculando embedding...")`.
40pub fn emit_progress_i18n(en: &str, pt: &str) {
41    use crate::i18n::{current, Language};
42    match current() {
43        Language::English => tracing::info!(message = en),
44        Language::Portugues => tracing::info!(message = pt),
45    }
46}
47
48/// Emite mensagem de erro localizada em stderr com prefixo `Error:`/`Erro:`.
49///
50/// Centraliza a saída de erro para humanos seguindo o Pattern 5 (`output.rs` é o
51/// ÚNICO ponto de I/O do CLI). Não loga via `tracing` — chame `tracing::error!`
52/// explicitamente antes desta função quando observabilidade estruturada for desejada.
53pub fn emit_error(localized_msg: &str) {
54    eprintln!("{}: {}", crate::i18n::prefixo_erro(), localized_msg);
55}
56
57/// Emite erro bilíngue em stderr respeitando `--lang` ou `SQLITE_GRAPHRAG_LANG`.
58/// Uso: `output::emit_error_i18n("invariant violated", "invariante violado")`.
59pub fn emit_error_i18n(en: &str, pt: &str) {
60    use crate::i18n::{current, Language};
61    let msg = match current() {
62        Language::English => en,
63        Language::Portugues => pt,
64    };
65    emit_error(msg);
66}
67
68/// Payload JSON emitido pelo subcomando `remember`.
69///
70/// Todos os campos são obrigatórios no contrato JSON (ver `docs/schemas/remember.schema.json`).
71/// `operation` é alias de `action` para compatibilidade com clientes que usam o campo antigo.
72///
73/// # Examples
74///
75/// ```
76/// use sqlite_graphrag::output::RememberResponse;
77///
78/// let resp = RememberResponse {
79///     memory_id: 1,
80///     name: "nota-inicial".into(),
81///     namespace: "global".into(),
82///     action: "created".into(),
83///     operation: "created".into(),
84///     version: 1,
85///     entities_persisted: 0,
86///     relationships_persisted: 0,
87///     relationships_truncated: false,
88///     chunks_created: 1,
89///     chunks_persisted: 0,
90///     urls_persisted: 0,
91///     extraction_method: None,
92///     merged_into_memory_id: None,
93///     warnings: vec![],
94///     created_at: 1_700_000_000,
95///     created_at_iso: "2023-11-14T22:13:20Z".into(),
96///     elapsed_ms: 42,
97/// };
98///
99/// let json = serde_json::to_string(&resp).unwrap();
100/// assert!(json.contains("\"memory_id\":1"));
101/// assert!(json.contains("\"elapsed_ms\":42"));
102/// assert!(json.contains("\"merged_into_memory_id\":null"));
103/// assert!(json.contains("\"urls_persisted\":0"));
104/// assert!(json.contains("\"relationships_truncated\":false"));
105/// ```
106#[derive(Serialize)]
107pub struct RememberResponse {
108    pub memory_id: i64,
109    pub name: String,
110    pub namespace: String,
111    pub action: String,
112    /// Alias semântico de `action` para compatibilidade com contrato documentado em SKILL.md e AGENT_PROTOCOL.md.
113    pub operation: String,
114    pub version: i64,
115    pub entities_persisted: usize,
116    pub relationships_persisted: usize,
117    /// True when the relationship builder hit the cap before covering all entity pairs.
118    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
119    pub relationships_truncated: bool,
120    /// Total chunks produced by the hierarchical splitter for this body.
121    ///
122    /// For single-chunk bodies this equals 1 even though no row is added to
123    /// the `memory_chunks` table — the memory row itself acts as the chunk.
124    /// Use `chunks_persisted` to know how many rows were actually written.
125    pub chunks_created: usize,
126    /// Number of rows actually inserted into the `memory_chunks` table.
127    ///
128    /// Equals zero for single-chunk bodies (the memory row is the chunk) and
129    /// equals `chunks_created` for multi-chunk bodies. Added in v1.0.23 to
130    /// disambiguate from `chunks_created` and reflect database state precisely.
131    pub chunks_persisted: usize,
132    /// Number of unique URLs inserted into `memory_urls` for this memory.
133    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
134    #[serde(default)]
135    pub urls_persisted: usize,
136    /// Método de extração usado: "bert+regex" ou "regex-only". None se skip-extraction.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub extraction_method: Option<String>,
139    pub merged_into_memory_id: Option<i64>,
140    pub warnings: Vec<String>,
141    /// Timestamp Unix epoch seconds.
142    pub created_at: i64,
143    /// Timestamp RFC 3339 UTC string paralelo a `created_at` para parsers ISO 8601.
144    pub created_at_iso: String,
145    /// Tempo total de execução em milissegundos desde início do handler até serialização.
146    pub elapsed_ms: u64,
147}
148
149/// Item individual retornado pela consulta `recall`.
150///
151/// O campo `memory_type` é serializado como `"type"` no JSON para manter
152/// compatibilidade com clientes externos — o nome Rust usa `memory_type`
153/// para evitar conflito com a palavra reservada.
154///
155/// # Examples
156///
157/// ```
158/// use sqlite_graphrag::output::RecallItem;
159///
160/// let item = RecallItem {
161///     memory_id: 7,
162///     name: "nota-rust".into(),
163///     namespace: "global".into(),
164///     memory_type: "user".into(),
165///     description: "aprendizado de Rust".into(),
166///     snippet: "ownership e borrowing".into(),
167///     distance: 0.12,
168///     source: "direct".into(),
169///     graph_depth: None,
170/// };
171///
172/// let json = serde_json::to_string(&item).unwrap();
173/// // Campo Rust `memory_type` aparece como `"type"` no JSON.
174/// assert!(json.contains("\"type\":\"user\""));
175/// assert!(!json.contains("memory_type"));
176/// assert!(json.contains("\"distance\":0.12"));
177/// ```
178#[derive(Serialize, Clone)]
179pub struct RecallItem {
180    pub memory_id: i64,
181    pub name: String,
182    pub namespace: String,
183    #[serde(rename = "type")]
184    pub memory_type: String,
185    pub description: String,
186    pub snippet: String,
187    pub distance: f32,
188    pub source: String,
189    /// Number of graph hops between this match and the seed memories.
190    ///
191    /// Set to `None` for direct vector matches (where `distance` is meaningful)
192    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
193    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
194    /// from the `distance: 0.0` placeholder previously used for graph entries.
195    /// Field is omitted from JSON output when `None`.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub graph_depth: Option<u32>,
198}
199
200#[derive(Serialize)]
201pub struct RecallResponse {
202    pub query: String,
203    pub k: usize,
204    pub direct_matches: Vec<RecallItem>,
205    pub graph_matches: Vec<RecallItem>,
206    /// Alias agregado de `direct_matches` + `graph_matches` para contrato documentado em SKILL.md.
207    pub results: Vec<RecallItem>,
208    /// Tempo total de execução em milissegundos desde início do handler até serialização.
209    pub elapsed_ms: u64,
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use serde::Serialize;
216
217    #[derive(Serialize)]
218    struct Dummy {
219        val: u32,
220    }
221
222    // Tipo não-serializável para forçar erro de serialização JSON
223    struct NotSerializable;
224    impl Serialize for NotSerializable {
225        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
226            Err(serde::ser::Error::custom(
227                "falha intencional de serialização",
228            ))
229        }
230    }
231
232    #[test]
233    fn emit_json_retorna_ok_para_valor_valido() {
234        let v = Dummy { val: 42 };
235        assert!(emit_json(&v).is_ok());
236    }
237
238    #[test]
239    fn emit_json_retorna_erro_para_valor_nao_serializavel() {
240        let v = NotSerializable;
241        assert!(emit_json(&v).is_err());
242    }
243
244    #[test]
245    fn emit_json_compact_retorna_ok_para_valor_valido() {
246        let v = Dummy { val: 7 };
247        assert!(emit_json_compact(&v).is_ok());
248    }
249
250    #[test]
251    fn emit_json_compact_retorna_erro_para_valor_nao_serializavel() {
252        let v = NotSerializable;
253        assert!(emit_json_compact(&v).is_err());
254    }
255
256    #[test]
257    fn emit_text_nao_entra_em_panico() {
258        emit_text("mensagem de teste");
259    }
260
261    #[test]
262    fn emit_progress_nao_entra_em_panico() {
263        emit_progress("progresso de teste");
264    }
265
266    #[test]
267    fn remember_response_serializa_corretamente() {
268        let r = RememberResponse {
269            memory_id: 1,
270            name: "teste".to_string(),
271            namespace: "ns".to_string(),
272            action: "created".to_string(),
273            operation: "created".to_string(),
274            version: 1,
275            entities_persisted: 2,
276            relationships_persisted: 3,
277            relationships_truncated: false,
278            chunks_created: 4,
279            chunks_persisted: 4,
280            urls_persisted: 2,
281            extraction_method: None,
282            merged_into_memory_id: None,
283            warnings: vec!["aviso".to_string()],
284            created_at: 1776569715,
285            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
286            elapsed_ms: 123,
287        };
288        let json = serde_json::to_string(&r).unwrap();
289        assert!(json.contains("memory_id"));
290        assert!(json.contains("aviso"));
291        assert!(json.contains("\"namespace\""));
292        assert!(json.contains("\"merged_into_memory_id\""));
293        assert!(json.contains("\"operation\""));
294        assert!(json.contains("\"created_at\""));
295        assert!(json.contains("\"created_at_iso\""));
296        assert!(json.contains("\"elapsed_ms\""));
297        assert!(json.contains("\"urls_persisted\""));
298        assert!(json.contains("\"relationships_truncated\":false"));
299    }
300
301    #[test]
302    fn recall_item_serializa_campo_type_renomeado() {
303        let item = RecallItem {
304            memory_id: 10,
305            name: "entidade".to_string(),
306            namespace: "ns".to_string(),
307            memory_type: "entity".to_string(),
308            description: "desc".to_string(),
309            snippet: "trecho".to_string(),
310            distance: 0.5,
311            source: "db".to_string(),
312            graph_depth: None,
313        };
314        let json = serde_json::to_string(&item).unwrap();
315        assert!(json.contains("\"type\""));
316        assert!(!json.contains("memory_type"));
317        // Field is omitted from JSON when None.
318        assert!(!json.contains("graph_depth"));
319    }
320
321    #[test]
322    fn recall_response_serializa_com_listas() {
323        let resp = RecallResponse {
324            query: "busca".to_string(),
325            k: 10,
326            direct_matches: vec![],
327            graph_matches: vec![],
328            results: vec![],
329            elapsed_ms: 42,
330        };
331        let json = serde_json::to_string(&resp).unwrap();
332        assert!(json.contains("direct_matches"));
333        assert!(json.contains("graph_matches"));
334        assert!(json.contains("\"k\":"));
335        assert!(json.contains("\"results\""));
336        assert!(json.contains("\"elapsed_ms\""));
337    }
338
339    #[test]
340    fn output_format_default_eh_json() {
341        let fmt = OutputFormat::default();
342        assert!(matches!(fmt, OutputFormat::Json));
343    }
344
345    #[test]
346    fn output_format_variantes_existem() {
347        let _text = OutputFormat::Text;
348        let _md = OutputFormat::Markdown;
349        let _json = OutputFormat::Json;
350    }
351
352    #[test]
353    fn recall_item_clone_produz_valor_igual() {
354        let item = RecallItem {
355            memory_id: 99,
356            name: "clone".to_string(),
357            namespace: "ns".to_string(),
358            memory_type: "relation".to_string(),
359            description: "d".to_string(),
360            snippet: "s".to_string(),
361            distance: 0.1,
362            source: "src".to_string(),
363            graph_depth: Some(2),
364        };
365        let cloned = item.clone();
366        assert_eq!(cloned.memory_id, item.memory_id);
367        assert_eq!(cloned.name, item.name);
368        assert_eq!(cloned.graph_depth, Some(2));
369    }
370}