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