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///     name_was_normalized: false,
103///     original_name: None,
104/// };
105///
106/// let json = serde_json::to_string(&resp).unwrap();
107/// assert!(json.contains("\"memory_id\":1"));
108/// assert!(json.contains("\"elapsed_ms\":42"));
109/// assert!(json.contains("\"merged_into_memory_id\":null"));
110/// assert!(json.contains("\"urls_persisted\":0"));
111/// assert!(json.contains("\"relationships_truncated\":false"));
112/// ```
113#[derive(Serialize)]
114pub struct RememberResponse {
115    pub memory_id: i64,
116    pub name: String,
117    pub namespace: String,
118    pub action: String,
119    /// Semantic alias of `action` for compatibility with the contract documented in SKILL.md and AGENT_PROTOCOL.md.
120    pub operation: String,
121    pub version: i64,
122    pub entities_persisted: usize,
123    pub relationships_persisted: usize,
124    /// True when the relationship builder hit the cap before covering all entity pairs.
125    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
126    pub relationships_truncated: bool,
127    /// Total chunks produced by the hierarchical splitter for this body.
128    ///
129    /// For single-chunk bodies this equals 1 even though no row is added to
130    /// the `memory_chunks` table — the memory row itself acts as the chunk.
131    /// Use `chunks_persisted` to know how many rows were actually written.
132    pub chunks_created: usize,
133    /// Number of rows actually inserted into the `memory_chunks` table.
134    ///
135    /// Equals zero for single-chunk bodies (the memory row is the chunk) and
136    /// equals `chunks_created` for multi-chunk bodies. Added in v1.0.23 to
137    /// disambiguate from `chunks_created` and reflect database state precisely.
138    pub chunks_persisted: usize,
139    /// Number of unique URLs inserted into `memory_urls` for this memory.
140    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
141    #[serde(default)]
142    pub urls_persisted: usize,
143    /// Extraction method used: "bert+regex" or "regex-only". None when skip-extraction.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub extraction_method: Option<String>,
146    pub merged_into_memory_id: Option<i64>,
147    pub warnings: Vec<String>,
148    /// Timestamp Unix epoch seconds.
149    pub created_at: i64,
150    /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
151    pub created_at_iso: String,
152    /// Total execution time in milliseconds from handler start to serialisation.
153    pub elapsed_ms: u64,
154    /// True when the user-supplied `--name` differed from the persisted slug
155    /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
156    /// callers can detect normalization without parsing stderr WARN logs.
157    #[serde(default)]
158    pub name_was_normalized: bool,
159    /// Original user-supplied `--name` value before normalization.
160    /// Present only when `name_was_normalized == true`; omitted otherwise to
161    /// keep the common (already-kebab) payload small.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub original_name: Option<String>,
164}
165
166/// Individual item returned by the `recall` query.
167///
168/// The `memory_type` field is serialised as `"type"` in JSON to maintain
169/// compatibility with external clients — the Rust name uses `memory_type`
170/// to avoid conflict with the reserved keyword.
171///
172/// # Examples
173///
174/// ```
175/// use sqlite_graphrag::output::RecallItem;
176///
177/// let item = RecallItem {
178///     memory_id: 7,
179///     name: "nota-rust".into(),
180///     namespace: "global".into(),
181///     memory_type: "user".into(),
182///     description: "aprendizado de Rust".into(),
183///     snippet: "ownership e borrowing".into(),
184///     distance: 0.12,
185///     source: "direct".into(),
186///     graph_depth: None,
187/// };
188///
189/// let json = serde_json::to_string(&item).unwrap();
190/// // Rust field `memory_type` appears as `"type"` in JSON.
191/// assert!(json.contains("\"type\":\"user\""));
192/// assert!(!json.contains("memory_type"));
193/// assert!(json.contains("\"distance\":0.12"));
194/// ```
195#[derive(Serialize, Clone)]
196pub struct RecallItem {
197    pub memory_id: i64,
198    pub name: String,
199    pub namespace: String,
200    #[serde(rename = "type")]
201    pub memory_type: String,
202    pub description: String,
203    pub snippet: String,
204    pub distance: f32,
205    pub source: String,
206    /// Number of graph hops between this match and the seed memories.
207    ///
208    /// Set to `None` for direct vector matches (where `distance` is meaningful)
209    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
210    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
211    /// from the `distance: 0.0` placeholder previously used for graph entries.
212    /// Field is omitted from JSON output when `None`.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub graph_depth: Option<u32>,
215}
216
217#[derive(Serialize)]
218pub struct RecallResponse {
219    pub query: String,
220    pub k: usize,
221    pub direct_matches: Vec<RecallItem>,
222    pub graph_matches: Vec<RecallItem>,
223    /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
224    pub results: Vec<RecallItem>,
225    /// Total execution time in milliseconds from handler start to serialisation.
226    pub elapsed_ms: u64,
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use serde::Serialize;
233
234    #[derive(Serialize)]
235    struct Dummy {
236        val: u32,
237    }
238
239    // Non-serializable type to force a JSON serialization error
240    struct NotSerializable;
241    impl Serialize for NotSerializable {
242        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
243            Err(serde::ser::Error::custom(
244                "intentional serialization failure",
245            ))
246        }
247    }
248
249    #[test]
250    fn emit_json_returns_ok_for_valid_value() {
251        let v = Dummy { val: 42 };
252        assert!(emit_json(&v).is_ok());
253    }
254
255    #[test]
256    fn emit_json_returns_err_for_non_serializable_value() {
257        let v = NotSerializable;
258        assert!(emit_json(&v).is_err());
259    }
260
261    #[test]
262    fn emit_json_compact_returns_ok_for_valid_value() {
263        let v = Dummy { val: 7 };
264        assert!(emit_json_compact(&v).is_ok());
265    }
266
267    #[test]
268    fn emit_json_compact_returns_err_for_non_serializable_value() {
269        let v = NotSerializable;
270        assert!(emit_json_compact(&v).is_err());
271    }
272
273    #[test]
274    fn emit_text_does_not_panic() {
275        emit_text("mensagem de teste");
276    }
277
278    #[test]
279    fn emit_progress_does_not_panic() {
280        emit_progress("progresso de teste");
281    }
282
283    #[test]
284    fn remember_response_serializes_correctly() {
285        let r = RememberResponse {
286            memory_id: 1,
287            name: "teste".to_string(),
288            namespace: "ns".to_string(),
289            action: "created".to_string(),
290            operation: "created".to_string(),
291            version: 1,
292            entities_persisted: 2,
293            relationships_persisted: 3,
294            relationships_truncated: false,
295            chunks_created: 4,
296            chunks_persisted: 4,
297            urls_persisted: 2,
298            extraction_method: None,
299            merged_into_memory_id: None,
300            warnings: vec!["aviso".to_string()],
301            created_at: 1776569715,
302            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
303            elapsed_ms: 123,
304            name_was_normalized: false,
305            original_name: None,
306        };
307        let json = serde_json::to_string(&r).unwrap();
308        assert!(json.contains("memory_id"));
309        assert!(json.contains("aviso"));
310        assert!(json.contains("\"namespace\""));
311        assert!(json.contains("\"merged_into_memory_id\""));
312        assert!(json.contains("\"operation\""));
313        assert!(json.contains("\"created_at\""));
314        assert!(json.contains("\"created_at_iso\""));
315        assert!(json.contains("\"elapsed_ms\""));
316        assert!(json.contains("\"urls_persisted\""));
317        assert!(json.contains("\"relationships_truncated\":false"));
318    }
319
320    #[test]
321    fn recall_item_serializes_renamed_type_field() {
322        let item = RecallItem {
323            memory_id: 10,
324            name: "entidade".to_string(),
325            namespace: "ns".to_string(),
326            memory_type: "entity".to_string(),
327            description: "desc".to_string(),
328            snippet: "trecho".to_string(),
329            distance: 0.5,
330            source: "db".to_string(),
331            graph_depth: None,
332        };
333        let json = serde_json::to_string(&item).unwrap();
334        assert!(json.contains("\"type\""));
335        assert!(!json.contains("memory_type"));
336        // Field is omitted from JSON when None.
337        assert!(!json.contains("graph_depth"));
338    }
339
340    #[test]
341    fn recall_response_serializes_with_lists() {
342        let resp = RecallResponse {
343            query: "busca".to_string(),
344            k: 10,
345            direct_matches: vec![],
346            graph_matches: vec![],
347            results: vec![],
348            elapsed_ms: 42,
349        };
350        let json = serde_json::to_string(&resp).unwrap();
351        assert!(json.contains("direct_matches"));
352        assert!(json.contains("graph_matches"));
353        assert!(json.contains("\"k\":"));
354        assert!(json.contains("\"results\""));
355        assert!(json.contains("\"elapsed_ms\""));
356    }
357
358    #[test]
359    fn output_format_default_is_json() {
360        let fmt = OutputFormat::default();
361        assert!(matches!(fmt, OutputFormat::Json));
362    }
363
364    #[test]
365    fn output_format_variants_exist() {
366        let _text = OutputFormat::Text;
367        let _md = OutputFormat::Markdown;
368        let _json = OutputFormat::Json;
369    }
370
371    #[test]
372    fn recall_item_clone_produces_equal_value() {
373        let item = RecallItem {
374            memory_id: 99,
375            name: "clone".to_string(),
376            namespace: "ns".to_string(),
377            memory_type: "relation".to_string(),
378            description: "d".to_string(),
379            snippet: "s".to_string(),
380            distance: 0.1,
381            source: "src".to_string(),
382            graph_depth: Some(2),
383        };
384        let cloned = item.clone();
385        assert_eq!(cloned.memory_id, item.memory_id);
386        assert_eq!(cloned.name, item.name);
387        assert_eq!(cloned.graph_depth, Some(2));
388    }
389}