1use serde::{Deserialize, Serialize};
9
10use super::external_context_validation::{
11 validate_collection_len, validate_text, MAX_BUNDLE_ID_LEN, MAX_ITEMS, MAX_REFERENCES,
12 MAX_SCHEMA_VERSION_LEN,
13};
14use crate::entities::{ContextItem, ContextReference, ContextSummary};
15use crate::error::DomainError;
16use crate::value_objects::Attributes;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ExternalContextBundle {
21 bundle_id: String,
22 schema_version: String,
23 summary: Option<ContextSummary>,
24 #[serde(default)]
25 items: Vec<ContextItem>,
26 #[serde(default)]
27 references: Vec<ContextReference>,
28 #[serde(default)]
29 metadata: Attributes,
30}
31
32impl ExternalContextBundle {
33 pub fn new(
34 bundle_id: impl Into<String>,
35 schema_version: impl Into<String>,
36 summary: Option<ContextSummary>,
37 items: Vec<ContextItem>,
38 references: Vec<ContextReference>,
39 metadata: Attributes,
40 ) -> Result<Self, DomainError> {
41 let bundle_id = bundle_id.into();
42 let bundle_id = validate_text(&bundle_id, "external_context.bundle_id", MAX_BUNDLE_ID_LEN)?;
43 let schema_version = schema_version.into();
44 let schema_version = validate_text(
45 &schema_version,
46 "external_context.schema_version",
47 MAX_SCHEMA_VERSION_LEN,
48 )?;
49 validate_collection_len("external_context.items", items.len(), MAX_ITEMS)?;
50 validate_collection_len(
51 "external_context.references",
52 references.len(),
53 MAX_REFERENCES,
54 )?;
55
56 Ok(Self {
57 bundle_id,
58 schema_version,
59 summary,
60 items,
61 references,
62 metadata,
63 })
64 }
65
66 #[must_use]
67 pub fn bundle_id(&self) -> &str {
68 &self.bundle_id
69 }
70
71 #[must_use]
72 pub fn schema_version(&self) -> &str {
73 &self.schema_version
74 }
75
76 #[must_use]
77 pub fn summary(&self) -> Option<&ContextSummary> {
78 self.summary.as_ref()
79 }
80
81 #[must_use]
82 pub fn items(&self) -> &[ContextItem] {
83 &self.items
84 }
85
86 #[must_use]
87 pub fn references(&self) -> &[ContextReference] {
88 &self.references
89 }
90
91 #[must_use]
92 pub fn metadata(&self) -> &Attributes {
93 &self.metadata
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use serde_json::json;
101 use std::collections::BTreeMap;
102
103 fn attrs(key: &str, value: serde_json::Value) -> Attributes {
104 Attributes::new(BTreeMap::from([(key.to_owned(), value)])).unwrap()
105 }
106
107 fn sample_bundle() -> ExternalContextBundle {
108 ExternalContextBundle::new(
109 "ctx-1",
110 "v1",
111 Some(
112 ContextSummary::new(
113 "Complex state assembled from external systems",
114 attrs("source", json!("external")),
115 )
116 .unwrap(),
117 ),
118 vec![
119 ContextItem::new(
120 "finding-1",
121 "finding",
122 "Primary observation",
123 Some("A recent deployment correlates with the symptom".to_owned()),
124 attrs("score", json!(0.92)),
125 vec!["ref-1".to_owned()],
126 )
127 .unwrap(),
128 ContextItem::new(
129 "decision-1",
130 "decision",
131 "Previous decision",
132 None,
133 attrs("decision", json!("rollback rejected")),
134 vec!["ref-2".to_owned()],
135 )
136 .unwrap(),
137 ],
138 vec![
139 ContextReference::new(
140 "ref-1",
141 "s3://evidence/1.json",
142 Some("evidence snapshot".to_owned()),
143 Some("application/json".to_owned()),
144 Attributes::empty(),
145 )
146 .unwrap(),
147 ContextReference::new(
148 "ref-2",
149 "graph://decision/2",
150 None,
151 None,
152 attrs("kind", json!("decision")),
153 )
154 .unwrap(),
155 ],
156 attrs("bundle_kind", json!("external")),
157 )
158 .unwrap()
159 }
160
161 #[test]
162 fn bundle_preserves_typed_sections() {
163 let bundle = sample_bundle();
164 assert_eq!(bundle.bundle_id(), "ctx-1");
165 assert_eq!(bundle.schema_version(), "v1");
166 assert_eq!(
167 bundle.summary().unwrap().text(),
168 "Complex state assembled from external systems"
169 );
170 assert_eq!(bundle.items().len(), 2);
171 assert_eq!(bundle.references().len(), 2);
172 assert_eq!(bundle.items()[0].kind(), "finding");
173 assert_eq!(
174 bundle.items()[1].attributes().get("decision"),
175 Some(&json!("rollback rejected"))
176 );
177 }
178
179 #[test]
180 fn empty_bundle_id_is_rejected() {
181 let err = ExternalContextBundle::new("", "v1", None, vec![], vec![], Attributes::empty())
182 .unwrap_err();
183 assert!(matches!(
184 err,
185 DomainError::EmptyField {
186 field: "external_context.bundle_id"
187 }
188 ));
189 }
190
191 #[test]
192 fn context_item_requires_kind_and_title() {
193 let err =
194 ContextItem::new("item-1", "", "", None, Attributes::empty(), vec![]).unwrap_err();
195 assert!(matches!(
196 err,
197 DomainError::EmptyField {
198 field: "external_context.item.kind"
199 }
200 ));
201 }
202
203 #[test]
204 fn reference_requires_uri() {
205 let err = ContextReference::new("ref-1", " ", None, None, Attributes::empty()).unwrap_err();
206 assert!(matches!(
207 err,
208 DomainError::EmptyField {
209 field: "external_context.reference.uri"
210 }
211 ));
212 }
213
214 #[test]
215 fn serde_roundtrip_preserves_structure() {
216 let bundle = sample_bundle();
217 let json = serde_json::to_value(&bundle).unwrap();
218 let back: ExternalContextBundle = serde_json::from_value(json).unwrap();
219 assert_eq!(back, bundle);
220 }
221}