1use crate::{ConversationView, ConvoError, Result};
9use std::any::Any;
10
11pub trait ConversationProjector {
34 type Output;
36
37 fn project(&self, view: &ConversationView) -> Result<Self::Output>;
39}
40
41trait ErasedProjector: Send + Sync {
44 fn project_erased(&self, view: &ConversationView) -> Result<Box<dyn Any>>;
45}
46
47struct ErasedWrapper<P>(P);
48
49impl<P> ErasedProjector for ErasedWrapper<P>
50where
51 P: ConversationProjector + Send + Sync,
52 P::Output: 'static,
53{
54 fn project_erased(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
55 self.0
56 .project(view)
57 .map(|out| Box::new(out) as Box<dyn Any>)
58 }
59}
60
61pub struct AnyProjector {
94 inner: Box<dyn ErasedProjector>,
95}
96
97impl AnyProjector {
98 pub fn new<P>(projector: P) -> Self
100 where
101 P: ConversationProjector + Send + Sync + 'static,
102 P::Output: 'static,
103 {
104 Self {
105 inner: Box::new(ErasedWrapper(projector)),
106 }
107 }
108
109 pub fn project(&self, view: &ConversationView) -> Result<Box<dyn Any>> {
114 self.inner.project_erased(view)
115 }
116
117 pub fn project_as<T: 'static>(&self, view: &ConversationView) -> Result<T> {
122 let boxed = self.project(view)?;
123 boxed.downcast::<T>().map(|b| *b).map_err(|_| {
124 ConvoError::Provider(format!(
125 "AnyProjector::project_as: output is not of type {}",
126 std::any::type_name::<T>()
127 ))
128 })
129 }
130}
131
132#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::{Role, TokenUsage, ToolInvocation, ToolResult, Turn};
138
139 fn empty_view() -> ConversationView {
142 ConversationView {
143 id: "sess-1".into(),
144 started_at: None,
145 last_activity: None,
146 turns: vec![],
147 total_usage: None,
148 provider_id: None,
149 files_changed: vec![],
150 session_ids: vec![],
151 events: vec![],
152 ..Default::default()
153 }
154 }
155
156 fn make_turn(id: &str, role: Role, text: &str) -> Turn {
157 Turn {
158 id: id.into(),
159 parent_id: None,
160 group_id: None,
161 role,
162 timestamp: "2026-01-01T00:00:00Z".into(),
163 text: text.into(),
164 thinking: None,
165 tool_uses: vec![],
166 model: None,
167 stop_reason: None,
168 token_usage: None,
169 attributed_token_usage: None,
170 environment: None,
171 delegations: vec![],
172 file_mutations: Vec::new(),
173 }
174 }
175
176 fn view_with_turns() -> ConversationView {
177 ConversationView {
178 id: "sess-2".into(),
179 started_at: None,
180 last_activity: None,
181 turns: vec![
182 make_turn("t1", Role::User, "hello"),
183 make_turn("t2", Role::Assistant, "world"),
184 make_turn("t3", Role::User, "done"),
185 ],
186 total_usage: None,
187 provider_id: Some("test-provider".into()),
188 files_changed: vec![],
189 session_ids: vec![],
190 events: vec![],
191 ..Default::default()
192 }
193 }
194
195 struct TurnCounter;
198 impl ConversationProjector for TurnCounter {
199 type Output = usize;
200 fn project(&self, view: &ConversationView) -> Result<usize> {
201 Ok(view.turns.len())
202 }
203 }
204
205 struct ProviderIdExtractor;
206 impl ConversationProjector for ProviderIdExtractor {
207 type Output = Option<String>;
208 fn project(&self, view: &ConversationView) -> Result<Option<String>> {
209 Ok(view.provider_id.clone())
210 }
211 }
212
213 struct AlwaysFails;
214 impl ConversationProjector for AlwaysFails {
215 type Output = String;
216 fn project(&self, _view: &ConversationView) -> Result<String> {
217 Err(ConvoError::Provider("intentional failure".into()))
218 }
219 }
220
221 #[test]
224 fn test_concrete_projector_empty() {
225 let proj = TurnCounter;
226 let count = proj.project(&empty_view()).unwrap();
227 assert_eq!(count, 0);
228 }
229
230 #[test]
231 fn test_concrete_projector_with_turns() {
232 let proj = TurnCounter;
233 let count = proj.project(&view_with_turns()).unwrap();
234 assert_eq!(count, 3);
235 }
236
237 #[test]
238 fn test_concrete_projector_option_output() {
239 let proj = ProviderIdExtractor;
240 let id = proj.project(&view_with_turns()).unwrap();
241 assert_eq!(id.as_deref(), Some("test-provider"));
242
243 let id_none = proj.project(&empty_view()).unwrap();
244 assert!(id_none.is_none());
245 }
246
247 #[test]
250 fn test_any_projector_project_returns_box_any() {
251 let any = AnyProjector::new(TurnCounter);
252 let boxed = any.project(&view_with_turns()).unwrap();
253 let count = boxed.downcast::<usize>().unwrap();
255 assert_eq!(*count, 3);
256 }
257
258 #[test]
259 fn test_any_projector_project_empty() {
260 let any = AnyProjector::new(TurnCounter);
261 let boxed = any.project(&empty_view()).unwrap();
262 let count = boxed.downcast::<usize>().unwrap();
263 assert_eq!(*count, 0);
264 }
265
266 #[test]
269 fn test_any_projector_project_as_success() {
270 let any = AnyProjector::new(TurnCounter);
271 let count: usize = any.project_as(&view_with_turns()).unwrap();
272 assert_eq!(count, 3);
273 }
274
275 #[test]
276 fn test_any_projector_project_as_option_output() {
277 let any = AnyProjector::new(ProviderIdExtractor);
278 let id: Option<String> = any.project_as(&view_with_turns()).unwrap();
279 assert_eq!(id.as_deref(), Some("test-provider"));
280 }
281
282 #[test]
285 fn test_any_projector_project_as_wrong_type() {
286 let any = AnyProjector::new(TurnCounter); let result: Result<String> = any.project_as(&view_with_turns()); assert!(result.is_err());
289 let err = result.unwrap_err();
290 assert!(matches!(err, ConvoError::Provider(_)));
292 let msg = err.to_string();
293 assert!(msg.contains("AnyProjector::project_as"), "msg was: {}", msg);
294 }
295
296 #[test]
297 fn test_any_projector_project_as_wrong_type_bool() {
298 let any = AnyProjector::new(ProviderIdExtractor); let result: Result<bool> = any.project_as(&view_with_turns());
300 assert!(result.is_err());
301 }
302
303 struct TextCollector;
306 impl ConversationProjector for TextCollector {
307 type Output = Vec<String>;
308 fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
309 Ok(view.turns.iter().map(|t| t.text.clone()).collect())
310 }
311 }
312
313 struct ToolNameCollector;
314 impl ConversationProjector for ToolNameCollector {
315 type Output = Vec<String>;
316 fn project(&self, view: &ConversationView) -> Result<Vec<String>> {
317 Ok(view
318 .turns
319 .iter()
320 .flat_map(|t| t.tool_uses.iter().map(|u| u.name.clone()))
321 .collect())
322 }
323 }
324
325 #[test]
326 fn test_any_projector_with_turn_text_data() {
327 let any = AnyProjector::new(TextCollector);
328 let texts: Vec<String> = any.project_as(&view_with_turns()).unwrap();
329 assert_eq!(texts, vec!["hello", "world", "done"]);
330 }
331
332 #[test]
333 fn test_any_projector_with_tool_use_data() {
334 let view = ConversationView {
335 id: "s3".into(),
336 started_at: None,
337 last_activity: None,
338 events: vec![],
339 turns: vec![Turn {
340 id: "t1".into(),
341 parent_id: None,
342 group_id: None,
343 role: Role::Assistant,
344 timestamp: "2026-01-01T00:00:00Z".into(),
345 text: "reading file".into(),
346 thinking: None,
347 tool_uses: vec![
348 ToolInvocation {
349 id: "u1".into(),
350 name: "Read".into(),
351 input: serde_json::json!({"file": "src/main.rs"}),
352 result: Some(ToolResult {
353 content: "fn main() {}".into(),
354 is_error: false,
355 }),
356 category: None,
357 },
358 ToolInvocation {
359 id: "u2".into(),
360 name: "Bash".into(),
361 input: serde_json::json!({"command": "cargo test"}),
362 result: None,
363 category: None,
364 },
365 ],
366 model: None,
367 stop_reason: None,
368 token_usage: None,
369 attributed_token_usage: None,
370 environment: None,
371 delegations: vec![],
372 file_mutations: Vec::new(),
373 }],
374 total_usage: None,
375 provider_id: None,
376 files_changed: vec![],
377 session_ids: vec![],
378 ..Default::default()
379 };
380
381 let any = AnyProjector::new(ToolNameCollector);
382 let names: Vec<String> = any.project_as(&view).unwrap();
383 assert_eq!(names, vec!["Read", "Bash"]);
384 }
385
386 #[test]
387 fn test_any_projector_propagates_projector_error() {
388 let any = AnyProjector::new(AlwaysFails);
389 let result: Result<String> = any.project_as(&empty_view());
390 assert!(result.is_err());
391 assert!(matches!(result.unwrap_err(), ConvoError::Provider(_)));
392 }
393
394 #[test]
395 fn test_any_projector_with_token_usage() {
396 struct TotalInputTokens;
397 impl ConversationProjector for TotalInputTokens {
398 type Output = u32;
399 fn project(&self, view: &ConversationView) -> Result<u32> {
400 Ok(view
401 .turns
402 .iter()
403 .filter_map(|t| t.token_usage.as_ref())
404 .filter_map(|u| u.input_tokens)
405 .sum())
406 }
407 }
408
409 let view = ConversationView {
410 id: "s4".into(),
411 started_at: None,
412 last_activity: None,
413 events: vec![],
414 turns: vec![
415 Turn {
416 id: "t1".into(),
417 parent_id: None,
418 group_id: None,
419 role: Role::Assistant,
420 timestamp: "2026-01-01T00:00:00Z".into(),
421 text: "turn 1".into(),
422 thinking: None,
423 tool_uses: vec![],
424 model: None,
425 stop_reason: None,
426 token_usage: Some(TokenUsage {
427 input_tokens: Some(100),
428 output_tokens: Some(50),
429 cache_read_tokens: None,
430 cache_write_tokens: None,
431 ..Default::default()
432 }),
433 attributed_token_usage: None,
434 environment: None,
435 delegations: vec![],
436 file_mutations: Vec::new(),
437 },
438 Turn {
439 id: "t2".into(),
440 parent_id: Some("t1".into()),
441 group_id: None,
442 role: Role::Assistant,
443 timestamp: "2026-01-01T00:00:01Z".into(),
444 text: "turn 2".into(),
445 thinking: None,
446 tool_uses: vec![],
447 model: None,
448 stop_reason: None,
449 token_usage: Some(TokenUsage {
450 input_tokens: Some(200),
451 output_tokens: Some(75),
452 cache_read_tokens: None,
453 cache_write_tokens: None,
454 ..Default::default()
455 }),
456 attributed_token_usage: None,
457 environment: None,
458 delegations: vec![],
459 file_mutations: Vec::new(),
460 },
461 ],
462 total_usage: None,
463 provider_id: None,
464 files_changed: vec![],
465 session_ids: vec![],
466 ..Default::default()
467 };
468
469 let any = AnyProjector::new(TotalInputTokens);
470 let total: u32 = any.project_as(&view).unwrap();
471 assert_eq!(total, 300);
472 }
473}