Skip to main content

sqlite_graphrag/
output.rs

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