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.
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///     entities_created: vec![],
268///     enrich_recommended: vec![],
269/// };
270///
271/// let json = serde_json::to_string(&resp).unwrap();
272/// assert!(json.contains("\"memory_id\":1"));
273/// assert!(json.contains("\"elapsed_ms\":42"));
274/// assert!(json.contains("\"merged_into_memory_id\":null"));
275/// assert!(json.contains("\"urls_persisted\":0"));
276/// assert!(json.contains("\"relationships_truncated\":false"));
277/// ```
278#[derive(Serialize)]
279pub struct RememberResponse {
280    pub memory_id: i64,
281    pub name: String,
282    pub namespace: String,
283    pub action: String,
284    /// Semantic alias of `action` for compatibility with the contract documented in SKILL.md.
285    pub operation: String,
286    pub version: i64,
287    pub entities_persisted: usize,
288    pub relationships_persisted: usize,
289    /// True when the relationship builder hit the cap before covering all entity pairs.
290    /// Callers can use this to decide whether to increase GRAPHRAG_MAX_RELATIONSHIPS_PER_MEMORY.
291    pub relationships_truncated: bool,
292    /// Total number of chunks the body was split into BEFORE dedup.
293    ///
294    /// For single-chunk bodies this equals 1 even though no row is added to
295    /// the `memory_chunks` table — the memory row itself acts as the chunk.
296    /// Use `chunks_persisted` to know how many rows were actually written.
297    pub chunks_created: usize,
298    /// Number of chunks actually written to chunks/embeddings tables. Always <= chunks_created.
299    ///
300    /// Equal when no chunk had identical normalized text already in DB; less when dedup skipped
301    /// some. Equals zero for single-chunk bodies (the memory row is the chunk) and equals
302    /// `chunks_created` for multi-chunk bodies. Added in v1.0.23 to disambiguate from
303    /// `chunks_created` and reflect database state precisely.
304    pub chunks_persisted: usize,
305    /// Number of unique URLs inserted into `memory_urls` for this memory.
306    /// Added in v1.0.24 — split URLs out of the entity graph (P0-2 fix).
307    #[serde(default)]
308    pub urls_persisted: usize,
309    /// 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.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub extraction_method: Option<String>,
312    pub merged_into_memory_id: Option<i64>,
313    pub warnings: Vec<String>,
314    /// Timestamp Unix epoch seconds.
315    pub created_at: i64,
316    /// RFC 3339 UTC timestamp string parallel to `created_at` for ISO 8601 parsers.
317    pub created_at_iso: String,
318    /// Total execution time in milliseconds from handler start to serialisation.
319    pub elapsed_ms: u64,
320    /// True when the user-supplied `--name` differed from the persisted slug
321    /// (i.e. kebab-case normalization changed the value). Added in v1.0.32 so
322    /// callers can detect normalization without parsing stderr WARN logs.
323    #[serde(default)]
324    pub name_was_normalized: bool,
325    /// Original user-supplied `--name` value before normalization.
326    /// Present only when `name_was_normalized == true`; omitted otherwise to
327    /// keep the common (already-kebab) payload small.
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub original_name: Option<String>,
330    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
331    /// ran the passage embedding. `"claude" | "codex" | "none"`.
332    /// Absent on the wire when `None` (kept for happy-path envelope cleanliness).
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub backend_invoked: Option<&'static str>,
335    /// GAP-CLI-PRIO-01: entity names written/linked in this remember call
336    /// (hot set for priority entity-descriptions).
337    #[serde(default, skip_serializing_if = "Vec::is_empty")]
338    pub entities_created: Vec<String>,
339    /// GAP-CLI-PRIO-01 / G-T-ONESHOT-02: enrich operations the operator
340    /// should run next (e.g. `["entity-descriptions"]` after curated graph).
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub enrich_recommended: Vec<String>,
343}
344
345/// Individual item returned by the `recall` query.
346///
347/// The `memory_type` field is serialised as `"type"` in JSON to maintain
348/// compatibility with external clients — the Rust name uses `memory_type`
349/// to avoid conflict with the reserved keyword.
350///
351/// # Examples
352///
353/// ```
354/// use sqlite_graphrag::output::RecallItem;
355///
356/// let item = RecallItem {
357///     memory_id: 7,
358///     name: "nota-rust".into(),
359///     namespace: "global".into(),
360///     memory_type: "user".into(),
361///     description: "aprendizado de Rust".into(),
362///     snippet: "ownership e borrowing".into(),
363///     distance: 0.12,
364///     score: 0.88,
365///     source: "direct".into(),
366///     graph_depth: None,
367/// };
368///
369/// let json = serde_json::to_string(&item).unwrap();
370/// // Rust field `memory_type` appears as `"type"` in JSON.
371/// assert!(json.contains("\"type\":\"user\""));
372/// assert!(!json.contains("memory_type"));
373/// assert!(json.contains("\"distance\":0.12"));
374/// ```
375#[derive(Serialize, Clone)]
376pub struct RecallItem {
377    pub memory_id: i64,
378    pub name: String,
379    pub namespace: String,
380    #[serde(rename = "type")]
381    pub memory_type: String,
382    pub description: String,
383    pub snippet: String,
384    pub distance: f32,
385    /// Cosine similarity in `[0.0, 1.0]` derived as `1.0 - distance` and clamped
386    /// to that interval. Always populated to satisfy the documented contract
387    /// (M-A5 in v1.0.40); higher means more similar. For graph hits the value
388    /// reflects the hop-derived distance proxy and should be interpreted
389    /// alongside `graph_depth` rather than as a true cosine score.
390    pub score: f32,
391    pub source: String,
392    /// Number of graph hops between this match and the seed memories.
393    ///
394    /// Set to `None` for direct vector matches (where `distance` is meaningful)
395    /// and to `Some(N)` for traversal results, with `N=0` when the depth could
396    /// not be tracked precisely. Added in v1.0.23 to disambiguate graph results
397    /// from the `distance: 0.0` placeholder previously used for graph entries.
398    /// Field is omitted from JSON output when `None`.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub graph_depth: Option<u32>,
401}
402
403impl RecallItem {
404    /// Computes the similarity score from a vector distance, clamped to
405    /// `[0.0, 1.0]`. Cosine distance returned by sqlite-vec lives in `[0, 2]`
406    /// in theory but the embedder produces unit-norm vectors so the practical
407    /// range is `[0, 1]`. Centralized so every constructor keeps the contract.
408    #[inline]
409    pub fn score_from_distance(distance: f32) -> f32 {
410        let raw = 1.0 - distance;
411        if raw.is_nan() {
412            0.0
413        } else {
414            raw.clamp(0.0, 1.0)
415        }
416    }
417}
418
419/// Full response envelope returned by the `recall` subcommand.
420///
421/// Contains both direct vector matches and graph-traversal matches, plus the
422/// aggregated `results` list that merges both for callers that do not need
423/// to distinguish the source.
424#[derive(Serialize)]
425pub struct RecallResponse {
426    pub query: String,
427    pub k: usize,
428    pub direct_matches: Vec<RecallItem>,
429    pub graph_matches: Vec<RecallItem>,
430    /// Aggregated alias of `direct_matches` + `graph_matches` for the contract documented in SKILL.md.
431    pub results: Vec<RecallItem>,
432    /// Total execution time in milliseconds from handler start to serialisation.
433    pub elapsed_ms: u64,
434    /// G58 (v1.0.80): `true` when the live query embedding failed and the
435    /// handler fell back to FTS5 BM25 + LIKE prefix. Symmetric to
436    /// `fts_degraded` in `hybrid-search`. Absent on the wire when false.
437    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
438    pub vec_degraded: bool,
439    /// G58 (v1.0.80): human-readable description of the embedding failure
440    /// that triggered the fallback. Absent on the wire when `vec_degraded`
441    /// is false or the failure had no message.
442    #[serde(skip_serializing_if = "std::option::Option::is_none")]
443    pub vec_error: Option<String>,
444    /// G58 (v1.0.80): advisory warning echoed for callers that branch on
445    /// top-level status. Distinguishes a FTS5-only fallback from a clean
446    /// hybrid response so downstream pipelines can lower their confidence.
447    #[serde(skip_serializing_if = "std::option::Option::is_none")]
448    pub warning: Option<String>,
449    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
450    /// ran the live embedding. `"claude" | "codex" | "none"`. Absent
451    /// on the wire when `None` (kept for happy-path envelope cleanliness).
452    #[serde(skip_serializing_if = "std::option::Option::is_none")]
453    pub backend_invoked: Option<&'static str>,
454    /// v1.0.84 (ADR-0042): reason code discriminating the degradation
455    /// (`"embedding_failed" | "cancelled" | "timeout"`). Absent when
456    /// `vec_degraded` is false.
457    #[serde(skip_serializing_if = "std::option::Option::is_none")]
458    pub vec_degraded_reason: Option<String>,
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use serde::Serialize;
465
466    #[derive(Serialize)]
467    struct Dummy {
468        val: u32,
469    }
470
471    // Non-serializable type to force a JSON serialization error
472    struct NotSerializable;
473    impl Serialize for NotSerializable {
474        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
475            Err(serde::ser::Error::custom(
476                "intentional serialization failure",
477            ))
478        }
479    }
480
481    #[test]
482    fn emit_json_returns_ok_for_valid_value() {
483        let v = Dummy { val: 42 };
484        assert!(emit_json(&v).is_ok());
485    }
486
487    #[test]
488    fn emit_json_returns_err_for_non_serializable_value() {
489        let v = NotSerializable;
490        assert!(emit_json(&v).is_err());
491    }
492
493    #[test]
494    fn emit_json_compact_returns_ok_for_valid_value() {
495        let v = Dummy { val: 7 };
496        assert!(emit_json_compact(&v).is_ok());
497    }
498
499    #[test]
500    fn emit_json_compact_returns_err_for_non_serializable_value() {
501        let v = NotSerializable;
502        assert!(emit_json_compact(&v).is_err());
503    }
504
505    #[test]
506    fn emit_text_does_not_panic() {
507        emit_text("mensagem de teste");
508    }
509
510    #[test]
511    fn emit_progress_does_not_panic() {
512        emit_progress("progresso de teste");
513    }
514
515    #[test]
516    fn remember_response_serializes_correctly() {
517        let r = RememberResponse {
518            memory_id: 1,
519            name: "teste".to_string(),
520            namespace: "ns".to_string(),
521            action: "created".to_string(),
522            operation: "created".to_string(),
523            version: 1,
524            entities_persisted: 2,
525            relationships_persisted: 3,
526            relationships_truncated: false,
527            chunks_created: 4,
528            chunks_persisted: 4,
529            urls_persisted: 2,
530            extraction_method: None,
531            merged_into_memory_id: None,
532            warnings: vec!["aviso".to_string()],
533            created_at: 1776569715,
534            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
535            elapsed_ms: 123,
536            name_was_normalized: false,
537            original_name: None,
538            backend_invoked: None,
539            entities_created: vec![],
540            enrich_recommended: vec![],
541        };
542        let json = serde_json::to_string(&r).unwrap();
543        assert!(json.contains("memory_id"));
544        assert!(json.contains("aviso"));
545        assert!(json.contains("\"namespace\""));
546        assert!(json.contains("\"merged_into_memory_id\""));
547        assert!(json.contains("\"operation\""));
548        assert!(json.contains("\"created_at\""));
549        assert!(json.contains("\"created_at_iso\""));
550        assert!(json.contains("\"elapsed_ms\""));
551        assert!(json.contains("\"urls_persisted\""));
552        assert!(json.contains("\"relationships_truncated\":false"));
553    }
554
555    #[test]
556    fn recall_item_serializes_renamed_type_field() {
557        let item = RecallItem {
558            memory_id: 10,
559            name: "entidade".to_string(),
560            namespace: "ns".to_string(),
561            memory_type: "entity".to_string(),
562            description: "desc".to_string(),
563            snippet: "trecho".to_string(),
564            distance: 0.5,
565            score: RecallItem::score_from_distance(0.5),
566            source: "db".to_string(),
567            graph_depth: None,
568        };
569        let json = serde_json::to_string(&item).unwrap();
570        assert!(json.contains("\"type\""));
571        assert!(!json.contains("memory_type"));
572        // Field is omitted from JSON when None.
573        assert!(!json.contains("graph_depth"));
574        assert!(json.contains("\"score\":0.5"));
575    }
576
577    #[test]
578    fn recall_response_serializes_with_lists() {
579        let resp = RecallResponse {
580            query: "busca".to_string(),
581            k: 10,
582            direct_matches: vec![],
583            graph_matches: vec![],
584            results: vec![],
585            elapsed_ms: 42,
586            vec_degraded: false,
587            vec_error: None,
588            warning: None,
589            backend_invoked: None,
590            vec_degraded_reason: None,
591        };
592        let json = serde_json::to_string(&resp).unwrap();
593        assert!(json.contains("direct_matches"));
594        assert!(json.contains("graph_matches"));
595        assert!(json.contains("\"k\":"));
596        assert!(json.contains("\"results\""));
597        assert!(json.contains("\"elapsed_ms\""));
598        // G58: clean response must NOT carry the degradation fields.
599        assert!(!json.contains("vec_degraded"));
600        assert!(!json.contains("vec_error"));
601        assert!(!json.contains("warning"));
602    }
603
604    #[test]
605    fn recall_response_serializes_vec_degraded_when_fallback_fired() {
606        let resp = RecallResponse {
607            query: "busca".to_string(),
608            k: 10,
609            direct_matches: vec![],
610            graph_matches: vec![],
611            results: vec![],
612            elapsed_ms: 42,
613            vec_degraded: true,
614            vec_error: Some("embedding cancelled by external signal".to_string()),
615            warning: Some("live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)".to_string()),
616            backend_invoked: None,
617            vec_degraded_reason: Some("embedding cancelled by external signal".to_string()),
618        };
619        let json = serde_json::to_string(&resp).unwrap();
620        assert!(json.contains("\"vec_degraded\":true"));
621        assert!(json.contains("\"vec_error\":\"embedding cancelled by external signal\""));
622        assert!(json.contains("\"warning\":\"live query embedding unavailable"));
623    }
624
625    #[test]
626    fn error_envelope_serializes_correctly() {
627        #[derive(serde::Serialize)]
628        struct ErrorEnvelope<'a> {
629            error: bool,
630            code: i32,
631            message: &'a str,
632        }
633        let envelope = ErrorEnvelope {
634            error: true,
635            code: 10,
636            message: "database disk image is malformed",
637        };
638        let json = serde_json::to_value(&envelope).unwrap();
639        assert_eq!(json["error"], true);
640        assert_eq!(json["code"], 10);
641        assert_eq!(json["message"], "database disk image is malformed");
642    }
643
644    #[test]
645    fn output_format_default_is_json() {
646        let fmt = OutputFormat::default();
647        assert!(matches!(fmt, OutputFormat::Json));
648    }
649
650    #[test]
651    fn output_format_variants_exist() {
652        let _text = OutputFormat::Text;
653        let _md = OutputFormat::Markdown;
654        let _json = OutputFormat::Json;
655    }
656
657    #[test]
658    fn recall_item_clone_produces_equal_value() {
659        let item = RecallItem {
660            memory_id: 99,
661            name: "clone".to_string(),
662            namespace: "ns".to_string(),
663            memory_type: "relation".to_string(),
664            description: "d".to_string(),
665            snippet: "s".to_string(),
666            distance: 0.1,
667            score: RecallItem::score_from_distance(0.1),
668            source: "src".to_string(),
669            graph_depth: Some(2),
670        };
671        let cloned = item.clone();
672        assert_eq!(cloned.memory_id, item.memory_id);
673        assert_eq!(cloned.name, item.name);
674        assert_eq!(cloned.graph_depth, Some(2));
675    }
676}