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.
32#[inline]
33pub fn emit_json<T: Serialize>(value: &T) -> Result<(), AppError> {
34 let json = serde_json::to_string_pretty(value)?;
35 let mut out = std::io::stdout().lock();
36 if let Err(e) = std::io::Write::write_all(&mut out, json.as_bytes())
37 .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
38 .and_then(|()| std::io::Write::flush(&mut out))
39 {
40 if e.kind() == std::io::ErrorKind::BrokenPipe {
41 return Ok(());
42 }
43 return Err(AppError::Io(e));
44 }
45 Ok(())
46}
47
48/// Serializes `value` as compact (single-line) JSON and writes it to stdout with a trailing newline.
49///
50/// Flushes stdout after writing. A `BrokenPipe` error is silenced.
51///
52/// # Errors
53/// Returns `Err` when serialization fails or when a non-`BrokenPipe` I/O error occurs.
54#[inline]
55pub fn emit_json_compact<T: Serialize>(value: &T) -> Result<(), AppError> {
56 let json = serde_json::to_string(value)?;
57 let mut out = std::io::stdout().lock();
58 if let Err(e) = std::io::Write::write_all(&mut out, json.as_bytes())
59 .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
60 .and_then(|()| std::io::Write::flush(&mut out))
61 {
62 if e.kind() == std::io::ErrorKind::BrokenPipe {
63 return Ok(());
64 }
65 return Err(AppError::Io(e));
66 }
67 Ok(())
68}
69
70/// Writes compact JSON to stdout, silently ignoring serialization and I/O errors.
71/// Designed for NDJSON streaming where partial output is acceptable.
72#[inline]
73pub fn emit_json_line<T: Serialize>(value: &T) {
74 if let Ok(json) = serde_json::to_string(value) {
75 let mut out = std::io::stdout().lock();
76 let _ = std::io::Write::write_all(&mut out, json.as_bytes());
77 let _ = std::io::Write::write_all(&mut out, b"\n");
78 let _ = std::io::Write::flush(&mut out);
79 }
80}
81
82/// Writes `msg` followed by a newline to stdout and flushes.
83///
84/// A `BrokenPipe` error is silenced gracefully.
85#[inline]
86pub fn emit_text(msg: &str) {
87 let mut out = std::io::stdout().lock();
88 let _ = std::io::Write::write_all(&mut out, msg.as_bytes())
89 .and_then(|()| std::io::Write::write_all(&mut out, b"\n"))
90 .and_then(|()| std::io::Write::flush(&mut out));
91}
92
93/// GAP-SG-50: writes `bytes` to stdout verbatim, with no trailing newline and
94/// no JSON envelope. Used by `read --format raw` so the pure memory body can be
95/// piped without a `jaq -r '.body'` round-trip. A `BrokenPipe` error is
96/// silenced gracefully.
97#[inline]
98pub fn emit_raw(bytes: &[u8]) {
99 let mut out = std::io::stdout().lock();
100 let _ =
101 std::io::Write::write_all(&mut out, bytes).and_then(|()| std::io::Write::flush(&mut out));
102}
103
104/// Logs `msg` as a structured `tracing::info!` event (does not write to stdout).
105/// v1.0.89: suppressed when stderr is not a terminal (pipe) to avoid
106/// polluting JSON pipelines when the user redirects stderr with `2>&1`.
107#[inline]
108pub fn emit_progress(msg: &str) {
109 if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
110 tracing::info!(target: "output", message = msg);
111 }
112}
113
114/// Emits a bilingual progress message honouring `--lang` or `SQLITE_GRAPHRAG_LANG`.
115/// v1.0.89: suppressed when stderr is not a terminal (pipe).
116pub fn emit_progress_i18n(en: &str, pt: &str) {
117 if !std::io::IsTerminal::is_terminal(&std::io::stderr()) {
118 return;
119 }
120 use crate::i18n::{current, Language};
121 match current() {
122 Language::English => tracing::info!(target: "output", message = en),
123 Language::Portuguese => tracing::info!(target: "output", message = pt),
124 }
125}
126
127/// Emits a JSON error envelope to stdout for machine consumers.
128///
129/// Ensures the stdout JSON contract is honoured even on error paths:
130/// `{"error": true, "code": <exit_code>, "message": "<localized_msg>"}`.
131/// A `BrokenPipe` error is silenced so piping to early-closing consumers
132/// does not surface a secondary error.
133#[cold]
134#[inline(never)]
135pub fn emit_error_json(code: i32, message: &str) {
136 #[derive(serde::Serialize)]
137 struct ErrorEnvelope<'a> {
138 error: bool,
139 code: i32,
140 message: &'a str,
141 }
142 let envelope = ErrorEnvelope {
143 error: true,
144 code,
145 message,
146 };
147 if emit_json(&envelope).is_err() {
148 use std::io::Write;
149 let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
150 let _ = writeln!(
151 std::io::stdout().lock(),
152 r#"{{"error":true,"code":{code},"message":"{escaped}"}}"#
153 );
154 }
155}
156
157/// GAP-SG-39: emits an actionable JSON error envelope to stdout, including an
158/// optional `suggestion` field carrying the remediation hint derived from the
159/// error variant. Ensures even silent write failures (e.g. `remember` rejecting
160/// a malformed name) surface both the cause and how to fix it on stdout:
161/// `{"error": true, "code": <code>, "message": "...", "suggestion": "..."}`.
162/// A `BrokenPipe` error is silenced; a hand-rolled fallback preserves the
163/// contract when serialization itself fails.
164#[cold]
165#[inline(never)]
166pub fn emit_error_json_with_suggestion(code: i32, message: &str, suggestion: Option<&str>) {
167 #[derive(serde::Serialize)]
168 struct ErrorEnvelope<'a> {
169 error: bool,
170 code: i32,
171 message: &'a str,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 suggestion: Option<&'a str>,
174 }
175 let envelope = ErrorEnvelope {
176 error: true,
177 code,
178 message,
179 suggestion,
180 };
181 if emit_json(&envelope).is_err() {
182 use std::io::Write;
183 let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
184 match suggestion {
185 Some(s) => {
186 let esc_s = s.replace('\\', "\\\\").replace('"', "\\\"");
187 let _ = writeln!(
188 std::io::stdout().lock(),
189 r#"{{"error":true,"code":{code},"message":"{escaped}","suggestion":"{esc_s}"}}"#
190 );
191 }
192 None => {
193 let _ = writeln!(
194 std::io::stdout().lock(),
195 r#"{{"error":true,"code":{code},"message":"{escaped}"}}"#
196 );
197 }
198 }
199 }
200}
201
202/// Emits a localised error message to stderr via the `tracing` subscriber.
203///
204/// ADR-0047 / BUG-12 v1.0.88: prior implementation also called `eprintln!`
205/// which produced a SECOND stderr line (Error:/Erro: prefix) for the same
206/// error, on top of the structured `tracing::error!` line. Operators and
207/// log parsers observed duplicated stderr lines.
208///
209/// The tracing subscriber is configured for stderr at `main.rs:115`, so a
210/// single `tracing::error!` call already produces the human-readable line.
211/// Callers that want a plain stderr line without tracing (e.g. one-shot
212/// scripts) should use `eprintln!` directly instead of this helper.
213///
214/// Centralises human-readable error output following Pattern 5 (`output.rs` is
215/// the SOLE I/O point of the CLI).
216#[cold]
217#[inline(never)]
218pub fn emit_error(localized_msg: &str) {
219 tracing::error!(target: "output", message = localized_msg);
220}
221
222/// Emits a bilingual error to stderr honouring `--lang` or `SQLITE_GRAPHRAG_LANG`.
223/// Usage: `output::emit_error_i18n("invariant violated", "invariante violado")`.
224#[cold]
225#[inline(never)]
226pub fn emit_error_i18n(en: &str, pt: &str) {
227 use crate::i18n::{current, Language};
228 let msg = match current() {
229 Language::English => en,
230 Language::Portuguese => pt,
231 };
232 emit_error(msg);
233}
234
235/// JSON payload emitted by the `remember` subcommand.
236///
237/// All fields are required by the JSON contract (see `docs/schemas/remember.schema.json`).
238/// `operation` is an alias of `action` for compatibility with clients using the old field name.
239///
240/// # Examples
241///
242/// ```
243/// use sqlite_graphrag::output::RememberResponse;
244///
245/// let resp = RememberResponse {
246/// memory_id: 1,
247/// name: "nota-inicial".into(),
248/// namespace: "global".into(),
249/// action: "created".into(),
250/// operation: "created".into(),
251/// version: 1,
252/// entities_persisted: 0,
253/// relationships_persisted: 0,
254/// relationships_truncated: false,
255/// chunks_created: 1,
256/// chunks_persisted: 0,
257/// urls_persisted: 0,
258/// extraction_method: None,
259/// merged_into_memory_id: None,
260/// warnings: vec![],
261/// created_at: 1_700_000_000,
262/// created_at_iso: "2023-11-14T22:13:20Z".into(),
263/// elapsed_ms: 42,
264/// name_was_normalized: false,
265/// original_name: None,
266/// backend_invoked: None,
267/// };
268///
269/// let json = serde_json::to_string(&resp).unwrap();
270/// assert!(json.contains("\"memory_id\":1"));
271/// assert!(json.contains("\"elapsed_ms\":42"));
272/// assert!(json.contains("\"merged_into_memory_id\":null"));
273/// assert!(json.contains("\"urls_persisted\":0"));
274/// assert!(json.contains("\"relationships_truncated\":false"));
275/// ```
276#[derive(Serialize)]
277pub struct RememberResponse {
278 pub memory_id: i64,
279 pub name: String,
280 pub namespace: String,
281 pub action: String,
282 /// Semantic alias of `action` for compatibility with the contract documented in SKILL.md.
283 pub operation: String,
284 pub version: i64,
285 pub entities_persisted: usize,
286 pub relationships_persisted: usize,
287 /// True when the relationship builder hit the cap before covering all entity pairs.
288 /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
289 pub relationships_truncated: bool,
290 /// Total number of chunks the body was split into BEFORE dedup.
291 ///
292 /// For single-chunk bodies this equals 1 even though no row is added to
293 /// the `memory_chunks` table — the memory row itself acts as the chunk.
294 /// Use `chunks_persisted` to know how many rows were actually written.
295 pub chunks_created: usize,
296 /// Number of chunks actually written to chunks/embeddings tables. Always <= chunks_created.
297 ///
298 /// Equal when no chunk had identical normalized text already in DB; less when dedup skipped
299 /// some. Equals zero for single-chunk bodies (the memory row is the chunk) and equals
300 /// `chunks_created` for multi-chunk bodies. Added in v1.0.23 to disambiguate from
301 /// `chunks_created` and reflect database state precisely.
302 pub chunks_persisted: usize,
303 /// Number of unique URLs inserted into `memory_urls` for this memory.
304 /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
305 #[serde(default)]
306 pub urls_persisted: usize,
307 /// Extraction method used: "gliner-{variant}+regex" or "regex-only". None when NER is not enabled.
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub extraction_method: Option<String>,
310 pub merged_into_memory_id: Option<i64>,
311 pub warnings: Vec<String>,
312 /// Timestamp Unix epoch seconds.
313 pub created_at: i64,
314 /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
315 pub created_at_iso: String,
316 /// Total execution time in milliseconds from handler start to serialisation.
317 pub elapsed_ms: u64,
318 /// True when the user-supplied `--name` differed from the persisted slug
319 /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
320 /// callers can detect normalization without parsing stderr WARN logs.
321 #[serde(default)]
322 pub name_was_normalized: bool,
323 /// Original user-supplied `--name` value before normalization.
324 /// Present only when `name_was_normalized == true`; omitted otherwise to
325 /// keep the common (already-kebab) payload small.
326 #[serde(skip_serializing_if = "Option::is_none")]
327 pub original_name: Option<String>,
328 /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
329 /// ran the passage embedding. `"claude" | "codex" | "none"`.
330 /// Absent on the wire when `None` (kept for happy-path envelope cleanliness).
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub backend_invoked: Option<&'static str>,
333}
334
335/// Individual item returned by the `recall` query.
336///
337/// The `memory_type` field is serialised as `"type"` in JSON to maintain
338/// compatibility with external clients — the Rust name uses `memory_type`
339/// to avoid conflict with the reserved keyword.
340///
341/// # Examples
342///
343/// ```
344/// use sqlite_graphrag::output::RecallItem;
345///
346/// let item = RecallItem {
347/// memory_id: 7,
348/// name: "nota-rust".into(),
349/// namespace: "global".into(),
350/// memory_type: "user".into(),
351/// description: "aprendizado de Rust".into(),
352/// snippet: "ownership e borrowing".into(),
353/// distance: 0.12,
354/// score: 0.88,
355/// source: "direct".into(),
356/// graph_depth: None,
357/// };
358///
359/// let json = serde_json::to_string(&item).unwrap();
360/// // Rust field `memory_type` appears as `"type"` in JSON.
361/// assert!(json.contains("\"type\":\"user\""));
362/// assert!(!json.contains("memory_type"));
363/// assert!(json.contains("\"distance\":0.12"));
364/// ```
365#[derive(Serialize, Clone)]
366pub struct RecallItem {
367 pub memory_id: i64,
368 pub name: String,
369 pub namespace: String,
370 #[serde(rename = "type")]
371 pub memory_type: String,
372 pub description: String,
373 pub snippet: String,
374 pub distance: f32,
375 /// Cosine similarity in `[0.0, 1.0]` derived as `1.0 - distance` and clamped
376 /// to that interval. Always populated to satisfy the documented contract
377 /// (M-A5 in v1.0.40); higher means more similar. For graph hits the value
378 /// reflects the hop-derived distance proxy and should be interpreted
379 /// alongside `graph_depth` rather than as a true cosine score.
380 pub score: f32,
381 pub source: String,
382 /// Number of graph hops between this match and the seed memories.
383 ///
384 /// Set to `None` for direct vector matches (where `distance` is meaningful)
385 /// and to `Some(N)` for traversal results, with `N=0` when the depth could
386 /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
387 /// from the `distance: 0.0` placeholder previously used for graph entries.
388 /// Field is omitted from JSON output when `None`.
389 #[serde(skip_serializing_if = "Option::is_none")]
390 pub graph_depth: Option<u32>,
391}
392
393impl RecallItem {
394 /// Computes the similarity score from a vector distance, clamped to
395 /// `[0.0, 1.0]`. Cosine distance returned by sqlite-vec lives in `[0, 2]`
396 /// in theory but the embedder produces unit-norm vectors so the practical
397 /// range is `[0, 1]`. Centralized so every constructor keeps the contract.
398 #[inline]
399 pub fn score_from_distance(distance: f32) -> f32 {
400 let raw = 1.0 - distance;
401 if raw.is_nan() {
402 0.0
403 } else {
404 raw.clamp(0.0, 1.0)
405 }
406 }
407}
408
409/// Full response envelope returned by the `recall` subcommand.
410///
411/// Contains both direct vector matches and graph-traversal matches, plus the
412/// aggregated `results` list that merges both for callers that do not need
413/// to distinguish the source.
414#[derive(Serialize)]
415pub struct RecallResponse {
416 pub query: String,
417 pub k: usize,
418 pub direct_matches: Vec<RecallItem>,
419 pub graph_matches: Vec<RecallItem>,
420 /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
421 pub results: Vec<RecallItem>,
422 /// Total execution time in milliseconds from handler start to serialisation.
423 pub elapsed_ms: u64,
424 /// G58 (v1.0.80): `true` when the live query embedding failed and the
425 /// handler fell back to FTS5 BM25 + LIKE prefix. Symmetric to
426 /// `fts_degraded` in `hybrid-search`. Absent on the wire when false.
427 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
428 pub vec_degraded: bool,
429 /// G58 (v1.0.80): human-readable description of the embedding failure
430 /// that triggered the fallback. Absent on the wire when `vec_degraded`
431 /// is false or the failure had no message.
432 #[serde(skip_serializing_if = "std::option::Option::is_none")]
433 pub vec_error: Option<String>,
434 /// G58 (v1.0.80): advisory warning echoed for callers that branch on
435 /// top-level status. Distinguishes a FTS5-only fallback from a clean
436 /// hybrid response so downstream pipelines can lower their confidence.
437 #[serde(skip_serializing_if = "std::option::Option::is_none")]
438 pub warning: Option<String>,
439 /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
440 /// ran the live embedding. `"claude" | "codex" | "none"`. Absent
441 /// on the wire when `None` (kept for happy-path envelope cleanliness).
442 #[serde(skip_serializing_if = "std::option::Option::is_none")]
443 pub backend_invoked: Option<&'static str>,
444 /// v1.0.84 (ADR-0042): reason code discriminating the degradation
445 /// (`"embedding_failed" | "cancelled" | "timeout"`). Absent when
446 /// `vec_degraded` is false.
447 #[serde(skip_serializing_if = "std::option::Option::is_none")]
448 pub vec_degraded_reason: Option<String>,
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use serde::Serialize;
455
456 #[derive(Serialize)]
457 struct Dummy {
458 val: u32,
459 }
460
461 // Non-serializable type to force a JSON serialization error
462 struct NotSerializable;
463 impl Serialize for NotSerializable {
464 fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
465 Err(serde::ser::Error::custom(
466 "intentional serialization failure",
467 ))
468 }
469 }
470
471 #[test]
472 fn emit_json_returns_ok_for_valid_value() {
473 let v = Dummy { val: 42 };
474 assert!(emit_json(&v).is_ok());
475 }
476
477 #[test]
478 fn emit_json_returns_err_for_non_serializable_value() {
479 let v = NotSerializable;
480 assert!(emit_json(&v).is_err());
481 }
482
483 #[test]
484 fn emit_json_compact_returns_ok_for_valid_value() {
485 let v = Dummy { val: 7 };
486 assert!(emit_json_compact(&v).is_ok());
487 }
488
489 #[test]
490 fn emit_json_compact_returns_err_for_non_serializable_value() {
491 let v = NotSerializable;
492 assert!(emit_json_compact(&v).is_err());
493 }
494
495 #[test]
496 fn emit_text_does_not_panic() {
497 emit_text("mensagem de teste");
498 }
499
500 #[test]
501 fn emit_progress_does_not_panic() {
502 emit_progress("progresso de teste");
503 }
504
505 #[test]
506 fn remember_response_serializes_correctly() {
507 let r = RememberResponse {
508 memory_id: 1,
509 name: "teste".to_string(),
510 namespace: "ns".to_string(),
511 action: "created".to_string(),
512 operation: "created".to_string(),
513 version: 1,
514 entities_persisted: 2,
515 relationships_persisted: 3,
516 relationships_truncated: false,
517 chunks_created: 4,
518 chunks_persisted: 4,
519 urls_persisted: 2,
520 extraction_method: None,
521 merged_into_memory_id: None,
522 warnings: vec!["aviso".to_string()],
523 created_at: 1776569715,
524 created_at_iso: "2026-04-19T03:34:15Z".to_string(),
525 elapsed_ms: 123,
526 name_was_normalized: false,
527 original_name: None,
528 backend_invoked: None,
529 };
530 let json = serde_json::to_string(&r).unwrap();
531 assert!(json.contains("memory_id"));
532 assert!(json.contains("aviso"));
533 assert!(json.contains("\"namespace\""));
534 assert!(json.contains("\"merged_into_memory_id\""));
535 assert!(json.contains("\"operation\""));
536 assert!(json.contains("\"created_at\""));
537 assert!(json.contains("\"created_at_iso\""));
538 assert!(json.contains("\"elapsed_ms\""));
539 assert!(json.contains("\"urls_persisted\""));
540 assert!(json.contains("\"relationships_truncated\":false"));
541 }
542
543 #[test]
544 fn recall_item_serializes_renamed_type_field() {
545 let item = RecallItem {
546 memory_id: 10,
547 name: "entidade".to_string(),
548 namespace: "ns".to_string(),
549 memory_type: "entity".to_string(),
550 description: "desc".to_string(),
551 snippet: "trecho".to_string(),
552 distance: 0.5,
553 score: RecallItem::score_from_distance(0.5),
554 source: "db".to_string(),
555 graph_depth: None,
556 };
557 let json = serde_json::to_string(&item).unwrap();
558 assert!(json.contains("\"type\""));
559 assert!(!json.contains("memory_type"));
560 // Field is omitted from JSON when None.
561 assert!(!json.contains("graph_depth"));
562 assert!(json.contains("\"score\":0.5"));
563 }
564
565 #[test]
566 fn recall_response_serializes_with_lists() {
567 let resp = RecallResponse {
568 query: "busca".to_string(),
569 k: 10,
570 direct_matches: vec![],
571 graph_matches: vec![],
572 results: vec![],
573 elapsed_ms: 42,
574 vec_degraded: false,
575 vec_error: None,
576 warning: None,
577 backend_invoked: None,
578 vec_degraded_reason: None,
579 };
580 let json = serde_json::to_string(&resp).unwrap();
581 assert!(json.contains("direct_matches"));
582 assert!(json.contains("graph_matches"));
583 assert!(json.contains("\"k\":"));
584 assert!(json.contains("\"results\""));
585 assert!(json.contains("\"elapsed_ms\""));
586 // G58: clean response must NOT carry the degradation fields.
587 assert!(!json.contains("vec_degraded"));
588 assert!(!json.contains("vec_error"));
589 assert!(!json.contains("warning"));
590 }
591
592 #[test]
593 fn recall_response_serializes_vec_degraded_when_fallback_fired() {
594 let resp = RecallResponse {
595 query: "busca".to_string(),
596 k: 10,
597 direct_matches: vec![],
598 graph_matches: vec![],
599 results: vec![],
600 elapsed_ms: 42,
601 vec_degraded: true,
602 vec_error: Some("embedding cancelled by external signal".to_string()),
603 warning: Some("live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)".to_string()),
604 backend_invoked: None,
605 vec_degraded_reason: Some("embedding cancelled by external signal".to_string()),
606 };
607 let json = serde_json::to_string(&resp).unwrap();
608 assert!(json.contains("\"vec_degraded\":true"));
609 assert!(json.contains("\"vec_error\":\"embedding cancelled by external signal\""));
610 assert!(json.contains("\"warning\":\"live query embedding unavailable"));
611 }
612
613 #[test]
614 fn error_envelope_serializes_correctly() {
615 #[derive(serde::Serialize)]
616 struct ErrorEnvelope<'a> {
617 error: bool,
618 code: i32,
619 message: &'a str,
620 }
621 let envelope = ErrorEnvelope {
622 error: true,
623 code: 10,
624 message: "database disk image is malformed",
625 };
626 let json = serde_json::to_value(&envelope).unwrap();
627 assert_eq!(json["error"], true);
628 assert_eq!(json["code"], 10);
629 assert_eq!(json["message"], "database disk image is malformed");
630 }
631
632 #[test]
633 fn output_format_default_is_json() {
634 let fmt = OutputFormat::default();
635 assert!(matches!(fmt, OutputFormat::Json));
636 }
637
638 #[test]
639 fn output_format_variants_exist() {
640 let _text = OutputFormat::Text;
641 let _md = OutputFormat::Markdown;
642 let _json = OutputFormat::Json;
643 }
644
645 #[test]
646 fn recall_item_clone_produces_equal_value() {
647 let item = RecallItem {
648 memory_id: 99,
649 name: "clone".to_string(),
650 namespace: "ns".to_string(),
651 memory_type: "relation".to_string(),
652 description: "d".to_string(),
653 snippet: "s".to_string(),
654 distance: 0.1,
655 score: RecallItem::score_from_distance(0.1),
656 source: "src".to_string(),
657 graph_depth: Some(2),
658 };
659 let cloned = item.clone();
660 assert_eq!(cloned.memory_id, item.memory_id);
661 assert_eq!(cloned.name, item.name);
662 assert_eq!(cloned.graph_depth, Some(2));
663 }
664}