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}