sqlite-graphrag 1.0.7

Local GraphRAG memory for LLMs in a single SQLite file
Documentation
use crate::errors::AppError;
use serde::Serialize;

#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
pub enum OutputFormat {
    #[default]
    Json,
    Text,
    Markdown,
}

#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)]
pub enum JsonOutputFormat {
    #[default]
    Json,
}

pub fn emit_json<T: Serialize>(value: &T) -> Result<(), AppError> {
    let json = serde_json::to_string_pretty(value)?;
    println!("{json}");
    Ok(())
}

pub fn emit_json_compact<T: Serialize>(value: &T) -> Result<(), AppError> {
    let json = serde_json::to_string(value)?;
    println!("{json}");
    Ok(())
}

pub fn emit_text(msg: &str) {
    println!("{msg}");
}

pub fn emit_progress(msg: &str) {
    tracing::info!(message = msg);
}

/// Emite mensagem de progresso bilíngue respeitando `--lang` ou `SQLITE_GRAPHRAG_LANG`.
/// Uso: `output::emit_progress_i18n("Computing embedding...", "Calculando embedding...")`.
pub fn emit_progress_i18n(en: &str, pt: &str) {
    use crate::i18n::{current, Language};
    match current() {
        Language::English => tracing::info!(message = en),
        Language::Portugues => tracing::info!(message = pt),
    }
}

/// Payload JSON emitido pelo subcomando `remember`.
///
/// Todos os campos são obrigatórios no contrato JSON (ver `docs/schemas/remember.schema.json`).
/// `operation` é alias de `action` para compatibilidade com clientes que usam o campo antigo.
///
/// # Examples
///
/// ```
/// use sqlite_graphrag::output::RememberResponse;
///
/// let resp = RememberResponse {
///     memory_id: 1,
///     name: "nota-inicial".into(),
///     namespace: "global".into(),
///     action: "created".into(),
///     operation: "created".into(),
///     version: 1,
///     entities_persisted: 0,
///     relationships_persisted: 0,
///     chunks_created: 1,
///     merged_into_memory_id: None,
///     warnings: vec![],
///     created_at: 1_700_000_000,
///     created_at_iso: "2023-11-14T22:13:20Z".into(),
///     elapsed_ms: 42,
/// };
///
/// let json = serde_json::to_string(&resp).unwrap();
/// assert!(json.contains("\"memory_id\":1"));
/// assert!(json.contains("\"elapsed_ms\":42"));
/// assert!(json.contains("\"merged_into_memory_id\":null"));
/// ```
#[derive(Serialize)]
pub struct RememberResponse {
    pub memory_id: i64,
    pub name: String,
    pub namespace: String,
    pub action: String,
    /// Alias semântico de `action` para compatibilidade com contrato documentado em SKILL.md e AGENT_PROTOCOL.md.
    pub operation: String,
    pub version: i64,
    pub entities_persisted: usize,
    pub relationships_persisted: usize,
    pub chunks_created: usize,
    pub merged_into_memory_id: Option<i64>,
    pub warnings: Vec<String>,
    /// Timestamp Unix epoch seconds.
    pub created_at: i64,
    /// Timestamp RFC 3339 UTC string paralelo a `created_at` para parsers ISO 8601.
    pub created_at_iso: String,
    /// Tempo total de execução em milissegundos desde início do handler até serialização.
    pub elapsed_ms: u64,
}

/// Item individual retornado pela consulta `recall`.
///
/// O campo `memory_type` é serializado como `"type"` no JSON para manter
/// compatibilidade com clientes externos — o nome Rust usa `memory_type`
/// para evitar conflito com a palavra reservada.
///
/// # Examples
///
/// ```
/// use sqlite_graphrag::output::RecallItem;
///
/// let item = RecallItem {
///     memory_id: 7,
///     name: "nota-rust".into(),
///     namespace: "global".into(),
///     memory_type: "user".into(),
///     description: "aprendizado de Rust".into(),
///     snippet: "ownership e borrowing".into(),
///     distance: 0.12,
///     source: "direct".into(),
/// };
///
/// let json = serde_json::to_string(&item).unwrap();
/// // Campo Rust `memory_type` aparece como `"type"` no JSON.
/// assert!(json.contains("\"type\":\"user\""));
/// assert!(!json.contains("memory_type"));
/// assert!(json.contains("\"distance\":0.12"));
/// ```
#[derive(Serialize, Clone)]
pub struct RecallItem {
    pub memory_id: i64,
    pub name: String,
    pub namespace: String,
    #[serde(rename = "type")]
    pub memory_type: String,
    pub description: String,
    pub snippet: String,
    pub distance: f32,
    pub source: String,
}

