1use serde::{Deserialize, Serialize};
9
10use crate::error::DomainError;
11use crate::value_objects::Attributes;
12
13const MAX_BUNDLE_ID_LEN: usize = 128;
14const MAX_SCHEMA_VERSION_LEN: usize = 64;
15const MAX_ITEM_ID_LEN: usize = 128;
16const MAX_ITEM_KIND_LEN: usize = 64;
17const MAX_ITEM_TITLE_LEN: usize = 256;
18const MAX_REFERENCE_ID_LEN: usize = 128;
19const MAX_URI_LEN: usize = 2048;
20const MAX_ITEMS: usize = 256;
21const MAX_REFERENCES: usize = 512;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ExternalContextBundle {
26 bundle_id: String,
27 schema_version: String,
28 summary: Option<ContextSummary>,
29 #[serde(default)]
30 items: Vec<ContextItem>,
31 #[serde(default)]
32 references: Vec<ContextReference>,
33 #[serde(default)]
34 metadata: Attributes,
35}
36
37impl ExternalContextBundle {
38 pub fn new(
39 bundle_id: impl Into<String>,
40 schema_version: impl Into<String>,
41 summary: Option<ContextSummary>,
42 items: Vec<ContextItem>,
43 references: Vec<ContextReference>,
44 metadata: Attributes,
45 ) -> Result<Self, DomainError> {
46 let bundle_id = bundle_id.into();
47 let bundle_id = validate_text(&bundle_id, "external_context.bundle_id", MAX_BUNDLE_ID_LEN)?;
48 let schema_version = schema_version.into();
49 let schema_version = validate_text(
50 &schema_version,
51 "external_context.schema_version",
52 MAX_SCHEMA_VERSION_LEN,
53 )?;
54 validate_collection_len("external_context.items", items.len(), MAX_ITEMS)?;
55 validate_collection_len(
56 "external_context.references",
57 references.len(),
58 MAX_REFERENCES,
59 )?;
60
61 Ok(Self {
62 bundle_id,
63 schema_version,
64 summary,
65 items,
66 references,
67 metadata,
68 })
69 }
70
71 #[must_use]
72 pub fn bundle_id(&self) -> &str {
73 &self.bundle_id
74 }
75
76 #[must_use]
77 pub fn schema_version(&self) -> &str {
78 &self.schema_version
79 }
80
81 #[must_use]
82 pub fn summary(&self) -> Option<&ContextSummary> {
83 self.summary.as_ref()
84 }
85
86 #[must_use]
87 pub fn items(&self) -> &[ContextItem] {
88 &self.items
89 }
90
91 #[must_use]
92 pub fn references(&self) -> &[ContextReference] {
93 &self.references
94 }
95
96 #[must_use]
97 pub fn metadata(&self) -> &Attributes {
98 &self.metadata
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct ContextSummary {
105 text: String,
106 #[serde(default)]
107 attributes: Attributes,
108}
109
110impl ContextSummary {
111 pub fn new(text: impl Into<String>, attributes: Attributes) -> Result<Self, DomainError> {
112 let text = text.into();
113 Ok(Self {
114 text: validate_text(&text, "external_context.summary.text", MAX_ITEM_TITLE_LEN)?,
115 attributes,
116 })
117 }
118
119 #[must_use]
120 pub fn text(&self) -> &str {
121 &self.text
122 }
123
124 #[must_use]
125 pub fn attributes(&self) -> &Attributes {
126 &self.attributes
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ContextItem {
133 item_id: String,
134 kind: String,
135 title: String,
136 narrative: Option<String>,
137 #[serde(default)]
138 attributes: Attributes,
139 #[serde(default)]
140 reference_ids: Vec<String>,
141}
142
143impl ContextItem {
144 pub fn new(
145 item_id: impl Into<String>,
146 kind: impl Into<String>,
147 title: impl Into<String>,
148 narrative: Option<String>,
149 attributes: Attributes,
150 reference_ids: Vec<String>,
151 ) -> Result<Self, DomainError> {
152 let reference_ids = reference_ids
153 .into_iter()
154 .map(|reference_id| {
155 validate_text(
156 &reference_id,
157 "external_context.item.reference_id",
158 MAX_REFERENCE_ID_LEN,
159 )
160 })
161 .collect::<Result<Vec<_>, _>>()?;
162 let item_id = item_id.into();
163 let kind = kind.into();
164 let title = title.into();
165
166 Ok(Self {
167 item_id: validate_text(&item_id, "external_context.item_id", MAX_ITEM_ID_LEN)?,
168 kind: validate_text(&kind, "external_context.item.kind", MAX_ITEM_KIND_LEN)?,
169 title: validate_text(&title, "external_context.item.title", MAX_ITEM_TITLE_LEN)?,
170 narrative: narrative.and_then(|text| {
171 let trimmed = text.trim().to_owned();
172 if trimmed.is_empty() {
173 None
174 } else {
175 Some(trimmed)
176 }
177 }),
178 attributes,
179 reference_ids,
180 })
181 }
182
183 #[must_use]
184 pub fn item_id(&self) -> &str {
185 &self.item_id
186 }
187
188 #[must_use]
189 pub fn kind(&self) -> &str {
190 &self.kind
191 }
192
193 #[must_use]
194 pub fn title(&self) -> &str {
195 &self.title
196 }
197
198 #[must_use]
199 pub fn narrative(&self) -> Option<&str> {
200 self.narrative.as_deref()
201 }
202
203 #[must_use]
204 pub fn attributes(&self) -> &Attributes {
205 &self.attributes
206 }
207
208 #[must_use]
209 pub fn reference_ids(&self) -> &[String] {
210 &self.reference_ids
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ContextReference {
217 reference_id: String,
218 uri: String,
219 title: Option<String>,
220 media_type: Option<String>,
221 #[serde(default)]
222 attributes: Attributes,
223}
224
225impl ContextReference {
226 pub fn new(
227 reference_id: impl Into<String>,
228 uri: impl Into<String>,
229 title: Option<String>,
230 media_type: Option<String>,
231 attributes: Attributes,
232 ) -> Result<Self, DomainError> {
233 let reference_id = reference_id.into();
234 let uri = uri.into();
235 Ok(Self {
236 reference_id: validate_text(
237 &reference_id,
238 "external_context.reference_id",
239 MAX_REFERENCE_ID_LEN,
240 )?,
241 uri: validate_text(&uri, "external_context.reference.uri", MAX_URI_LEN)?,
242 title: normalize_optional(title),
243 media_type: normalize_optional(media_type),
244 attributes,
245 })
246 }
247
248 #[must_use]
249 pub fn reference_id(&self) -> &str {
250 &self.reference_id
251 }
252
253 #[must_use]
254 pub fn uri(&self) -> &str {
255 &self.uri
256 }
257
258 #[must_use]
259 pub fn title(&self) -> Option<&str> {
260 self.title.as_deref()
261 }
262
263 #[must_use]
264 pub fn media_type(&self) -> Option<&str> {
265 self.media_type.as_deref()
266 }
267
268 #[must_use]
269 pub fn attributes(&self) -> &Attributes {
270 &self.attributes
271 }
272}
273
274fn validate_text(value: &str, field: &'static str, max_len: usize) -> Result<String, DomainError> {
275 let trimmed = value.trim();
276 if trimmed.is_empty() {
277 return Err(DomainError::EmptyField { field });
278 }
279 if trimmed.len() > max_len {
280 return Err(DomainError::FieldTooLong {
281 field,
282 actual: trimmed.len(),
283 max: max_len,
284 });
285 }
286 Ok(trimmed.to_owned())
287}
288
289fn validate_collection_len(
290 field: &'static str,
291 actual: usize,
292 max: usize,
293) -> Result<(), DomainError> {
294 if actual > max {
295 return Err(DomainError::OutOfRange {
296 field,
297 value: actual as f64,
298 min: 0.0,
299 max: max as f64,
300 });
301 }
302 Ok(())
303}
304
305fn normalize_optional(value: Option<String>) -> Option<String> {
306 value.and_then(|value| {
307 let trimmed = value.trim().to_owned();
308 if trimmed.is_empty() {
309 None
310 } else {
311 Some(trimmed)
312 }
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use serde_json::json;
320 use std::collections::BTreeMap;
321
322 fn attrs(key: &str, value: serde_json::Value) -> Attributes {
323 Attributes::new(BTreeMap::from([(key.to_owned(), value)])).unwrap()
324 }
325
326 fn sample_bundle() -> ExternalContextBundle {
327 ExternalContextBundle::new(
328 "ctx-1",
329 "v1",
330 Some(
331 ContextSummary::new(
332 "Complex state assembled from external systems",
333 attrs("source", json!("kernel")),
334 )
335 .unwrap(),
336 ),
337 vec![
338 ContextItem::new(
339 "finding-1",
340 "finding",
341 "Primary observation",
342 Some("A recent deployment correlates with the symptom".to_owned()),
343 attrs("score", json!(0.92)),
344 vec!["ref-1".to_owned()],
345 )
346 .unwrap(),
347 ContextItem::new(
348 "decision-1",
349 "decision",
350 "Previous decision",
351 None,
352 attrs("decision", json!("rollback rejected")),
353 vec!["ref-2".to_owned()],
354 )
355 .unwrap(),
356 ],
357 vec![
358 ContextReference::new(
359 "ref-1",
360 "s3://evidence/1.json",
361 Some("evidence snapshot".to_owned()),
362 Some("application/json".to_owned()),
363 Attributes::empty(),
364 )
365 .unwrap(),
366 ContextReference::new(
367 "ref-2",
368 "graph://decision/2",
369 None,
370 None,
371 attrs("kind", json!("decision")),
372 )
373 .unwrap(),
374 ],
375 attrs("bundle_kind", json!("external")),
376 )
377 .unwrap()
378 }
379
380 #[test]
381 fn bundle_preserves_typed_sections() {
382 let bundle = sample_bundle();
383 assert_eq!(bundle.bundle_id(), "ctx-1");
384 assert_eq!(bundle.schema_version(), "v1");
385 assert_eq!(
386 bundle.summary().unwrap().text(),
387 "Complex state assembled from external systems"
388 );
389 assert_eq!(bundle.items().len(), 2);
390 assert_eq!(bundle.references().len(), 2);
391 assert_eq!(bundle.items()[0].kind(), "finding");
392 assert_eq!(
393 bundle.items()[1].attributes().get("decision"),
394 Some(&json!("rollback rejected"))
395 );
396 }
397
398 #[test]
399 fn empty_bundle_id_is_rejected() {
400 let err = ExternalContextBundle::new("", "v1", None, vec![], vec![], Attributes::empty())
401 .unwrap_err();
402 assert!(matches!(
403 err,
404 DomainError::EmptyField {
405 field: "external_context.bundle_id"
406 }
407 ));
408 }
409
410 #[test]
411 fn context_item_requires_kind_and_title() {
412 let err =
413 ContextItem::new("item-1", "", "", None, Attributes::empty(), vec![]).unwrap_err();
414 assert!(matches!(
415 err,
416 DomainError::EmptyField {
417 field: "external_context.item.kind"
418 }
419 ));
420 }
421
422 #[test]
423 fn reference_requires_uri() {
424 let err = ContextReference::new("ref-1", " ", None, None, Attributes::empty()).unwrap_err();
425 assert!(matches!(
426 err,
427 DomainError::EmptyField {
428 field: "external_context.reference.uri"
429 }
430 ));
431 }
432
433 #[test]
434 fn serde_roundtrip_preserves_structure() {
435 let bundle = sample_bundle();
436 let json = serde_json::to_value(&bundle).unwrap();
437 let back: ExternalContextBundle = serde_json::from_value(json).unwrap();
438 assert_eq!(back, bundle);
439 }
440}