1use std::sync::Arc;
47
48use serde_json::Value;
49
50use crate::state::State;
51
52#[derive(Clone)]
56enum FieldKind {
57 Value,
59 Flag,
61 Sentiment,
63 Format(Arc<dyn Fn(&Value) -> String + Send + Sync>),
65}
66
67#[derive(Clone)]
69struct Field {
70 key: String,
71 label: String,
72 kind: FieldKind,
73}
74
75impl Field {
76 fn render(&self, state: &State) -> Option<String> {
77 let val: Option<Value> = state.get(&self.key);
78 match &self.kind {
79 FieldKind::Value => {
80 let val = val?;
81 match &val {
82 Value::String(s) if s.is_empty() => None,
83 Value::String(s) => Some(format!("{}: {s}.", self.label)),
84 Value::Number(n) => Some(format!("{}: {n}.", self.label)),
85 Value::Bool(b) => Some(format!("{}: {b}.", self.label)),
86 Value::Null => None,
87 other => Some(format!("{}: {other}.", self.label)),
88 }
89 }
90 FieldKind::Flag => {
91 let val = val?;
92 if val.as_bool().unwrap_or(false) {
93 Some(format!("{}.", self.label))
94 } else {
95 None
96 }
97 }
98 FieldKind::Sentiment => {
99 let val = val?;
100 let s = val.as_str()?;
101 if s.is_empty() || s == "neutral" || s == "unknown" {
102 None
103 } else {
104 Some(format!("Caller seems {s}."))
105 }
106 }
107 FieldKind::Format(f) => {
108 let val = val?;
109 let rendered = f(&val);
110 if rendered.is_empty() {
111 None
112 } else {
113 Some(rendered)
114 }
115 }
116 }
117 }
118}
119
120#[derive(Clone)]
124struct Section {
125 label: String,
126 fields: Vec<Field>,
127}
128
129impl Section {
130 fn render(&self, state: &State) -> Option<String> {
131 let parts: Vec<String> = self.fields.iter().filter_map(|f| f.render(state)).collect();
132 if parts.is_empty() {
133 None
134 } else {
135 Some(format!("[{}] {}", self.label, parts.join(" ")))
136 }
137 }
138}
139
140pub struct SectionBuilder {
144 label: String,
145 fields: Vec<Field>,
146 parent_sections: Vec<Section>,
147}
148
149impl SectionBuilder {
150 pub fn field(mut self, key: &str, label: &str) -> Self {
152 self.fields.push(Field {
153 key: key.into(),
154 label: label.into(),
155 kind: FieldKind::Value,
156 });
157 self
158 }
159
160 pub fn flag(mut self, key: &str, label: &str) -> Self {
162 self.fields.push(Field {
163 key: key.into(),
164 label: label.into(),
165 kind: FieldKind::Flag,
166 });
167 self
168 }
169
170 pub fn sentiment(mut self, key: &str) -> Self {
172 self.fields.push(Field {
173 key: key.into(),
174 label: "sentiment".into(),
175 kind: FieldKind::Sentiment,
176 });
177 self
178 }
179
180 pub fn format(
185 mut self,
186 key: &str,
187 label: &str,
188 f: impl Fn(&Value) -> String + Send + Sync + 'static,
189 ) -> Self {
190 self.fields.push(Field {
191 key: key.into(),
192 label: label.into(),
193 kind: FieldKind::Format(Arc::new(f)),
194 });
195 self
196 }
197
198 pub fn section(mut self, label: &str) -> SectionBuilder {
200 if !self.fields.is_empty() {
202 self.parent_sections.push(Section {
203 label: self.label,
204 fields: self.fields,
205 });
206 }
207 SectionBuilder {
208 label: label.into(),
209 fields: Vec::new(),
210 parent_sections: self.parent_sections,
211 }
212 }
213
214 pub fn build(mut self) -> ContextBuilder {
216 if !self.fields.is_empty() {
218 self.parent_sections.push(Section {
219 label: self.label,
220 fields: self.fields,
221 });
222 }
223 ContextBuilder {
224 sections: self.parent_sections,
225 }
226 }
227}
228
229#[derive(Clone, Default)]
253pub struct ContextBuilder {
254 sections: Vec<Section>,
255}
256
257impl ContextBuilder {
258 pub fn new() -> SectionBuilder {
260 SectionBuilder {
261 label: String::new(),
262 fields: Vec::new(),
263 parent_sections: Vec::new(),
264 }
265 }
266
267 pub fn render(&self, state: &State) -> String {
271 let mut lines: Vec<String> = self
272 .sections
273 .iter()
274 .filter_map(|s| s.render(state))
275 .collect();
276
277 if let Some(needs) = state.get::<Vec<String>>("session:phase_needs") {
281 let missing: Vec<&str> = needs
282 .iter()
283 .filter(|key| !state.contains(key))
284 .map(|s| s.as_str())
285 .collect();
286 if !missing.is_empty() {
287 lines.push(format!("[Gathering] {}", missing.join(", ")));
288 }
289 }
290
291 lines.join("\n")
292 }
293
294 pub fn into_modifier(self) -> super::InstructionModifier {
296 super::InstructionModifier::CustomAppend(Arc::new(move |state: &State| self.render(state)))
297 }
298}
299
300impl std::ops::Add for ContextBuilder {
303 type Output = ContextBuilder;
304
305 fn add(mut self, rhs: ContextBuilder) -> Self::Output {
306 self.sections.extend(rhs.sections);
307 self
308 }
309}
310
311#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::state::State;
317
318 #[test]
319 fn empty_state_returns_empty_string() {
320 let ctx = ContextBuilder::new()
321 .section("Caller")
322 .field("name", "Name")
323 .build();
324
325 let state = State::new();
326 assert_eq!(ctx.render(&state), "");
327 }
328
329 #[test]
330 fn renders_populated_fields() {
331 let ctx = ContextBuilder::new()
332 .section("Caller")
333 .field("name", "Name")
334 .field("org", "Organization")
335 .build();
336
337 let state = State::new();
338 state.set("name", "Bob");
339 state.set("org", "Google");
340
341 assert_eq!(
342 ctx.render(&state),
343 "[Caller] Name: Bob. Organization: Google."
344 );
345 }
346
347 #[test]
348 fn skips_missing_fields() {
349 let ctx = ContextBuilder::new()
350 .section("Caller")
351 .field("name", "Name")
352 .field("org", "Organization")
353 .build();
354
355 let state = State::new();
356 state.set("name", "Bob");
357
358 assert_eq!(ctx.render(&state), "[Caller] Name: Bob.");
359 }
360
361 #[test]
362 fn flag_renders_when_true() {
363 let ctx = ContextBuilder::new()
364 .section("Status")
365 .flag("verified", "Identity verified")
366 .build();
367
368 let state = State::new();
369 state.set("verified", true);
370
371 assert_eq!(ctx.render(&state), "[Status] Identity verified.");
372 }
373
374 #[test]
375 fn flag_omitted_when_false() {
376 let ctx = ContextBuilder::new()
377 .section("Status")
378 .flag("verified", "Identity verified")
379 .build();
380
381 let state = State::new();
382 state.set("verified", false);
383
384 assert_eq!(ctx.render(&state), "");
385 }
386
387 #[test]
388 fn sentiment_renders_non_neutral() {
389 let ctx = ContextBuilder::new()
390 .section("Mood")
391 .sentiment("sentiment")
392 .build();
393
394 let state = State::new();
395 state.set("sentiment", "impatient");
396
397 assert_eq!(ctx.render(&state), "[Mood] Caller seems impatient.");
398 }
399
400 #[test]
401 fn sentiment_skips_neutral() {
402 let ctx = ContextBuilder::new()
403 .section("Mood")
404 .sentiment("sentiment")
405 .build();
406
407 let state = State::new();
408 state.set("sentiment", "neutral");
409
410 assert_eq!(ctx.render(&state), "");
411 }
412
413 #[test]
414 fn custom_format() {
415 let ctx = ContextBuilder::new()
416 .section("Call")
417 .format("urgency", "Urgency", |v| {
418 let u = v.as_f64().unwrap_or(0.0);
419 if u > 0.7 {
420 format!("high ({u:.1})")
421 } else {
422 String::new()
423 }
424 })
425 .build();
426
427 let state = State::new();
428 state.set("urgency", 0.9_f64);
429
430 assert_eq!(ctx.render(&state), "[Call] high (0.9)");
431 }
432
433 #[test]
434 fn multiple_sections() {
435 let ctx = ContextBuilder::new()
436 .section("A")
437 .field("x", "X")
438 .section("B")
439 .field("y", "Y")
440 .build();
441
442 let state = State::new();
443 state.set("x", "1");
444 state.set("y", "2");
445
446 assert_eq!(ctx.render(&state), "[A] X: 1.\n[B] Y: 2.");
447 }
448
449 #[test]
450 fn empty_section_omitted() {
451 let ctx = ContextBuilder::new()
452 .section("Empty")
453 .field("missing", "Missing")
454 .section("Present")
455 .field("exists", "Exists")
456 .build();
457
458 let state = State::new();
459 state.set("exists", "yes");
460
461 assert_eq!(ctx.render(&state), "[Present] Exists: yes.");
462 }
463
464 #[test]
465 fn compose_with_add() {
466 let a = ContextBuilder::new().section("A").field("x", "X").build();
467
468 let b = ContextBuilder::new().section("B").field("y", "Y").build();
469
470 let combined = a + b;
471
472 let state = State::new();
473 state.set("x", "1");
474 state.set("y", "2");
475
476 assert_eq!(combined.render(&state), "[A] X: 1.\n[B] Y: 2.");
477 }
478
479 #[test]
480 fn phase_needs_shows_gathering() {
481 let ctx = ContextBuilder::new()
482 .section("Caller")
483 .field("name", "Name")
484 .build();
485
486 let state = State::new();
487 state.set("name", "Bob");
488 state.set(
489 "session:phase_needs",
490 vec!["name".to_string(), "org".to_string()],
491 );
492
493 let rendered = ctx.render(&state);
494 assert!(rendered.contains("[Caller] Name: Bob."));
495 assert!(rendered.contains("[Gathering] org"));
496 }
497
498 #[test]
499 fn phase_needs_disappears_when_all_gathered() {
500 let ctx = ContextBuilder::new()
501 .section("Caller")
502 .field("name", "Name")
503 .field("org", "Org")
504 .build();
505
506 let state = State::new();
507 state.set("name", "Bob");
508 state.set("org", "Google");
509 state.set(
510 "session:phase_needs",
511 vec!["name".to_string(), "org".to_string()],
512 );
513
514 let rendered = ctx.render(&state);
515 assert!(!rendered.contains("[Gathering]"));
516 }
517}