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 #[allow(
260 clippy::new_ret_no_self,
261 reason = "ContextBuilder::new() is the builder entry point; it opens the first SectionBuilder"
262 )]
263 pub fn new() -> SectionBuilder {
264 SectionBuilder {
265 label: String::new(),
266 fields: Vec::new(),
267 parent_sections: Vec::new(),
268 }
269 }
270
271 pub fn render(&self, state: &State) -> String {
275 let mut lines: Vec<String> = self
276 .sections
277 .iter()
278 .filter_map(|s| s.render(state))
279 .collect();
280
281 if let Some(needs) = state.get::<Vec<String>>("session:phase_needs") {
285 let missing: Vec<&str> = needs
286 .iter()
287 .filter(|key| !state.contains(key))
288 .map(|s| s.as_str())
289 .collect();
290 if !missing.is_empty() {
291 lines.push(format!("[Gathering] {}", missing.join(", ")));
292 }
293 }
294
295 lines.join("\n")
296 }
297
298 pub fn into_modifier(self) -> super::InstructionModifier {
300 super::InstructionModifier::CustomAppend(Arc::new(move |state: &State| self.render(state)))
301 }
302}
303
304impl std::ops::Add for ContextBuilder {
307 type Output = ContextBuilder;
308
309 fn add(mut self, rhs: ContextBuilder) -> Self::Output {
310 self.sections.extend(rhs.sections);
311 self
312 }
313}
314
315#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::state::State;
321
322 #[test]
323 fn empty_state_returns_empty_string() {
324 let ctx = ContextBuilder::new()
325 .section("Caller")
326 .field("name", "Name")
327 .build();
328
329 let state = State::new();
330 assert_eq!(ctx.render(&state), "");
331 }
332
333 #[test]
334 fn renders_populated_fields() {
335 let ctx = ContextBuilder::new()
336 .section("Caller")
337 .field("name", "Name")
338 .field("org", "Organization")
339 .build();
340
341 let state = State::new();
342 let _ = state.set("name", "Bob");
343 let _ = state.set("org", "Google");
344
345 assert_eq!(
346 ctx.render(&state),
347 "[Caller] Name: Bob. Organization: Google."
348 );
349 }
350
351 #[test]
352 fn skips_missing_fields() {
353 let ctx = ContextBuilder::new()
354 .section("Caller")
355 .field("name", "Name")
356 .field("org", "Organization")
357 .build();
358
359 let state = State::new();
360 let _ = state.set("name", "Bob");
361
362 assert_eq!(ctx.render(&state), "[Caller] Name: Bob.");
363 }
364
365 #[test]
366 fn flag_renders_when_true() {
367 let ctx = ContextBuilder::new()
368 .section("Status")
369 .flag("verified", "Identity verified")
370 .build();
371
372 let state = State::new();
373 let _ = state.set("verified", true);
374
375 assert_eq!(ctx.render(&state), "[Status] Identity verified.");
376 }
377
378 #[test]
379 fn flag_omitted_when_false() {
380 let ctx = ContextBuilder::new()
381 .section("Status")
382 .flag("verified", "Identity verified")
383 .build();
384
385 let state = State::new();
386 let _ = state.set("verified", false);
387
388 assert_eq!(ctx.render(&state), "");
389 }
390
391 #[test]
392 fn sentiment_renders_non_neutral() {
393 let ctx = ContextBuilder::new()
394 .section("Mood")
395 .sentiment("sentiment")
396 .build();
397
398 let state = State::new();
399 let _ = state.set("sentiment", "impatient");
400
401 assert_eq!(ctx.render(&state), "[Mood] Caller seems impatient.");
402 }
403
404 #[test]
405 fn sentiment_skips_neutral() {
406 let ctx = ContextBuilder::new()
407 .section("Mood")
408 .sentiment("sentiment")
409 .build();
410
411 let state = State::new();
412 let _ = state.set("sentiment", "neutral");
413
414 assert_eq!(ctx.render(&state), "");
415 }
416
417 #[test]
418 fn custom_format() {
419 let ctx = ContextBuilder::new()
420 .section("Call")
421 .format("urgency", "Urgency", |v| {
422 let u = v.as_f64().unwrap_or(0.0);
423 if u > 0.7 {
424 format!("high ({u:.1})")
425 } else {
426 String::new()
427 }
428 })
429 .build();
430
431 let state = State::new();
432 let _ = state.set("urgency", 0.9_f64);
433
434 assert_eq!(ctx.render(&state), "[Call] high (0.9)");
435 }
436
437 #[test]
438 fn multiple_sections() {
439 let ctx = ContextBuilder::new()
440 .section("A")
441 .field("x", "X")
442 .section("B")
443 .field("y", "Y")
444 .build();
445
446 let state = State::new();
447 let _ = state.set("x", "1");
448 let _ = state.set("y", "2");
449
450 assert_eq!(ctx.render(&state), "[A] X: 1.\n[B] Y: 2.");
451 }
452
453 #[test]
454 fn empty_section_omitted() {
455 let ctx = ContextBuilder::new()
456 .section("Empty")
457 .field("missing", "Missing")
458 .section("Present")
459 .field("exists", "Exists")
460 .build();
461
462 let state = State::new();
463 let _ = state.set("exists", "yes");
464
465 assert_eq!(ctx.render(&state), "[Present] Exists: yes.");
466 }
467
468 #[test]
469 fn compose_with_add() {
470 let a = ContextBuilder::new().section("A").field("x", "X").build();
471
472 let b = ContextBuilder::new().section("B").field("y", "Y").build();
473
474 let combined = a + b;
475
476 let state = State::new();
477 let _ = state.set("x", "1");
478 let _ = state.set("y", "2");
479
480 assert_eq!(combined.render(&state), "[A] X: 1.\n[B] Y: 2.");
481 }
482
483 #[test]
484 fn phase_needs_shows_gathering() {
485 let ctx = ContextBuilder::new()
486 .section("Caller")
487 .field("name", "Name")
488 .build();
489
490 let state = State::new();
491 let _ = state.set("name", "Bob");
492 let _ = state.set(
493 "session:phase_needs",
494 vec!["name".to_string(), "org".to_string()],
495 );
496
497 let rendered = ctx.render(&state);
498 assert!(rendered.contains("[Caller] Name: Bob."));
499 assert!(rendered.contains("[Gathering] org"));
500 }
501
502 #[test]
503 fn phase_needs_disappears_when_all_gathered() {
504 let ctx = ContextBuilder::new()
505 .section("Caller")
506 .field("name", "Name")
507 .field("org", "Org")
508 .build();
509
510 let state = State::new();
511 let _ = state.set("name", "Bob");
512 let _ = state.set("org", "Google");
513 let _ = state.set(
514 "session:phase_needs",
515 vec!["name".to_string(), "org".to_string()],
516 );
517
518 let rendered = ctx.render(&state);
519 assert!(!rendered.contains("[Gathering]"));
520 }
521}