Skip to main content

sqlite_graphrag/commands/
remember.rs

1use crate::chunking;
2use crate::cli::MemoryType;
3use crate::errors::AppError;
4use crate::i18n::erros;
5use crate::output::{self, JsonOutputFormat, RememberResponse};
6use crate::paths::AppPaths;
7use crate::storage::chunks as storage_chunks;
8use crate::storage::connection::{ensure_schema, open_rw};
9use crate::storage::entities::{NewEntity, NewRelationship};
10use crate::storage::memories::NewMemory;
11use crate::storage::{entities, memories, urls as storage_urls, versions};
12use serde::Deserialize;
13use std::io::Read as _;
14
15#[derive(clap::Args)]
16pub struct RememberArgs {
17    /// Memory name in kebab-case (lowercase letters, digits, hyphens).
18    /// Acts as unique key within the namespace; collisions trigger merge or rejection.
19    #[arg(long)]
20    pub name: String,
21    #[arg(
22        long,
23        value_enum,
24        long_help = "Memory kind stored in `memories.type`. This is NOT the graph `entity_type` used in `--entities-file`. Valid values: user, feedback, project, reference, decision, incident, skill."
25    )]
26    pub r#type: MemoryType,
27    /// Short description (≤500 chars) summarizing the memory for use in `list` and `recall` snippets.
28    #[arg(long)]
29    pub description: String,
30    /// Inline body content. Mutually exclusive with --body-file, --body-stdin, --graph-stdin.
31    /// Maximum 512000 bytes; rejected if empty without an external graph.
32    #[arg(
33        long,
34        conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
35    )]
36    pub body: Option<String>,
37    #[arg(
38        long,
39        help = "Read body from a file instead of --body",
40        conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
41    )]
42    pub body_file: Option<std::path::PathBuf>,
43    /// Read body from stdin until EOF. Useful in pipes (echo "..." | sqlite-graphrag remember ...).
44    /// Mutually exclusive with --body, --body-file, --graph-stdin.
45    #[arg(
46        long,
47        conflicts_with_all = ["body", "body_file", "graph_stdin"]
48    )]
49    pub body_stdin: bool,
50    #[arg(
51        long,
52        help = "JSON file containing entities to associate with this memory"
53    )]
54    pub entities_file: Option<std::path::PathBuf>,
55    #[arg(
56        long,
57        help = "JSON file containing relationships to associate with this memory"
58    )]
59    pub relationships_file: Option<std::path::PathBuf>,
60    #[arg(
61        long,
62        help = "Read graph JSON (body + entities + relationships) from stdin",
63        conflicts_with_all = [
64            "body",
65            "body_file",
66            "body_stdin",
67            "entities_file",
68            "relationships_file"
69        ]
70    )]
71    pub graph_stdin: bool,
72    #[arg(long, default_value = "global")]
73    pub namespace: Option<String>,
74    /// Inline JSON object with arbitrary metadata key-value pairs. Mutually exclusive with --metadata-file.
75    #[arg(long)]
76    pub metadata: Option<String>,
77    #[arg(long, help = "JSON file containing metadata key-value pairs")]
78    pub metadata_file: Option<std::path::PathBuf>,
79    #[arg(long)]
80    pub force_merge: bool,
81    #[arg(
82        long,
83        value_name = "EPOCH_OR_RFC3339",
84        value_parser = crate::parsers::parse_expected_updated_at,
85        long_help = "Optimistic lock: reject if updated_at does not match. \
86Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
87    )]
88    pub expected_updated_at: Option<i64>,
89    #[arg(
90        long,
91        help = "Disable automatic entity/relationship extraction from body"
92    )]
93    pub skip_extraction: bool,
94    /// Optional opaque session identifier for tracing memory provenance across multi-agent runs.
95    #[arg(long)]
96    pub session_id: Option<String>,
97    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
98    pub format: JsonOutputFormat,
99    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
100    pub json: bool,
101    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
102    pub db: Option<String>,
103}
104
105#[derive(Deserialize, Default)]
106#[serde(deny_unknown_fields)]
107struct GraphInput {
108    #[serde(default)]
109    body: Option<String>,
110    #[serde(default)]
111    entities: Vec<NewEntity>,
112    #[serde(default)]
113    relationships: Vec<NewRelationship>,
114}
115
116fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
117    for entity in &graph.entities {
118        if !is_valid_entity_type(&entity.entity_type) {
119            return Err(AppError::Validation(format!(
120                "entity_type '{}' inválido para entidade '{}'",
121                entity.entity_type, entity.name
122            )));
123        }
124    }
125
126    for rel in &mut graph.relationships {
127        rel.relation = rel.relation.replace('-', "_");
128        if !is_valid_relation(&rel.relation) {
129            return Err(AppError::Validation(format!(
130                "relation '{}' inválida para relacionamento '{}' -> '{}'",
131                rel.relation, rel.source, rel.target
132            )));
133        }
134        if !(0.0..=1.0).contains(&rel.strength) {
135            return Err(AppError::Validation(format!(
136                "strength {} inválido para relacionamento '{}' -> '{}'; esperado valor em [0.0, 1.0]",
137                rel.strength, rel.source, rel.target
138            )));
139        }
140    }
141
142    Ok(())
143}
144
145fn is_valid_entity_type(entity_type: &str) -> bool {
146    matches!(
147        entity_type,
148        "project"
149            | "tool"
150            | "person"
151            | "file"
152            | "concept"
153            | "incident"
154            | "decision"
155            | "memory"
156            | "dashboard"
157            | "issue_tracker"
158    )
159}
160
161fn is_valid_relation(relation: &str) -> bool {
162    matches!(
163        relation,
164        "applies_to"
165            | "uses"
166            | "depends_on"
167            | "causes"
168            | "fixes"
169            | "contradicts"
170            | "supports"
171            | "follows"
172            | "related"
173            | "mentions"
174            | "replaces"
175            | "tracked_in"
176    )
177}
178
179pub fn run(args: RememberArgs) -> Result<(), AppError> {
180    use crate::constants::*;
181
182    let inicio = std::time::Instant::now();
183    let _ = args.format;
184    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
185
186    // Auto-normalizar para kebab-case antes de validar (P2-H).
187    // v1.0.20: também faz trim de hífens em borda (incluindo trailing) para evitar rejeição
188    // após truncamento por filename longo terminando em hífen.
189    let normalized_name = {
190        let lower = args.name.to_lowercase().replace(['_', ' '], "-");
191        let trimmed = lower.trim_matches('-').to_string();
192        if trimmed != args.name {
193            tracing::warn!(
194                original = %args.name,
195                normalized = %trimmed,
196                "name auto-normalized to kebab-case"
197            );
198        }
199        trimmed
200    };
201
202    if normalized_name.is_empty() {
203        return Err(AppError::Validation(
204            "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
205        ));
206    }
207    if normalized_name.len() > MAX_MEMORY_NAME_LEN {
208        return Err(AppError::Validation(
209            crate::i18n::validacao::nome_comprimento(MAX_MEMORY_NAME_LEN),
210        ));
211    }
212
213    if normalized_name.starts_with("__") {
214        return Err(AppError::Validation(
215            crate::i18n::validacao::nome_reservado(),
216        ));
217    }
218
219    {
220        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
221            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
222        if !slug_re.is_match(&normalized_name) {
223            return Err(AppError::Validation(crate::i18n::validacao::nome_kebab(
224                &normalized_name,
225            )));
226        }
227    }
228
229    if args.description.len() > MAX_MEMORY_DESCRIPTION_LEN {
230        return Err(AppError::Validation(
231            crate::i18n::validacao::descricao_excede(MAX_MEMORY_DESCRIPTION_LEN),
232        ));
233    }
234
235    let mut raw_body = if let Some(b) = args.body {
236        b
237    } else if let Some(path) = args.body_file {
238        std::fs::read_to_string(&path).map_err(AppError::Io)?
239    } else if args.body_stdin || args.graph_stdin {
240        let mut buf = String::new();
241        std::io::stdin()
242            .read_to_string(&mut buf)
243            .map_err(AppError::Io)?;
244        buf
245    } else {
246        String::new()
247    };
248
249    let entities_provided_externally =
250        args.entities_file.is_some() || args.relationships_file.is_some() || args.graph_stdin;
251
252    let mut graph = GraphInput::default();
253    if let Some(path) = args.entities_file {
254        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
255        graph.entities = serde_json::from_str(&content)?;
256    }
257    if let Some(path) = args.relationships_file {
258        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
259        graph.relationships = serde_json::from_str(&content)?;
260    }
261    if args.graph_stdin {
262        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
263            AppError::Validation(format!("payload JSON inválido em --graph-stdin: {e}"))
264        })?;
265        raw_body = graph.body.take().unwrap_or_default();
266    }
267
268    if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
269        return Err(AppError::LimitExceeded(erros::limite_entidades(
270            MAX_ENTITIES_PER_MEMORY,
271        )));
272    }
273    if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
274        return Err(AppError::LimitExceeded(erros::limite_relacionamentos(
275            MAX_RELATIONSHIPS_PER_MEMORY,
276        )));
277    }
278    normalize_and_validate_graph_input(&mut graph)?;
279
280    if raw_body.len() > MAX_MEMORY_BODY_LEN {
281        return Err(AppError::LimitExceeded(
282            crate::i18n::validacao::body_excede(MAX_MEMORY_BODY_LEN),
283        ));
284    }
285
286    // v1.0.22 P1: rejeitar body vazio ou whitespace-only quando não há grafo externo.
287    // Sem este check, embeddings vazios eram persistidos quebrando a semântica de recall.
288    if !entities_provided_externally && graph.entities.is_empty() && raw_body.trim().is_empty() {
289        return Err(AppError::Validation(
290            "body não pode estar vazio: forneça --body, --body-file, --body-stdin com conteúdo, \
291             ou um grafo via --entities-file/--graph-stdin"
292                .to_string(),
293        ));
294    }
295
296    let metadata: serde_json::Value = if let Some(m) = args.metadata {
297        serde_json::from_str(&m)?
298    } else if let Some(path) = args.metadata_file {
299        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
300        serde_json::from_str(&content)?
301    } else {
302        serde_json::json!({})
303    };
304
305    let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
306    let snippet: String = raw_body.chars().take(200).collect();
307
308    let paths = AppPaths::resolve(args.db.as_deref())?;
309    paths.ensure_dirs()?;
310
311    // v1.0.20: usar .trim().is_empty() para rejeitar bodies que são apenas whitespace.
312    let mut extraction_method: Option<String> = None;
313    let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::new();
314    let mut relationships_truncated = false;
315    if !args.skip_extraction
316        && !entities_provided_externally
317        && graph.entities.is_empty()
318        && !raw_body.trim().is_empty()
319    {
320        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
321            Ok(extracted) => {
322                extraction_method = Some(extracted.extraction_method.clone());
323                extracted_urls = extracted.urls;
324                graph.entities = extracted.entities;
325                graph.relationships = extracted.relationships;
326                relationships_truncated = extracted.relationships_truncated;
327
328                if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
329                    graph.entities.truncate(MAX_ENTITIES_PER_MEMORY);
330                }
331                if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
332                    relationships_truncated = true;
333                    graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
334                }
335                normalize_and_validate_graph_input(&mut graph)?;
336            }
337            Err(e) => {
338                tracing::warn!("auto-extraction falhou (graceful degradation): {e:#}");
339            }
340        }
341    }
342
343    let mut conn = open_rw(&paths.db)?;
344    ensure_schema(&mut conn)?;
345
346    {
347        use crate::constants::MAX_NAMESPACES_ACTIVE;
348        let active_count: u32 = conn.query_row(
349            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
350            [],
351            |r| r.get::<_, i64>(0).map(|v| v as u32),
352        )?;
353        let ns_exists: bool = conn.query_row(
354            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
355            rusqlite::params![namespace],
356            |r| r.get::<_, i64>(0).map(|v| v > 0),
357        )?;
358        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
359            return Err(AppError::NamespaceError(format!(
360                "limite de {MAX_NAMESPACES_ACTIVE} namespaces ativos excedido ao tentar criar '{namespace}'"
361            )));
362        }
363    }
364
365    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
366    if existing_memory.is_some() && !args.force_merge {
367        return Err(AppError::Duplicate(erros::memoria_duplicada(
368            &normalized_name,
369            &namespace,
370        )));
371    }
372
373    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
374
375    output::emit_progress_i18n(
376        &format!(
377            "Remember stage: validated input; available memory {} MB",
378            crate::memory_guard::available_memory_mb()
379        ),
380        &format!(
381            "Etapa remember: entrada validada; memória disponível {} MB",
382            crate::memory_guard::available_memory_mb()
383        ),
384    );
385
386    let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
387    let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
388    let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
389    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
390    let chunks_created = chunks_info.len();
391    // For single-chunk bodies the memory row itself stores the content and no
392    // entry is appended to `memory_chunks` (see line ~545). For multi-chunk
393    // bodies every chunk is persisted via `insert_chunk_slices`.
394    let chunks_persisted = if chunks_info.len() > 1 {
395        chunks_info.len()
396    } else {
397        0
398    };
399
400    output::emit_progress_i18n(
401        &format!(
402            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
403            chunks_created,
404            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
405        ),
406        &format!(
407            "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem (máximo do modelo {model_max_length}); chunking gerou {} chunks; RSS do processo {} MB",
408            chunks_created,
409            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
410        ),
411    );
412
413    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
414        return Err(AppError::LimitExceeded(format!(
415            "documento gera {chunks_created} chunks; limite operacional seguro atual é {} chunks; divida o documento antes de usar remember",
416            crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
417        )));
418    }
419
420    output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
421    let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
422
423    let embedding = if chunks_info.len() == 1 {
424        crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
425    } else {
426        let chunk_texts: Vec<&str> = chunks_info
427            .iter()
428            .map(|c| chunking::chunk_text(&raw_body, c))
429            .collect();
430        output::emit_progress_i18n(
431            &format!(
432                "Embedding {} chunks serially to keep memory bounded...",
433                chunks_info.len()
434            ),
435            &format!(
436                "Embedando {} chunks serialmente para manter memória limitada...",
437                chunks_info.len()
438            ),
439        );
440        let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
441        for chunk_text in &chunk_texts {
442            chunk_embeddings.push(crate::daemon::embed_passage_or_local(
443                &paths.models,
444                chunk_text,
445            )?);
446        }
447        output::emit_progress_i18n(
448            &format!(
449                "Remember stage: chunk embeddings complete; process RSS {} MB",
450                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
451            ),
452            &format!(
453                "Etapa remember: embeddings dos chunks concluídos; RSS do processo {} MB",
454                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
455            ),
456        );
457        let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
458        chunk_embeddings_cache = Some(chunk_embeddings);
459        aggregated
460    };
461    let body_for_storage = raw_body;
462
463    let memory_type = args.r#type.as_str();
464    let new_memory = NewMemory {
465        namespace: namespace.clone(),
466        name: normalized_name.clone(),
467        memory_type: memory_type.to_string(),
468        description: args.description.clone(),
469        body: body_for_storage,
470        body_hash: body_hash.clone(),
471        session_id: args.session_id.clone(),
472        source: "agent".to_string(),
473        metadata,
474    };
475
476    let mut warnings = Vec::new();
477    let mut entities_persisted = 0usize;
478    let mut relationships_persisted = 0usize;
479
480    let graph_entity_embeddings = graph
481        .entities
482        .iter()
483        .map(|entity| {
484            let entity_text = match &entity.description {
485                Some(desc) => format!("{} {}", entity.name, desc),
486                None => entity.name.clone(),
487            };
488            crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
489        })
490        .collect::<Result<Vec<_>, _>>()?;
491
492    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
493
494    let (memory_id, action, version) = match existing_memory {
495        Some((existing_id, _updated_at, _current_version)) => {
496            if let Some(hash_id) = duplicate_hash_id {
497                if hash_id != existing_id {
498                    warnings.push(format!(
499                        "identical body already exists as memory id {hash_id}"
500                    ));
501                }
502            }
503
504            storage_chunks::delete_chunks(&tx, existing_id)?;
505
506            let next_v = versions::next_version(&tx, existing_id)?;
507            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
508            versions::insert_version(
509                &tx,
510                existing_id,
511                next_v,
512                &normalized_name,
513                memory_type,
514                &args.description,
515                &new_memory.body,
516                &serde_json::to_string(&new_memory.metadata)?,
517                None,
518                "edit",
519            )?;
520            memories::upsert_vec(
521                &tx,
522                existing_id,
523                &namespace,
524                memory_type,
525                &embedding,
526                &normalized_name,
527                &snippet,
528            )?;
529            (existing_id, "updated".to_string(), next_v)
530        }
531        None => {
532            if let Some(hash_id) = duplicate_hash_id {
533                warnings.push(format!(
534                    "identical body already exists as memory id {hash_id}"
535                ));
536            }
537            let id = memories::insert(&tx, &new_memory)?;
538            versions::insert_version(
539                &tx,
540                id,
541                1,
542                &normalized_name,
543                memory_type,
544                &args.description,
545                &new_memory.body,
546                &serde_json::to_string(&new_memory.metadata)?,
547                None,
548                "create",
549            )?;
550            memories::upsert_vec(
551                &tx,
552                id,
553                &namespace,
554                memory_type,
555                &embedding,
556                &normalized_name,
557                &snippet,
558            )?;
559            (id, "created".to_string(), 1)
560        }
561    };
562
563    if chunks_info.len() > 1 {
564        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
565
566        let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
567            AppError::Internal(anyhow::anyhow!(
568                "cache de embeddings de chunks ausente no caminho multi-chunk do remember"
569            ))
570        })?;
571
572        for (i, emb) in chunk_embeddings.iter().enumerate() {
573            storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
574        }
575        output::emit_progress_i18n(
576            &format!(
577                "Remember stage: persisted chunk vectors; process RSS {} MB",
578                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
579            ),
580            &format!(
581                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
582                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
583            ),
584        );
585    }
586
587    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
588        for entity in &graph.entities {
589            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
590            let entity_embedding = &graph_entity_embeddings[entities_persisted];
591            entities::upsert_entity_vec(
592                &tx,
593                entity_id,
594                &namespace,
595                &entity.entity_type,
596                entity_embedding,
597                &entity.name,
598            )?;
599            entities::link_memory_entity(&tx, memory_id, entity_id)?;
600            entities::increment_degree(&tx, entity_id)?;
601            entities_persisted += 1;
602        }
603        let entity_types: std::collections::HashMap<&str, &str> = graph
604            .entities
605            .iter()
606            .map(|entity| (entity.name.as_str(), entity.entity_type.as_str()))
607            .collect();
608
609        for rel in &graph.relationships {
610            let source_entity = NewEntity {
611                name: rel.source.clone(),
612                entity_type: entity_types
613                    .get(rel.source.as_str())
614                    .copied()
615                    .unwrap_or("concept")
616                    .to_string(),
617                description: None,
618            };
619            let target_entity = NewEntity {
620                name: rel.target.clone(),
621                entity_type: entity_types
622                    .get(rel.target.as_str())
623                    .copied()
624                    .unwrap_or("concept")
625                    .to_string(),
626                description: None,
627            };
628            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
629            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
630            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
631            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
632            relationships_persisted += 1;
633        }
634    }
635    tx.commit()?;
636
637    // v1.0.24 P0-2: persistir URLs em tabela dedicada, fora da transação principal.
638    // Falhas não propagam — caminho não crítico com graceful degradation.
639    let urls_persisted = if !extracted_urls.is_empty() {
640        let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
641            .into_iter()
642            .map(|u| storage_urls::MemoryUrl {
643                url: u.url,
644                offset: Some(u.offset as i64),
645            })
646            .collect();
647        storage_urls::insert_urls(&conn, memory_id, &url_entries)
648    } else {
649        0
650    };
651
652    let created_at_epoch = chrono::Utc::now().timestamp();
653    let created_at_iso = crate::tz::formatar_iso(chrono::Utc::now());
654
655    output::emit_json(&RememberResponse {
656        memory_id,
657        name: args.name,
658        namespace,
659        action: action.clone(),
660        operation: action,
661        version,
662        entities_persisted,
663        relationships_persisted,
664        relationships_truncated,
665        chunks_created,
666        chunks_persisted,
667        urls_persisted,
668        extraction_method,
669        merged_into_memory_id: None,
670        warnings,
671        created_at: created_at_epoch,
672        created_at_iso,
673        elapsed_ms: inicio.elapsed().as_millis() as u64,
674    })?;
675
676    Ok(())
677}
678
679#[cfg(test)]
680mod testes {
681    use crate::output::RememberResponse;
682
683    #[test]
684    fn remember_response_serializa_campos_obrigatorios() {
685        let resp = RememberResponse {
686            memory_id: 42,
687            name: "minha-mem".to_string(),
688            namespace: "global".to_string(),
689            action: "created".to_string(),
690            operation: "created".to_string(),
691            version: 1,
692            entities_persisted: 0,
693            relationships_persisted: 0,
694            relationships_truncated: false,
695            chunks_created: 1,
696            chunks_persisted: 0,
697            urls_persisted: 0,
698            extraction_method: None,
699            merged_into_memory_id: None,
700            warnings: vec![],
701            created_at: 1_705_320_000,
702            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
703            elapsed_ms: 55,
704        };
705
706        let json = serde_json::to_value(&resp).expect("serialização falhou");
707        assert_eq!(json["memory_id"], 42);
708        assert_eq!(json["action"], "created");
709        assert_eq!(json["operation"], "created");
710        assert_eq!(json["version"], 1);
711        assert_eq!(json["elapsed_ms"], 55u64);
712        assert!(json["warnings"].is_array());
713        assert!(json["merged_into_memory_id"].is_null());
714    }
715
716    #[test]
717    fn remember_response_action_e_operation_sao_aliases() {
718        let resp = RememberResponse {
719            memory_id: 1,
720            name: "mem".to_string(),
721            namespace: "global".to_string(),
722            action: "updated".to_string(),
723            operation: "updated".to_string(),
724            version: 2,
725            entities_persisted: 3,
726            relationships_persisted: 1,
727            relationships_truncated: false,
728            extraction_method: None,
729            chunks_created: 2,
730            chunks_persisted: 2,
731            urls_persisted: 0,
732            merged_into_memory_id: None,
733            warnings: vec![],
734            created_at: 0,
735            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
736            elapsed_ms: 0,
737        };
738
739        let json = serde_json::to_value(&resp).expect("serialização falhou");
740        assert_eq!(
741            json["action"], json["operation"],
742            "action e operation devem ser iguais"
743        );
744        assert_eq!(json["entities_persisted"], 3);
745        assert_eq!(json["relationships_persisted"], 1);
746        assert_eq!(json["chunks_created"], 2);
747    }
748
749    #[test]
750    fn remember_response_warnings_lista_mensagens() {
751        let resp = RememberResponse {
752            memory_id: 5,
753            name: "dup-mem".to_string(),
754            namespace: "global".to_string(),
755            action: "created".to_string(),
756            operation: "created".to_string(),
757            version: 1,
758            entities_persisted: 0,
759            extraction_method: None,
760            relationships_persisted: 0,
761            relationships_truncated: false,
762            chunks_created: 1,
763            chunks_persisted: 0,
764            urls_persisted: 0,
765            merged_into_memory_id: None,
766            warnings: vec!["identical body already exists as memory id 3".to_string()],
767            created_at: 0,
768            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
769            elapsed_ms: 10,
770        };
771
772        let json = serde_json::to_value(&resp).expect("serialização falhou");
773        let warnings = json["warnings"]
774            .as_array()
775            .expect("warnings deve ser array");
776        assert_eq!(warnings.len(), 1);
777        assert!(warnings[0].as_str().unwrap().contains("identical body"));
778    }
779
780    #[test]
781    fn nome_invalido_prefixo_reservado_retorna_validation_error() {
782        use crate::errors::AppError;
783        // Valida a lógica de rejeição de nomes com prefixo "__" diretamente
784        let nome = "__reservado";
785        let resultado: Result<(), AppError> = if nome.starts_with("__") {
786            Err(AppError::Validation(
787                crate::i18n::validacao::nome_reservado(),
788            ))
789        } else {
790            Ok(())
791        };
792        assert!(resultado.is_err());
793        if let Err(AppError::Validation(msg)) = resultado {
794            assert!(!msg.is_empty());
795        }
796    }
797
798    #[test]
799    fn nome_muito_longo_retorna_validation_error() {
800        use crate::errors::AppError;
801        let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
802        let resultado: Result<(), AppError> =
803            if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
804                Err(AppError::Validation(
805                    crate::i18n::validacao::nome_comprimento(crate::constants::MAX_MEMORY_NAME_LEN),
806                ))
807            } else {
808                Ok(())
809            };
810        assert!(resultado.is_err());
811    }
812
813    #[test]
814    fn remember_response_merged_into_memory_id_some_serializa_inteiro() {
815        let resp = RememberResponse {
816            memory_id: 10,
817            name: "mem-mergeada".to_string(),
818            namespace: "global".to_string(),
819            action: "updated".to_string(),
820            operation: "updated".to_string(),
821            version: 3,
822            extraction_method: None,
823            entities_persisted: 0,
824            relationships_persisted: 0,
825            relationships_truncated: false,
826            chunks_created: 1,
827            chunks_persisted: 0,
828            urls_persisted: 0,
829            merged_into_memory_id: Some(7),
830            warnings: vec![],
831            created_at: 0,
832            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
833            elapsed_ms: 0,
834        };
835
836        let json = serde_json::to_value(&resp).expect("serialização falhou");
837        assert_eq!(json["merged_into_memory_id"], 7);
838    }
839
840    #[test]
841    fn remember_response_urls_persisted_serializa_campo() {
842        // v1.0.24 P0-2: garante que urls_persisted aparece no JSON e aceita valor > 0.
843        let resp = RememberResponse {
844            memory_id: 3,
845            name: "mem-com-urls".to_string(),
846            namespace: "global".to_string(),
847            action: "created".to_string(),
848            operation: "created".to_string(),
849            version: 1,
850            entities_persisted: 0,
851            relationships_persisted: 0,
852            relationships_truncated: false,
853            chunks_created: 1,
854            chunks_persisted: 0,
855            urls_persisted: 3,
856            extraction_method: Some("regex-only".to_string()),
857            merged_into_memory_id: None,
858            warnings: vec![],
859            created_at: 0,
860            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
861            elapsed_ms: 0,
862        };
863        let json = serde_json::to_value(&resp).expect("serialização falhou");
864        assert_eq!(json["urls_persisted"], 3);
865    }
866
867    #[test]
868    fn nome_vazio_apos_normalizacao_retorna_mensagem_especifica() {
869        // P0-4 regression: name consisting only of hyphens normalizes to empty string;
870        // must produce a distinct error message, not the "too long" message.
871        use crate::errors::AppError;
872        let normalized = "---".to_lowercase().replace(['_', ' '], "-");
873        let normalized = normalized.trim_matches('-').to_string();
874        let resultado: Result<(), AppError> = if normalized.is_empty() {
875            Err(AppError::Validation(
876                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
877            ))
878        } else {
879            Ok(())
880        };
881        assert!(resultado.is_err());
882        if let Err(AppError::Validation(msg)) = resultado {
883            assert!(
884                msg.contains("empty after normalization"),
885                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
886            );
887        }
888    }
889
890    #[test]
891    fn nome_so_underscores_apos_normalizacao_retorna_mensagem_especifica() {
892        // P0-4 regression: name consisting only of underscores normalizes to empty string.
893        use crate::errors::AppError;
894        let normalized = "___".to_lowercase().replace(['_', ' '], "-");
895        let normalized = normalized.trim_matches('-').to_string();
896        assert!(
897            normalized.is_empty(),
898            "underscores devem normalizar para string vazia"
899        );
900        let resultado: Result<(), AppError> = if normalized.is_empty() {
901            Err(AppError::Validation(
902                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
903            ))
904        } else {
905            Ok(())
906        };
907        assert!(resultado.is_err());
908        if let Err(AppError::Validation(msg)) = resultado {
909            assert!(
910                msg.contains("empty after normalization"),
911                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
912            );
913        }
914    }
915
916    #[test]
917    fn remember_response_relationships_truncated_serializa_campo() {
918        // P1-D: garante que relationships_truncated aparece no JSON como bool.
919        let resp_false = RememberResponse {
920            memory_id: 1,
921            name: "test".to_string(),
922            namespace: "global".to_string(),
923            action: "created".to_string(),
924            operation: "created".to_string(),
925            version: 1,
926            entities_persisted: 2,
927            relationships_persisted: 1,
928            relationships_truncated: false,
929            chunks_created: 1,
930            chunks_persisted: 0,
931            urls_persisted: 0,
932            extraction_method: None,
933            merged_into_memory_id: None,
934            warnings: vec![],
935            created_at: 0,
936            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
937            elapsed_ms: 0,
938        };
939        let json_false = serde_json::to_value(&resp_false).expect("serialização falhou");
940        assert_eq!(json_false["relationships_truncated"], false);
941
942        let resp_true = RememberResponse {
943            relationships_truncated: true,
944            ..resp_false
945        };
946        let json_true = serde_json::to_value(&resp_true).expect("serialização falhou");
947        assert_eq!(json_true["relationships_truncated"], true);
948    }
949}