#[derive(Serialize)]
pub struct RecallResponse {
    pub query: String,
    pub k: usize,
    pub direct_matches: Vec<RecallItem>,
    pub graph_matches: Vec<RecallItem>,
    /// Alias agregado de `direct_matches` + `graph_matches` para contrato documentado em SKILL.md.
    pub results: Vec<RecallItem>,
    /// Tempo total de execução em milissegundos desde início do handler até serialização.
    pub elapsed_ms: u64,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde::Serialize;

    #[derive(Serialize)]
    struct Dummy {
        val: u32,
    }

    // Tipo não-serializável para forçar erro de serialização JSON
    struct NotSerializable;
    impl Serialize for NotSerializable {
        fn serialize<S: serde::Serializer>(&self, _: S) -> Result<S::Ok, S::Error> {
            Err(serde::ser::Error::custom(
                "falha intencional de serialização",
            ))
        }
    }

    #[test]
    fn emit_json_retorna_ok_para_valor_valido() {
        let v = Dummy { val: 42 };
        assert!(emit_json(&v).is_ok());
    }

    #[test]
    fn emit_json_retorna_erro_para_valor_nao_serializavel() {
        let v = NotSerializable;
        assert!(emit_json(&v).is_err());
    }

    #[test]
    fn emit_json_compact_retorna_ok_para_valor_valido() {
        let v = Dummy { val: 7 };
        assert!(emit_json_compact(&v).is_ok());
    }

    #[test]
    fn emit_json_compact_retorna_erro_para_valor_nao_serializavel() {
        let v = NotSerializable;
        assert!(emit_json_compact(&v).is_err());
    }

    #[test]
    fn emit_text_nao_entra_em_panico() {
        emit_text("mensagem de teste");
    }

    #[test]
    fn emit_progress_nao_entra_em_panico() {
        emit_progress("progresso de teste");
    }

    #[test]
    fn remember_response_serializa_corretamente() {
        let r = RememberResponse {
            memory_id: 1,
            name: "teste".to_string(),
            namespace: "ns".to_string(),
            action: "created".to_string(),
            operation: "created".to_string(),
            version: 1,
            entities_persisted: 2,
            relationships_persisted: 3,
            chunks_created: 4,
            merged_into_memory_id: None,
            warnings: vec!["aviso".to_string()],
            created_at: 1776569715,
            created_at_iso: "2026-04-19T03:34:15Z".to_string(),
            elapsed_ms: 123,
        };
        let json = serde_json::to_string(&r).unwrap();
        assert!(json.contains("memory_id"));
        assert!(json.contains("aviso"));
        assert!(json.contains("\"namespace\""));
        assert!(json.contains("\"merged_into_memory_id\""));
        assert!(json.contains("\"operation\""));
        assert!(json.contains("\"created_at\""));
        assert!(json.contains("\"created_at_iso\""));
        assert!(json.contains("\"elapsed_ms\""));
    }

    #[test]
    fn recall_item_serializa_campo_type_renomeado() {
        let item = RecallItem {
            memory_id: 10,
            name: "entidade".to_string(),
            namespace: "ns".to_string(),
            memory_type: "entity".to_string(),
            description: "desc".to_string(),
            snippet: "trecho".to_string(),
            distance: 0.5,
            source: "db".to_string(),
        };
        let json = serde_json::to_string(&item).unwrap();
        assert!(json.contains("\"type\""));
        assert!(!json.contains("memory_type"));
    }

    #[test]
    fn recall_response_serializa_com_listas() {
        let resp = RecallResponse {
            query: "busca".to_string(),
            k: 10,
            direct_matches: vec![],
            graph_matches: vec![],
            results: vec![],
            elapsed_ms: 42,
        };
        let json = serde_json::to_string(&resp).unwrap();
        assert!(json.contains("direct_matches"));
        assert!(json.contains("graph_matches"));
        assert!(json.contains("\"k\":"));
        assert!(json.contains("\"results\""));
        assert!(json.contains("\"elapsed_ms\""));
    }

    #[test]
    fn output_format_default_eh_json() {
        let fmt = OutputFormat::default();
        assert!(matches!(fmt, OutputFormat::Json));
    }

    #[test]
    fn output_format_variantes_existem() {
        let _text = OutputFormat::Text;
        let _md = OutputFormat::Markdown;
        let _json = OutputFormat::Json;
    }

    #[test]
    fn recall_item_clone_produz_valor_igual() {
        let item = RecallItem {
            memory_id: 99,
            name: "clone".to_string(),
            namespace: "ns".to_string(),
            memory_type: "relation".to_string(),
            description: "d".to_string(),
            snippet: "s".to_string(),
            distance: 0.1,
            source: "src".to_string(),
        };
        let cloned = item.clone();
        assert_eq!(cloned.memory_id, item.memory_id);
        assert_eq!(cloned.name, item.name);
    }
}