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