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