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/// Output format variants accepted by `--format` CLI flags.
10#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
11pub enum OutputFormat {
12    #[default]
13    Json,
14    Text,
15    Markdown,
16}
17
18/// Restricted JSON-only format for commands that always emit JSON.
19#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
20pub enum JsonOutputFormat {
21    #[default]
22    Json,
23}
24
25/// Serializes `value` as pretty-printed JSON and writes it to stdout with a trailing newline.
26///
27/// Flushes stdout after writing. A `BrokenPipe` error is silenced so that
28/// piping to consumers that close early (e.g. `head`) does not surface an error.
29///
30/// # Errors
31/// Returns `Err` when serialization fails or when a non-`BrokenPipe` I/O error occurs.
32pub fn emit_json<T: Serialize>(value: &T) -> Result<(), AppError> {
33    let json = serde_json::to_string_pretty(value)?;
34    let mut out = std::io::stdout().lock();
35    if let Err(e) = std::io::Write::write_all(&mut out, json.as_bytes())
36        .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
37        .and_then(|()| std::io::Write::flush(&mut out))
38    {
39        if e.kind() == std::io::ErrorKind::BrokenPipe {
40            return Ok(());
41        }
42        return Err(AppError::Io(e));
43    }
44    Ok(())
45}
46
47/// Serializes `value` as compact (single-line) JSON and writes it to stdout with a trailing newline.
48///
49/// Flushes stdout after writing. A `BrokenPipe` error is silenced.
50///
51/// # Errors
52/// Returns `Err` when serialization fails or when a non-`BrokenPipe` I/O error occurs.
53pub fn emit_json_compact<T: Serialize>(value: &T) -> Result<(), AppError> {
54    let json = serde_json::to_string(value)?;
55    let mut out = std::io::stdout().lock();
56    if let Err(e) = std::io::Write::write_all(&mut out, json.as_bytes())
57        .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
58        .and_then(|()| std::io::Write::flush(&mut out))
59    {
60        if e.kind() == std::io::ErrorKind::BrokenPipe {
61            return Ok(());
62        }
63        return Err(AppError::Io(e));
64    }
65    Ok(())
66}
67
68/// Writes `msg` followed by a newline to stdout and flushes.
69///
70/// A `BrokenPipe` error is silenced gracefully.
71pub fn emit_text(msg: &str) {
72    let mut out = std::io::stdout().lock();
73    let _ = std::io::Write::write_all(&mut out, msg.as_bytes())
74        .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
75        .and_then(|()| std::io::Write::flush(&mut out));
76}
77
78/// Logs `msg` as a structured `tracing::info!` event (does not write to stdout).
79pub fn emit_progress(msg: &str) {
80    tracing::info!(message = msg);
81}
82
83/// Emits a bilingual progress message honouring `--lang` or `SQLITE_GRAPHRAG_LANG`.
84/// Usage: `output::emit_progress_i18n("Computing embedding...", "Calculando embedding...")`.
85pub fn emit_progress_i18n(en: &str, pt: &str) {
86    use crate::i18n::{current, Language};
87    match current() {
88        Language::English => tracing::info!(message = en),
89        Language::Portuguese => tracing::info!(message = pt),
90    }
91}
92
93/// Emits a localised error message to stderr with the `Error:`/`Erro:` prefix.
94///
95/// Centralises human-readable error output following Pattern 5 (`output.rs` is the
96/// SOLE I/O point of the CLI). Does not log via `tracing` — call `tracing::error!`
97/// explicitly before this function when structured observability is desired.
98pub fn emit_error(localized_msg: &str) {
99    eprintln!("{}: {}", crate::i18n::error_prefix(), localized_msg);
100}
101
102/// Emits a bilingual error to stderr honouring `--lang` or `SQLITE_GRAPHRAG_LANG`.
103/// Usage: `output::emit_error_i18n("invariant violated", "invariante violado")`.
104pub fn emit_error_i18n(en: &str, pt: &str) {
105    use crate::i18n::{current, Language};
106    let msg = match current() {
107        Language::English => en,
108        Language::Portuguese => pt,
109    };
110    emit_error(msg);
111}
112
113/// JSON payload emitted by the `remember` subcommand.
114///
115/// All fields are required by the JSON contract (see `docs/schemas/remember.schema.json`).
116/// `operation` is an alias of `action` for compatibility with clients using the old field name.
117///
118/// # Examples
119///
120/// ```
121/// use sqlite_graphrag::output::RememberResponse;
122///
123/// let resp = RememberResponse {
124///     memory_id: 1,
125///     name: "nota-inicial".into(),
126///     namespace: "global".into(),
127///     action: "created".into(),
128///     operation: "created".into(),
129///     version: 1,
130///     entities_persisted: 0,
131///     relationships_persisted: 0,
132///     relationships_truncated: false,
133///     chunks_created: 1,
134///     chunks_persisted: 0,
135///     urls_persisted: 0,
136///     extraction_method: None,
137///     merged_into_memory_id: None,
138///     warnings: vec![],
139///     created_at: 1_700_000_000,
140///     created_at_iso: "2023-11-14T22:13:20Z".into(),
141///     elapsed_ms: 42,
142///     name_was_normalized: false,
143///     original_name: None,
144/// };
145///
146/// let json = serde_json::to_string(&resp).unwrap();
147/// assert!(json.contains("\"memory_id\":1"));
148/// assert!(json.contains("\"elapsed_ms\":42"));
149/// assert!(json.contains("\"merged_into_memory_id\":null"));
150/// assert!(json.contains("\"urls_persisted\":0"));
151/// assert!(json.contains("\"relationships_truncated\":false"));
152/// ```
153#[derive(Serialize)]
154pub struct RememberResponse {
155    pub memory_id: i64,
156    pub name: String,
157    pub namespace: String,
158    pub action: String,
159    /// Semantic alias of `action` for compatibility with the contract documented in SKILL.md and AGENT_PROTOCOL.md.
160    pub operation: String,
161    pub version: i64,
162    pub entities_persisted: usize,
163    pub relationships_persisted: usize,
164    /// True when the relationship builder hit the cap before covering all entity pairs.
165    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
166    pub relationships_truncated: bool,
167    /// Total number of chunks the body was split into BEFORE dedup.
168    ///
169    /// For single-chunk bodies this equals 1 even though no row is added to
170    /// the `memory_chunks` table — the memory row itself acts as the chunk.
171    /// Use `chunks_persisted` to know how many rows were actually written.
172    pub chunks_created: usize,
173    /// Number of chunks actually written to chunks/embeddings tables. Always <= chunks_created.
174    ///
175    /// Equal when no chunk had identical normalized text already in DB; less when dedup skipped
176    /// some. Equals zero for single-chunk bodies (the memory row is the chunk) and equals
177    /// `chunks_created` for multi-chunk bodies. Added in v1.0.23 to disambiguate from
178    /// `chunks_created` and reflect database state precisely.
179    pub chunks_persisted: usize,
180    /// Number of unique URLs inserted into `memory_urls` for this memory.
181    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
182    #[serde(default)]
183    pub urls_persisted: usize,
184    /// Extraction method used: "bert+regex" or "regex-only". None when skip-extraction.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub extraction_method: Option<String>,
187    pub merged_into_memory_id: Option<i64>,
188    pub warnings: Vec<String>,
189    /// Timestamp Unix epoch seconds.
190    pub created_at: i64,
191    /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
192    pub created_at_iso: String,
193    /// Total execution time in milliseconds from handler start to serialisation.
194    pub elapsed_ms: u64,
195    /// True when the user-supplied `--name` differed from the persisted slug
196    /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
197    /// callers can detect normalization without parsing stderr WARN logs.
198    #[serde(default)]
199    pub name_was_normalized: bool,
200    /// Original user-supplied `--name` value before normalization.
201    /// Present only when `name_was_normalized == true`; omitted otherwise to
202    /// keep the common (already-kebab) payload small.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub original_name: Option<String>,
205}
206
207/// Individual item returned by the `recall` query.
208///
209/// The `memory_type` field is serialised as `"type"` in JSON to maintain
210/// compatibility with external clients — the Rust name uses `memory_type`
211/// to avoid conflict with the reserved keyword.
212///
213/// # Examples
214///
215/// ```
216/// use sqlite_graphrag::output::RecallItem;
217///
218/// let item = RecallItem {
219///     memory_id: 7,
220///     name: "nota-rust".into(),
221///     namespace: "global".into(),
222///     memory_type: "user".into(),
223///     description: "aprendizado de Rust".into(),
224///     snippet: "ownership e borrowing".into(),
225///     distance: 0.12,
226///     source: "direct".into(),
227///     graph_depth: None,
228/// };
229///
230/// let json = serde_json::to_string(&item).unwrap();
231/// // Rust field `memory_type` appears as `"type"` in JSON.
232/// assert!(json.contains("\"type\":\"user\""));
233/// assert!(!json.contains("memory_type"));
234/// assert!(json.contains("\"distance\":0.12"));
235/// ```
236#[derive(Serialize, Clone)]
237pub struct RecallItem {
238    pub memory_id: i64,
239    pub name: String,
240    pub namespace: String,
241    #[serde(rename = "type")]
242    pub memory_type: String,
243    pub description: String,
244    pub snippet: String,
245    pub distance: f32,
246    pub source: String,
247    /// Number of graph hops between this match and the seed memories.
248    ///
249    /// Set to `None` for direct vector matches (where `distance` is meaningful)
250    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
251    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
252    /// from the `distance: 0.0` placeholder previously used for graph entries.
253    /// Field is omitted from JSON output when `None`.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub graph_depth: Option<u32>,
256}
257
258/// Full response envelope returned by the `recall` subcommand.
259///
260/// Contains both direct vector matches and graph-traversal matches, plus the
261/// aggregated `results` list that merges both for callers that do not need
262/// to distinguish the source.
263#[derive(Serialize)]
264pub struct RecallResponse {
265    pub query: String,
266    pub k: usize,
267    pub direct_matches: Vec<RecallItem>,
268    pub graph_matches: Vec<RecallItem>,
269    /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
270    pub results: Vec<RecallItem>,
271    /// Total execution time in milliseconds from handler start to serialisation.
272    pub elapsed_ms: u64,
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use serde::Serialize;
279
280    #[derive(Serialize)]
281    struct Dummy {
282        val: u32,
283    }
284
285    // Non-serializable type to force a JSON serialization error
286    struct NotSerializable;
287    impl Serialize for NotSerializable {
288        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
289            Err(serde::ser::Error::custom(
290                "intentional serialization failure",
291            ))
292        }
293    }
294
295    #[test]
296    fn emit_json_returns_ok_for_valid_value() {
297        let v = Dummy { val: 42 };
298        assert!(emit_json(&v).is_ok());
299    }
300
301    #[test]
302    fn emit_json_returns_err_for_non_serializable_value() {
303        let v = NotSerializable;
304        assert!(emit_json(&v).is_err());
305    }
306
307    #[test]
308    fn emit_json_compact_returns_ok_for_valid_value() {
309        let v = Dummy { val: 7 };
310        assert!(emit_json_compact(&v).is_ok());
311    }
312
313    #[test]
314    fn emit_json_compact_returns_err_for_non_serializable_value() {
315        let v = NotSerializable;
316        assert!(emit_json_compact(&v).is_err());
317    }
318
319    #[test]
320    fn emit_text_does_not_panic() {
321        emit_text("mensagem de teste");
322    }
323
324    #[test]
325    fn emit_progress_does_not_panic() {
326        emit_progress("progresso de teste");
327    }
328
329    #[test]
330    fn remember_response_serializes_correctly() {
331        let r = RememberResponse {
332            memory_id: 1,
333            name: "teste".to_string(),
334            namespace: "ns".to_string(),
335            action: "created".to_string(),
336            operation: "created".to_string(),
337            version: 1,
338            entities_persisted: 2,
339            relationships_persisted: 3,
340            relationships_truncated: false,
341            chunks_created: 4,
342            chunks_persisted: 4,
343            urls_persisted: 2,
344            extraction_method: None,
345            merged_into_memory_id: None,
346            warnings: vec!["aviso".to_string()],
347            created_at: 1776569715,
348            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
349            elapsed_ms: 123,
350            name_was_normalized: false,
351            original_name: None,
352        };
353        let json = serde_json::to_string(&r).unwrap();
354        assert!(json.contains("memory_id"));
355        assert!(json.contains("aviso"));
356        assert!(json.contains("\"namespace\""));
357        assert!(json.contains("\"merged_into_memory_id\""));
358        assert!(json.contains("\"operation\""));
359        assert!(json.contains("\"created_at\""));
360        assert!(json.contains("\"created_at_iso\""));
361        assert!(json.contains("\"elapsed_ms\""));
362        assert!(json.contains("\"urls_persisted\""));
363        assert!(json.contains("\"relationships_truncated\":false"));
364    }
365
366    #[test]
367    fn recall_item_serializes_renamed_type_field() {
368        let item = RecallItem {
369            memory_id: 10,
370            name: "entidade".to_string(),
371            namespace: "ns".to_string(),
372            memory_type: "entity".to_string(),
373            description: "desc".to_string(),
374            snippet: "trecho".to_string(),
375            distance: 0.5,
376            source: "db".to_string(),
377            graph_depth: None,
378        };
379        let json = serde_json::to_string(&item).unwrap();
380        assert!(json.contains("\"type\""));
381        assert!(!json.contains("memory_type"));
382        // Field is omitted from JSON when None.
383        assert!(!json.contains("graph_depth"));
384    }
385
386    #[test]
387    fn recall_response_serializes_with_lists() {
388        let resp = RecallResponse {
389            query: "busca".to_string(),
390            k: 10,
391            direct_matches: vec![],
392            graph_matches: vec![],
393            results: vec![],
394            elapsed_ms: 42,
395        };
396        let json = serde_json::to_string(&resp).unwrap();
397        assert!(json.contains("direct_matches"));
398        assert!(json.contains("graph_matches"));
399        assert!(json.contains("\"k\":"));
400        assert!(json.contains("\"results\""));
401        assert!(json.contains("\"elapsed_ms\""));
402    }
403
404    #[test]
405    fn output_format_default_is_json() {
406        let fmt = OutputFormat::default();
407        assert!(matches!(fmt, OutputFormat::Json));
408    }
409
410    #[test]
411    fn output_format_variants_exist() {
412        let _text = OutputFormat::Text;
413        let _md = OutputFormat::Markdown;
414        let _json = OutputFormat::Json;
415    }
416
417    #[test]
418    fn recall_item_clone_produces_equal_value() {
419        let item = RecallItem {
420            memory_id: 99,
421            name: "clone".to_string(),
422            namespace: "ns".to_string(),
423            memory_type: "relation".to_string(),
424            description: "d".to_string(),
425            snippet: "s".to_string(),
426            distance: 0.1,
427            source: "src".to_string(),
428            graph_depth: Some(2),
429        };
430        let cloned = item.clone();
431        assert_eq!(cloned.memory_id, item.memory_id);
432        assert_eq!(cloned.name, item.name);
433        assert_eq!(cloned.graph_depth, Some(2));
434    }
435}