1use brain_db_sdk::wire::types::{
8 EntityCreateRequest, EntityCreateResponse, EntityGetRequest, EntityGetResponse, EntityListItem,
9 EntityListRequest, EntityResolveRequest, EntityResolveResponse, EntityView,
10 RelationTraverseRequest, RelationTraverseResponseFrame, ResolutionOutcomeWire,
11 TraversalPathWire,
12};
13use serde::{Deserialize, Serialize};
14
15use super::{parse_uuid, uuid_string};
16
17const DEFAULT_LIST_LIMIT: u32 = 100;
19const MAX_LIST_LIMIT: u32 = 1000;
21const DEFAULT_TRAVERSE_DEPTH: u32 = 3;
23const MAX_TRAVERSE_DEPTH: u32 = 5;
25const DEFAULT_TRAVERSE_NODES: u32 = 100;
27const MAX_TRAVERSE_NODES: u32 = 1000;
29
30fn merged_into_string(id: &[u8; 16]) -> Option<String> {
33 (id != &[0u8; 16]).then(|| uuid_string(id))
34}
35
36#[derive(Debug, Deserialize)]
40pub struct ResolveEntityBody {
41 pub candidate_name: String,
43 #[serde(default)]
45 pub resolution_context: String,
46 #[serde(default)]
48 pub type_hint: u32,
49 #[serde(default)]
51 pub allow_create: bool,
52}
53
54impl ResolveEntityBody {
55 pub fn to_request(&self) -> Result<EntityResolveRequest, String> {
57 let candidate_name = self.candidate_name.trim().to_owned();
58 if candidate_name.is_empty() {
59 return Err("candidate_name must not be blank".into());
60 }
61 Ok(EntityResolveRequest {
62 candidate_name,
63 resolution_context: self.resolution_context.clone(),
64 entity_type_hint: self.type_hint,
65 allow_create: self.allow_create,
66 request_id: brain_db_sdk::new_id(),
67 act_as: None,
68 })
69 }
70}
71
72#[derive(Debug, Serialize)]
74pub struct ResolveEntityResponseDto {
75 pub outcome: &'static str,
77 pub tier: u8,
79 pub confidence: f32,
81 pub entity_id: Option<String>,
84 pub candidate_ids: Vec<String>,
86}
87
88impl From<EntityResolveResponse> for ResolveEntityResponseDto {
89 fn from(r: EntityResolveResponse) -> Self {
90 let (outcome, bound) = match r.outcome {
93 ResolutionOutcomeWire::Resolved => ("resolved", true),
94 ResolutionOutcomeWire::Created => ("created", true),
95 ResolutionOutcomeWire::Ambiguous => ("ambiguous", false),
96 ResolutionOutcomeWire::NotFound => ("not_found", false),
97 };
98 Self {
99 outcome,
100 tier: r.tier,
101 confidence: r.confidence,
102 entity_id: bound.then(|| uuid_string(&r.resolved_entity)),
103 candidate_ids: r.candidate_ids.iter().map(uuid_string).collect(),
104 }
105 }
106}
107
108#[derive(Debug, Deserialize)]
112pub struct CreateEntityBody {
113 pub entity_type_id: u32,
116 pub canonical_name: String,
118 #[serde(default)]
120 pub aliases: Vec<String>,
121}
122
123impl CreateEntityBody {
124 pub fn to_request(&self) -> Result<EntityCreateRequest, String> {
126 if self.entity_type_id == 0 {
127 return Err("entity_type_id must be non-zero".into());
128 }
129 let canonical_name = self.canonical_name.trim().to_owned();
130 if canonical_name.is_empty() {
131 return Err("canonical_name must not be blank".into());
132 }
133 Ok(EntityCreateRequest {
134 entity_type_id: self.entity_type_id,
135 canonical_name,
136 aliases: self.aliases.clone(),
137 attributes_blob: Vec::new(),
138 session_id: 0,
139 request_id: brain_db_sdk::new_id(),
140 act_as: None,
141 })
142 }
143}
144
145#[derive(Debug, Serialize)]
147pub struct CreateEntityResponseDto {
148 pub entity_id: String,
150}
151
152impl From<EntityCreateResponse> for CreateEntityResponseDto {
153 fn from(r: EntityCreateResponse) -> Self {
154 Self {
155 entity_id: uuid_string(&r.entity_id),
156 }
157 }
158}
159
160#[derive(Debug, Serialize)]
164pub struct EntityDetailDto {
165 pub entity_id: String,
167 pub entity_type_id: u32,
169 pub canonical_name: String,
171 pub aliases: Vec<String>,
173 pub mention_count: u32,
175 pub created_at_unix_nanos: u64,
177 pub updated_at_unix_nanos: u64,
179 pub merged_into: Option<String>,
181 pub resolved_from: Vec<String>,
187}
188
189impl From<EntityView> for EntityDetailDto {
190 fn from(v: EntityView) -> Self {
191 Self {
192 entity_id: uuid_string(&v.entity_id),
193 entity_type_id: v.entity_type_id,
194 canonical_name: v.canonical_name,
195 aliases: v.aliases,
196 mention_count: v.mention_count,
197 created_at_unix_nanos: v.created_at_unix_nanos,
198 updated_at_unix_nanos: v.updated_at_unix_nanos,
199 merged_into: merged_into_string(&v.merged_into),
200 resolved_from: Vec::new(),
202 }
203 }
204}
205
206impl EntityDetailDto {
207 pub fn from_get_response(resp: EntityGetResponse) -> Self {
210 let resolved_from = resp.resolved_from.iter().map(uuid_string).collect();
211 Self {
212 resolved_from,
213 ..Self::from(resp.entity)
214 }
215 }
216}
217
218pub fn get_request_from_id(id: &str) -> Result<EntityGetRequest, String> {
220 Ok(EntityGetRequest {
221 entity_id: parse_uuid(id)?,
222 act_as: None,
223 })
224}
225
226#[derive(Debug, Default, Deserialize)]
229pub struct ListEntitiesQuery {
230 #[serde(default)]
232 pub type_id: u32,
233 #[serde(default)]
235 pub prefix: String,
236 #[serde(default)]
238 pub mention_count_min: u32,
239 #[serde(default)]
241 pub include_tombstoned: bool,
242 #[serde(default)]
244 pub include_merged: bool,
245 pub limit: Option<u32>,
247}
248
249impl ListEntitiesQuery {
250 pub fn to_request(&self) -> EntityListRequest {
253 let limit = self
254 .limit
255 .unwrap_or(DEFAULT_LIST_LIMIT)
256 .clamp(1, MAX_LIST_LIMIT);
257 EntityListRequest {
258 entity_type_id: self.type_id,
259 name_prefix: self.prefix.clone(),
260 mention_count_min: self.mention_count_min,
261 include_tombstoned: self.include_tombstoned,
262 include_merged: self.include_merged,
263 limit,
264 cursor: Vec::new(),
265 act_as: None,
266 }
267 }
268}
269
270#[derive(Debug, Serialize)]
272pub struct ListEntitiesResponseDto {
273 pub entities: Vec<EntityDetailDto>,
275 pub count: usize,
277}
278
279impl ListEntitiesResponseDto {
280 pub fn from_items(items: Vec<EntityListItem>) -> Self {
282 let entities: Vec<EntityDetailDto> = items
283 .into_iter()
284 .map(|item| EntityDetailDto::from(item.entity))
285 .collect();
286 let count = entities.len();
287 Self { entities, count }
288 }
289}
290
291#[derive(Debug, Default, Deserialize)]
297pub struct TraverseBody {
298 #[serde(default)]
300 pub direction: String,
301 #[serde(default)]
303 pub relation_types: Vec<String>,
304 #[serde(default)]
306 pub max_depth: Option<u32>,
307 #[serde(default)]
309 pub max_nodes: Option<u32>,
310 #[serde(default)]
312 pub include_superseded: bool,
313}
314
315fn traverse_direction_wire(raw: &str) -> Result<u8, String> {
317 match raw.trim().to_ascii_lowercase().as_str() {
318 "" | "outgoing" | "out" => Ok(0),
319 "incoming" | "in" => Ok(1),
320 "both" => Ok(2),
321 _ => Err("direction must be one of: outgoing, incoming, both".into()),
322 }
323}
324
325impl TraverseBody {
326 pub fn to_request(&self, id: &str) -> Result<RelationTraverseRequest, String> {
329 let start_entity = parse_uuid(id)?;
330 let direction = traverse_direction_wire(&self.direction)?;
331 let max_depth = self
332 .max_depth
333 .unwrap_or(DEFAULT_TRAVERSE_DEPTH)
334 .clamp(1, MAX_TRAVERSE_DEPTH);
335 let max_nodes = self
336 .max_nodes
337 .unwrap_or(DEFAULT_TRAVERSE_NODES)
338 .clamp(1, MAX_TRAVERSE_NODES);
339 Ok(RelationTraverseRequest {
340 start_entity,
341 relation_types: self.relation_types.clone(),
342 direction,
343 max_depth,
344 max_nodes,
345 time_at_unix_nanos: 0,
346 include_superseded: self.include_superseded,
347 request_id: brain_db_sdk::new_id(),
348 act_as: None,
349 })
350 }
351}
352
353#[derive(Debug, Serialize)]
355pub struct TraversalStepDto {
356 pub relation_id: String,
358 pub from: String,
360 pub to: String,
362 pub relation_type: String,
364 pub depth: u32,
366}
367
368#[derive(Debug, Serialize)]
370pub struct TraversalPathDto {
371 pub steps: Vec<TraversalStepDto>,
373}
374
375impl From<TraversalPathWire> for TraversalPathDto {
376 fn from(p: TraversalPathWire) -> Self {
377 Self {
378 steps: p
379 .steps
380 .into_iter()
381 .map(|s| TraversalStepDto {
382 relation_id: uuid_string(&s.relation_id),
383 from: uuid_string(&s.from),
384 to: uuid_string(&s.to),
385 relation_type: s.relation_type,
386 depth: s.depth,
387 })
388 .collect(),
389 }
390 }
391}
392
393#[derive(Debug, Serialize)]
395pub struct TraverseResponseDto {
396 pub paths: Vec<TraversalPathDto>,
398 pub total_paths: u32,
400 pub truncated: bool,
402}
403
404impl TraverseResponseDto {
405 pub fn from_frames(frames: Vec<RelationTraverseResponseFrame>) -> Self {
410 let truncated = frames.iter().any(|f| f.truncated);
411 let total_paths = frames.last().map_or(0, |f| f.total_paths);
412 let paths = frames
413 .into_iter()
414 .flat_map(|f| f.paths.into_iter().map(TraversalPathDto::from))
415 .collect();
416 Self {
417 paths,
418 total_paths,
419 truncated,
420 }
421 }
422}
423
424#[cfg(test)]
425mod entity_tests {
426 use super::*;
427 use brain_db_sdk::wire::types::{TraversalPathWire, TraversalStepWire};
428
429 #[test]
430 fn create_body_rejects_zero_type_and_blank_name() {
431 let bad_type = CreateEntityBody {
432 entity_type_id: 0,
433 canonical_name: "Ada".into(),
434 aliases: vec![],
435 };
436 assert!(bad_type.to_request().is_err());
437 let blank = CreateEntityBody {
438 entity_type_id: 1,
439 canonical_name: " ".into(),
440 aliases: vec![],
441 };
442 assert!(blank.to_request().is_err());
443 let ok = CreateEntityBody {
444 entity_type_id: 1,
445 canonical_name: " Ada Lovelace ".into(),
446 aliases: vec!["Ada".into()],
447 };
448 let req = ok.to_request().unwrap();
449 assert_eq!(req.canonical_name, "Ada Lovelace"); assert_eq!(req.entity_type_id, 1);
451 assert_eq!(req.attributes_blob, [] as [u8; 0]);
452 }
453
454 #[test]
455 fn entity_get_response_surfaces_resolved_from_chain() {
456 let mut survivor = [0u8; 16];
457 survivor[15] = 3; let mut a = [0u8; 16];
459 a[15] = 1;
460 let mut b = [0u8; 16];
461 b[15] = 2;
462 let view = EntityView {
463 entity_id: survivor,
464 entity_type_id: 1,
465 canonical_name: "Ada Lovelace".into(),
466 normalized_name: "ada lovelace".into(),
467 aliases: vec![],
468 attributes_blob: vec![],
469 mention_count: 4,
470 created_at_unix_nanos: 1,
471 updated_at_unix_nanos: 2,
472 merged_into: [0u8; 16],
473 embedding_version: 1,
474 flags: 0,
475 };
476 let resp = EntityGetResponse {
477 entity: view.clone(),
478 resolved_from: vec![a, b],
479 };
480 let dto = EntityDetailDto::from_get_response(resp);
481 assert_eq!(dto.entity_id, uuid_string(&survivor));
482 assert_eq!(dto.resolved_from, vec![uuid_string(&a), uuid_string(&b)]);
484 assert_eq!(EntityDetailDto::from(view).resolved_from, [] as [String; 0]);
486 }
487
488 #[test]
489 fn resolve_body_maps_type_hint_and_rejects_blank() {
490 assert!(
491 ResolveEntityBody {
492 candidate_name: " ".into(),
493 resolution_context: String::new(),
494 type_hint: 0,
495 allow_create: false,
496 }
497 .to_request()
498 .is_err()
499 );
500 let req = ResolveEntityBody {
501 candidate_name: "Ada".into(),
502 resolution_context: "the mathematician".into(),
503 type_hint: 5,
504 allow_create: true,
505 }
506 .to_request()
507 .unwrap();
508 assert_eq!(req.entity_type_hint, 5);
509 assert!(req.allow_create);
510 assert_eq!(req.resolution_context, "the mathematician");
511 }
512
513 #[test]
514 fn resolve_response_surfaces_id_only_when_bound() {
515 let mut id = [0u8; 16];
516 id[15] = 9;
517 let resolved = EntityResolveResponse {
518 outcome: ResolutionOutcomeWire::Resolved,
519 tier: 1,
520 confidence: 1.0,
521 resolved_entity: id,
522 candidate_ids: vec![],
523 audit_id: [0u8; 16],
524 };
525 let dto = ResolveEntityResponseDto::from(resolved);
526 assert_eq!(dto.outcome, "resolved");
527 assert_eq!(
528 dto.entity_id.as_deref(),
529 Some("00000000-0000-0000-0000-000000000009")
530 );
531
532 let ambiguous = EntityResolveResponse {
533 outcome: ResolutionOutcomeWire::Ambiguous,
534 tier: 0,
535 confidence: 0.0,
536 resolved_entity: id, candidate_ids: vec![[0x11u8; 16], [0x22u8; 16]],
538 audit_id: [0u8; 16],
539 };
540 let dto = ResolveEntityResponseDto::from(ambiguous);
541 assert_eq!(dto.outcome, "ambiguous");
542 assert!(dto.entity_id.is_none());
543 assert_eq!(dto.candidate_ids.len(), 2);
544 }
545
546 #[test]
547 fn list_query_clamps_limit_and_defaults() {
548 let q = ListEntitiesQuery {
549 limit: Some(9999),
550 ..Default::default()
551 };
552 let req = q.to_request();
553 assert_eq!(req.limit, MAX_LIST_LIMIT);
554 assert_eq!(req.cursor, [] as [u8; 0]);
555 let none = ListEntitiesQuery::default().to_request();
556 assert_eq!(none.limit, DEFAULT_LIST_LIMIT);
557 }
558
559 #[test]
560 fn traverse_body_parses_direction_and_clamps() {
561 let id = "00000000-0000-0000-0000-000000000001";
562 let req = TraverseBody {
563 direction: "both".into(),
564 relation_types: vec!["brain:works_at".into()],
565 max_depth: Some(99),
566 max_nodes: Some(0),
567 include_superseded: true,
568 }
569 .to_request(id)
570 .unwrap();
571 assert_eq!(req.direction, 2);
572 assert_eq!(req.max_depth, MAX_TRAVERSE_DEPTH);
573 assert_eq!(req.max_nodes, 1); assert!(req.include_superseded);
575
576 let req = TraverseBody::default().to_request(id).unwrap();
578 assert_eq!(req.direction, 0);
579 assert_eq!(req.max_depth, DEFAULT_TRAVERSE_DEPTH);
580
581 assert!(
582 TraverseBody {
583 direction: "sideways".into(),
584 ..Default::default()
585 }
586 .to_request(id)
587 .is_err()
588 );
589 assert!(TraverseBody::default().to_request("not-a-uuid").is_err());
590 }
591
592 #[test]
593 fn traverse_response_folds_frames_and_uuidifies() {
594 let step = TraversalStepWire {
595 relation_id: [0x01u8; 16],
596 from: [0x02u8; 16],
597 to: [0x03u8; 16],
598 relation_type: "brain:works_at".into(),
599 depth: 1,
600 };
601 let frames = vec![
602 RelationTraverseResponseFrame {
603 paths: vec![TraversalPathWire {
604 steps: vec![step.clone()],
605 }],
606 total_paths: 2,
607 truncated: false,
608 is_final: false,
609 },
610 RelationTraverseResponseFrame {
611 paths: vec![TraversalPathWire { steps: vec![step] }],
612 total_paths: 2,
613 truncated: true,
614 is_final: true,
615 },
616 ];
617 let dto = TraverseResponseDto::from_frames(frames);
618 assert_eq!(dto.paths.len(), 2);
619 assert_eq!(dto.total_paths, 2);
620 assert!(dto.truncated);
621 assert!(dto.paths[0].steps[0].from.contains('-'));
622 }
623}