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 chunks produced by the hierarchical splitter for this body.
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 rows actually inserted into the `memory_chunks` table.
174 ///
175 /// Equals zero for single-chunk bodies (the memory row is the chunk) and
176 /// equals `chunks_created` for multi-chunk bodies. Added in v1.0.23 to
177 /// disambiguate from `chunks_created` and reflect database state precisely.
178 pub chunks_persisted: usize,
179 /// Number of unique URLs inserted into `memory_urls` for this memory.
180 /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
181 #[serde(default)]
182 pub urls_persisted: usize,
183 /// Extraction method used: "bert+regex" or "regex-only". None when skip-extraction.
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub extraction_method: Option<String>,
186 pub merged_into_memory_id: Option<i64>,
187 pub warnings: Vec<String>,
188 /// Timestamp Unix epoch seconds.
189 pub created_at: i64,
190 /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
191 pub created_at_iso: String,
192 /// Total execution time in milliseconds from handler start to serialisation.
193 pub elapsed_ms: u64,
194 /// True when the user-supplied `--name` differed from the persisted slug
195 /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
196 /// callers can detect normalization without parsing stderr WARN logs.
197 #[serde(default)]
198 pub name_was_normalized: bool,
199 /// Original user-supplied `--name` value before normalization.
200 /// Present only when `name_was_normalized == true`; omitted otherwise to
201 /// keep the common (already-kebab) payload small.
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub original_name: Option<String>,
204}
205
206/// Individual item returned by the `recall` query.
207///
208/// The `memory_type` field is serialised as `"type"` in JSON to maintain
209/// compatibility with external clients — the Rust name uses `memory_type`
210/// to avoid conflict with the reserved keyword.
211///
212/// # Examples
213///
214/// ```
215/// use sqlite_graphrag::output::RecallItem;
216///
217/// let item = RecallItem {
218/// memory_id: 7,
219/// name: "nota-rust".into(),
220/// namespace: "global".into(),
221/// memory_type: "user".into(),
222/// description: "aprendizado de Rust".into(),
223/// snippet: "ownership e borrowing".into(),
224/// distance: 0.12,
225/// source: "direct".into(),
226/// graph_depth: None,
227/// };
228///
229/// let json = serde_json::to_string(&item).unwrap();
230/// // Rust field `memory_type` appears as `"type"` in JSON.
231/// assert!(json.contains("\"type\":\"user\""));
232/// assert!(!json.contains("memory_type"));
233/// assert!(json.contains("\"distance\":0.12"));
234/// ```
235#[derive(Serialize, Clone)]
236pub struct RecallItem {
237 pub memory_id: i64,
238 pub name: String,
239 pub namespace: String,
240 #[serde(rename = "type")]
241 pub memory_type: String,
242 pub description: String,
243 pub snippet: String,
244 pub distance: f32,
245 pub source: String,
246 /// Number of graph hops between this match and the seed memories.
247 ///
248 /// Set to `None` for direct vector matches (where `distance` is meaningful)
249 /// and to `Some(N)` for traversal results, with `N=0` when the depth could
250 /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
251 /// from the `distance: 0.0` placeholder previously used for graph entries.
252 /// Field is omitted from JSON output when `None`.
253 #[serde(skip_serializing_if = "Option::is_none")]
254 pub graph_depth: Option<u32>,
255}
256
257/// Full response envelope returned by the `recall` subcommand.
258///
259/// Contains both direct vector matches and graph-traversal matches, plus the
260/// aggregated `results` list that merges both for callers that do not need
261/// to distinguish the source.
262#[derive(Serialize)]
263pub struct RecallResponse {
264 pub query: String,
265 pub k: usize,
266 pub direct_matches: Vec<RecallItem>,
267 pub graph_matches: Vec<RecallItem>,
268 /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
269 pub results: Vec<RecallItem>,
270 /// Total execution time in milliseconds from handler start to serialisation.
271 pub elapsed_ms: u64,
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use serde::Serialize;
278
279 #[derive(Serialize)]
280 struct Dummy {
281 val: u32,
282 }
283
284 // Non-serializable type to force a JSON serialization error
285 struct NotSerializable;
286 impl Serialize for NotSerializable {
287 fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
288 Err(serde::ser::Error::custom(
289 "intentional serialization failure",
290 ))
291 }
292 }
293
294 #[test]
295 fn emit_json_returns_ok_for_valid_value() {
296 let v = Dummy { val: 42 };
297 assert!(emit_json(&v).is_ok());
298 }
299
300 #[test]
301 fn emit_json_returns_err_for_non_serializable_value() {
302 let v = NotSerializable;
303 assert!(emit_json(&v).is_err());
304 }
305
306 #[test]
307 fn emit_json_compact_returns_ok_for_valid_value() {
308 let v = Dummy { val: 7 };
309 assert!(emit_json_compact(&v).is_ok());
310 }
311
312 #[test]
313 fn emit_json_compact_returns_err_for_non_serializable_value() {
314 let v = NotSerializable;
315 assert!(emit_json_compact(&v).is_err());
316 }
317
318 #[test]
319 fn emit_text_does_not_panic() {
320 emit_text("mensagem de teste");
321 }
322
323 #[test]
324 fn emit_progress_does_not_panic() {
325 emit_progress("progresso de teste");
326 }
327
328 #[test]
329 fn remember_response_serializes_correctly() {
330 let r = RememberResponse {
331 memory_id: 1,
332 name: "teste".to_string(),
333 namespace: "ns".to_string(),
334 action: "created".to_string(),
335 operation: "created".to_string(),
336 version: 1,
337 entities_persisted: 2,
338 relationships_persisted: 3,
339 relationships_truncated: false,
340 chunks_created: 4,
341 chunks_persisted: 4,
342 urls_persisted: 2,
343 extraction_method: None,
344 merged_into_memory_id: None,
345 warnings: vec!["aviso".to_string()],
346 created_at: 1776569715,
347 created_at_iso: "2026-04-19T03:34:15Z".to_string(),
348 elapsed_ms: 123,
349 name_was_normalized: false,
350 original_name: None,
351 };
352 let json = serde_json::to_string(&r).unwrap();
353 assert!(json.contains("memory_id"));
354 assert!(json.contains("aviso"));
355 assert!(json.contains("\"namespace\""));
356 assert!(json.contains("\"merged_into_memory_id\""));
357 assert!(json.contains("\"operation\""));
358 assert!(json.contains("\"created_at\""));
359 assert!(json.contains("\"created_at_iso\""));
360 assert!(json.contains("\"elapsed_ms\""));
361 assert!(json.contains("\"urls_persisted\""));
362 assert!(json.contains("\"relationships_truncated\":false"));
363 }
364
365 #[test]
366 fn recall_item_serializes_renamed_type_field() {
367 let item = RecallItem {
368 memory_id: 10,
369 name: "entidade".to_string(),
370 namespace: "ns".to_string(),
371 memory_type: "entity".to_string(),
372 description: "desc".to_string(),
373 snippet: "trecho".to_string(),
374 distance: 0.5,
375 source: "db".to_string(),
376 graph_depth: None,
377 };
378 let json = serde_json::to_string(&item).unwrap();
379 assert!(json.contains("\"type\""));
380 assert!(!json.contains("memory_type"));
381 // Field is omitted from JSON when None.
382 assert!(!json.contains("graph_depth"));
383 }
384
385 #[test]
386 fn recall_response_serializes_with_lists() {
387 let resp = RecallResponse {
388 query: "busca".to_string(),
389 k: 10,
390 direct_matches: vec![],
391 graph_matches: vec![],
392 results: vec![],
393 elapsed_ms: 42,
394 };
395 let json = serde_json::to_string(&resp).unwrap();
396 assert!(json.contains("direct_matches"));
397 assert!(json.contains("graph_matches"));
398 assert!(json.contains("\"k\":"));
399 assert!(json.contains("\"results\""));
400 assert!(json.contains("\"elapsed_ms\""));
401 }
402
403 #[test]
404 fn output_format_default_is_json() {
405 let fmt = OutputFormat::default();
406 assert!(matches!(fmt, OutputFormat::Json));
407 }
408
409 #[test]
410 fn output_format_variants_exist() {
411 let _text = OutputFormat::Text;
412 let _md = OutputFormat::Markdown;
413 let _json = OutputFormat::Json;
414 }
415
416 #[test]
417 fn recall_item_clone_produces_equal_value() {
418 let item = RecallItem {
419 memory_id: 99,
420 name: "clone".to_string(),
421 namespace: "ns".to_string(),
422 memory_type: "relation".to_string(),
423 description: "d".to_string(),
424 snippet: "s".to_string(),
425 distance: 0.1,
426 source: "src".to_string(),
427 graph_depth: Some(2),
428 };
429 let cloned = item.clone();
430 assert_eq!(cloned.memory_id, item.memory_id);
431 assert_eq!(cloned.name, item.name);
432 assert_eq!(cloned.graph_depth, Some(2));
433 }
434}