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    /// 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}