1use std::fmt;
5
6#[derive(Debug, Clone)]
8pub struct DiffData {
9 pub file_path: String,
10 pub old_content: String,
11 pub new_content: String,
12}
13
14#[derive(Debug, Clone)]
16pub struct ToolCall {
17 pub tool_id: String,
18 pub params: serde_json::Map<String, serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Default)]
23pub struct FilterStats {
24 pub raw_chars: usize,
25 pub filtered_chars: usize,
26 pub raw_lines: usize,
27 pub filtered_lines: usize,
28 pub confidence: Option<crate::FilterConfidence>,
29 pub command: Option<String>,
30 pub kept_lines: Vec<usize>,
31}
32
33impl FilterStats {
34 #[must_use]
35 #[allow(clippy::cast_precision_loss)]
36 pub fn savings_pct(&self) -> f64 {
37 if self.raw_chars == 0 {
38 return 0.0;
39 }
40 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
41 }
42
43 #[must_use]
44 pub fn estimated_tokens_saved(&self) -> usize {
45 self.raw_chars.saturating_sub(self.filtered_chars) / 4
46 }
47
48 #[must_use]
49 pub fn format_inline(&self, tool_name: &str) -> String {
50 let cmd_label = self
51 .command
52 .as_deref()
53 .map(|c| {
54 let trimmed = c.trim();
55 if trimmed.len() > 60 {
56 format!(" `{}…`", &trimmed[..57])
57 } else {
58 format!(" `{trimmed}`")
59 }
60 })
61 .unwrap_or_default();
62 format!(
63 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
64 self.raw_lines,
65 self.filtered_lines,
66 self.savings_pct()
67 )
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct ToolOutput {
74 pub tool_name: String,
75 pub summary: String,
76 pub blocks_executed: u32,
77 pub filter_stats: Option<FilterStats>,
78 pub diff: Option<DiffData>,
79 pub streamed: bool,
81 pub terminal_id: Option<String>,
83 pub locations: Option<Vec<String>>,
85 pub raw_response: Option<serde_json::Value>,
87}
88
89impl fmt::Display for ToolOutput {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(&self.summary)
92 }
93}
94
95pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
96
97#[must_use]
99pub fn truncate_tool_output(output: &str) -> String {
100 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
101}
102
103#[must_use]
105pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
106 if output.len() <= max_chars {
107 return output.to_string();
108 }
109
110 let half = max_chars / 2;
111 let head_end = output.floor_char_boundary(half);
112 let tail_start = output.ceil_char_boundary(output.len() - half);
113 let head = &output[..head_end];
114 let tail = &output[tail_start..];
115 let truncated = output.len() - head_end - (output.len() - tail_start);
116
117 format!(
118 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
119 )
120}
121
122#[derive(Debug, Clone)]
124pub enum ToolEvent {
125 Started {
126 tool_name: String,
127 command: String,
128 },
129 OutputChunk {
130 tool_name: String,
131 command: String,
132 chunk: String,
133 },
134 Completed {
135 tool_name: String,
136 command: String,
137 output: String,
138 success: bool,
139 filter_stats: Option<FilterStats>,
140 diff: Option<DiffData>,
141 },
142}
143
144pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
151pub enum ErrorKind {
152 Transient,
153 Permanent,
154}
155
156impl std::fmt::Display for ErrorKind {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Transient => f.write_str("transient"),
160 Self::Permanent => f.write_str("permanent"),
161 }
162 }
163}
164
165#[derive(Debug, thiserror::Error)]
167pub enum ToolError {
168 #[error("command blocked by policy: {command}")]
169 Blocked { command: String },
170
171 #[error("path not allowed by sandbox: {path}")]
172 SandboxViolation { path: String },
173
174 #[error("command requires confirmation: {command}")]
175 ConfirmationRequired { command: String },
176
177 #[error("command timed out after {timeout_secs}s")]
178 Timeout { timeout_secs: u64 },
179
180 #[error("operation cancelled")]
181 Cancelled,
182
183 #[error("invalid tool parameters: {message}")]
184 InvalidParams { message: String },
185
186 #[error("execution failed: {0}")]
187 Execution(#[from] std::io::Error),
188}
189
190impl ToolError {
191 #[must_use]
199 pub fn kind(&self) -> ErrorKind {
200 match self {
201 Self::Timeout { .. } => ErrorKind::Transient,
202 Self::Execution(io_err) => match io_err.kind() {
203 std::io::ErrorKind::TimedOut
204 | std::io::ErrorKind::WouldBlock
205 | std::io::ErrorKind::Interrupted
206 | std::io::ErrorKind::ConnectionReset
207 | std::io::ErrorKind::ConnectionAborted
208 | std::io::ErrorKind::BrokenPipe => ErrorKind::Transient,
209 _ => ErrorKind::Permanent,
211 },
212 Self::Blocked { .. }
213 | Self::SandboxViolation { .. }
214 | Self::ConfirmationRequired { .. }
215 | Self::Cancelled
216 | Self::InvalidParams { .. } => ErrorKind::Permanent,
217 }
218 }
219}
220
221pub fn deserialize_params<T: serde::de::DeserializeOwned>(
227 params: &serde_json::Map<String, serde_json::Value>,
228) -> Result<T, ToolError> {
229 let obj = serde_json::Value::Object(params.clone());
230 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
231 message: e.to_string(),
232 })
233}
234
235pub trait ToolExecutor: Send + Sync {
240 fn execute(
241 &self,
242 response: &str,
243 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
244
245 fn execute_confirmed(
248 &self,
249 response: &str,
250 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
251 self.execute(response)
252 }
253
254 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
256 vec![]
257 }
258
259 fn execute_tool_call(
261 &self,
262 _call: &ToolCall,
263 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
264 std::future::ready(Ok(None))
265 }
266
267 fn execute_tool_call_confirmed(
272 &self,
273 call: &ToolCall,
274 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
275 self.execute_tool_call(call)
276 }
277
278 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
280
281 fn set_effective_trust(&self, _level: crate::TrustLevel) {}
283
284 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
290 false
291 }
292}
293
294pub trait ErasedToolExecutor: Send + Sync {
299 fn execute_erased<'a>(
300 &'a self,
301 response: &'a str,
302 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
303
304 fn execute_confirmed_erased<'a>(
305 &'a self,
306 response: &'a str,
307 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
308
309 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
310
311 fn execute_tool_call_erased<'a>(
312 &'a self,
313 call: &'a ToolCall,
314 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
315
316 fn execute_tool_call_confirmed_erased<'a>(
317 &'a self,
318 call: &'a ToolCall,
319 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
320 {
321 self.execute_tool_call_erased(call)
325 }
326
327 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
329
330 fn set_effective_trust(&self, _level: crate::TrustLevel) {}
332
333 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
335}
336
337impl<T: ToolExecutor> ErasedToolExecutor for T {
338 fn execute_erased<'a>(
339 &'a self,
340 response: &'a str,
341 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
342 {
343 Box::pin(self.execute(response))
344 }
345
346 fn execute_confirmed_erased<'a>(
347 &'a self,
348 response: &'a str,
349 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
350 {
351 Box::pin(self.execute_confirmed(response))
352 }
353
354 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
355 self.tool_definitions()
356 }
357
358 fn execute_tool_call_erased<'a>(
359 &'a self,
360 call: &'a ToolCall,
361 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
362 {
363 Box::pin(self.execute_tool_call(call))
364 }
365
366 fn execute_tool_call_confirmed_erased<'a>(
367 &'a self,
368 call: &'a ToolCall,
369 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
370 {
371 Box::pin(self.execute_tool_call_confirmed(call))
372 }
373
374 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
375 ToolExecutor::set_skill_env(self, env);
376 }
377
378 fn set_effective_trust(&self, level: crate::TrustLevel) {
379 ToolExecutor::set_effective_trust(self, level);
380 }
381
382 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
383 ToolExecutor::is_tool_retryable(self, tool_id)
384 }
385}
386
387pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
391
392impl ToolExecutor for DynExecutor {
393 fn execute(
394 &self,
395 response: &str,
396 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
397 let inner = std::sync::Arc::clone(&self.0);
399 let response = response.to_owned();
400 async move { inner.execute_erased(&response).await }
401 }
402
403 fn execute_confirmed(
404 &self,
405 response: &str,
406 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
407 let inner = std::sync::Arc::clone(&self.0);
408 let response = response.to_owned();
409 async move { inner.execute_confirmed_erased(&response).await }
410 }
411
412 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
413 self.0.tool_definitions_erased()
414 }
415
416 fn execute_tool_call(
417 &self,
418 call: &ToolCall,
419 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
420 let inner = std::sync::Arc::clone(&self.0);
421 let call = call.clone();
422 async move { inner.execute_tool_call_erased(&call).await }
423 }
424
425 fn execute_tool_call_confirmed(
426 &self,
427 call: &ToolCall,
428 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
429 let inner = std::sync::Arc::clone(&self.0);
430 let call = call.clone();
431 async move { inner.execute_tool_call_confirmed_erased(&call).await }
432 }
433
434 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
435 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
436 }
437
438 fn set_effective_trust(&self, level: crate::TrustLevel) {
439 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
440 }
441
442 fn is_tool_retryable(&self, tool_id: &str) -> bool {
443 self.0.is_tool_retryable_erased(tool_id)
444 }
445}
446
447#[must_use]
451pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
452 let marker = format!("```{lang}");
453 let marker_len = marker.len();
454 let mut blocks = Vec::new();
455 let mut rest = text;
456
457 let mut search_from = 0;
458 while let Some(rel) = rest[search_from..].find(&marker) {
459 let start = search_from + rel;
460 let after = &rest[start + marker_len..];
461 let boundary_ok = after
465 .chars()
466 .next()
467 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
468 if !boundary_ok {
469 search_from = start + marker_len;
470 continue;
471 }
472 if let Some(end) = after.find("```") {
473 blocks.push(after[..end].trim());
474 rest = &after[end + 3..];
475 search_from = 0;
476 } else {
477 break;
478 }
479 }
480
481 blocks
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn tool_output_display() {
490 let output = ToolOutput {
491 tool_name: "bash".to_owned(),
492 summary: "$ echo hello\nhello".to_owned(),
493 blocks_executed: 1,
494 filter_stats: None,
495 diff: None,
496 streamed: false,
497 terminal_id: None,
498 locations: None,
499 raw_response: None,
500 };
501 assert_eq!(output.to_string(), "$ echo hello\nhello");
502 }
503
504 #[test]
505 fn tool_error_blocked_display() {
506 let err = ToolError::Blocked {
507 command: "rm -rf /".to_owned(),
508 };
509 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
510 }
511
512 #[test]
513 fn tool_error_sandbox_violation_display() {
514 let err = ToolError::SandboxViolation {
515 path: "/etc/shadow".to_owned(),
516 };
517 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
518 }
519
520 #[test]
521 fn tool_error_confirmation_required_display() {
522 let err = ToolError::ConfirmationRequired {
523 command: "rm -rf /tmp".to_owned(),
524 };
525 assert_eq!(
526 err.to_string(),
527 "command requires confirmation: rm -rf /tmp"
528 );
529 }
530
531 #[test]
532 fn tool_error_timeout_display() {
533 let err = ToolError::Timeout { timeout_secs: 30 };
534 assert_eq!(err.to_string(), "command timed out after 30s");
535 }
536
537 #[test]
538 fn tool_error_invalid_params_display() {
539 let err = ToolError::InvalidParams {
540 message: "missing field `command`".to_owned(),
541 };
542 assert_eq!(
543 err.to_string(),
544 "invalid tool parameters: missing field `command`"
545 );
546 }
547
548 #[test]
549 fn deserialize_params_valid() {
550 #[derive(Debug, serde::Deserialize, PartialEq)]
551 struct P {
552 name: String,
553 count: u32,
554 }
555 let mut map = serde_json::Map::new();
556 map.insert("name".to_owned(), serde_json::json!("test"));
557 map.insert("count".to_owned(), serde_json::json!(42));
558 let p: P = deserialize_params(&map).unwrap();
559 assert_eq!(
560 p,
561 P {
562 name: "test".to_owned(),
563 count: 42
564 }
565 );
566 }
567
568 #[test]
569 fn deserialize_params_missing_required_field() {
570 #[derive(Debug, serde::Deserialize)]
571 #[allow(dead_code)]
572 struct P {
573 name: String,
574 }
575 let map = serde_json::Map::new();
576 let err = deserialize_params::<P>(&map).unwrap_err();
577 assert!(matches!(err, ToolError::InvalidParams { .. }));
578 }
579
580 #[test]
581 fn deserialize_params_wrong_type() {
582 #[derive(Debug, serde::Deserialize)]
583 #[allow(dead_code)]
584 struct P {
585 count: u32,
586 }
587 let mut map = serde_json::Map::new();
588 map.insert("count".to_owned(), serde_json::json!("not a number"));
589 let err = deserialize_params::<P>(&map).unwrap_err();
590 assert!(matches!(err, ToolError::InvalidParams { .. }));
591 }
592
593 #[test]
594 fn deserialize_params_all_optional_empty() {
595 #[derive(Debug, serde::Deserialize, PartialEq)]
596 struct P {
597 name: Option<String>,
598 }
599 let map = serde_json::Map::new();
600 let p: P = deserialize_params(&map).unwrap();
601 assert_eq!(p, P { name: None });
602 }
603
604 #[test]
605 fn deserialize_params_ignores_extra_fields() {
606 #[derive(Debug, serde::Deserialize, PartialEq)]
607 struct P {
608 name: String,
609 }
610 let mut map = serde_json::Map::new();
611 map.insert("name".to_owned(), serde_json::json!("test"));
612 map.insert("extra".to_owned(), serde_json::json!(true));
613 let p: P = deserialize_params(&map).unwrap();
614 assert_eq!(
615 p,
616 P {
617 name: "test".to_owned()
618 }
619 );
620 }
621
622 #[test]
623 fn tool_error_execution_display() {
624 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
625 let err = ToolError::Execution(io_err);
626 assert!(err.to_string().starts_with("execution failed:"));
627 assert!(err.to_string().contains("bash not found"));
628 }
629
630 #[test]
632 fn error_kind_timeout_is_transient() {
633 let err = ToolError::Timeout { timeout_secs: 30 };
634 assert_eq!(err.kind(), ErrorKind::Transient);
635 }
636
637 #[test]
638 fn error_kind_blocked_is_permanent() {
639 let err = ToolError::Blocked {
640 command: "rm -rf /".to_owned(),
641 };
642 assert_eq!(err.kind(), ErrorKind::Permanent);
643 }
644
645 #[test]
646 fn error_kind_sandbox_violation_is_permanent() {
647 let err = ToolError::SandboxViolation {
648 path: "/etc/shadow".to_owned(),
649 };
650 assert_eq!(err.kind(), ErrorKind::Permanent);
651 }
652
653 #[test]
654 fn error_kind_cancelled_is_permanent() {
655 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
656 }
657
658 #[test]
659 fn error_kind_invalid_params_is_permanent() {
660 let err = ToolError::InvalidParams {
661 message: "bad arg".to_owned(),
662 };
663 assert_eq!(err.kind(), ErrorKind::Permanent);
664 }
665
666 #[test]
667 fn error_kind_confirmation_required_is_permanent() {
668 let err = ToolError::ConfirmationRequired {
669 command: "rm /tmp/x".to_owned(),
670 };
671 assert_eq!(err.kind(), ErrorKind::Permanent);
672 }
673
674 #[test]
675 fn error_kind_execution_timed_out_is_transient() {
676 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
677 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
678 }
679
680 #[test]
681 fn error_kind_execution_interrupted_is_transient() {
682 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
683 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
684 }
685
686 #[test]
687 fn error_kind_execution_connection_reset_is_transient() {
688 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
689 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
690 }
691
692 #[test]
693 fn error_kind_execution_broken_pipe_is_transient() {
694 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
695 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
696 }
697
698 #[test]
699 fn error_kind_execution_would_block_is_transient() {
700 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
701 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
702 }
703
704 #[test]
705 fn error_kind_execution_connection_aborted_is_transient() {
706 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
707 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
708 }
709
710 #[test]
711 fn error_kind_execution_not_found_is_permanent() {
712 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
713 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
714 }
715
716 #[test]
717 fn error_kind_execution_permission_denied_is_permanent() {
718 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
719 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
720 }
721
722 #[test]
723 fn error_kind_execution_other_is_permanent() {
724 let io_err = std::io::Error::other("some other error");
725 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
726 }
727
728 #[test]
729 fn error_kind_execution_already_exists_is_permanent() {
730 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
731 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
732 }
733
734 #[test]
735 fn error_kind_display() {
736 assert_eq!(ErrorKind::Transient.to_string(), "transient");
737 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
738 }
739
740 #[test]
741 fn truncate_tool_output_short_passthrough() {
742 let short = "hello world";
743 assert_eq!(truncate_tool_output(short), short);
744 }
745
746 #[test]
747 fn truncate_tool_output_exact_limit() {
748 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
749 assert_eq!(truncate_tool_output(&exact), exact);
750 }
751
752 #[test]
753 fn truncate_tool_output_long_split() {
754 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
755 let result = truncate_tool_output(&long);
756 assert!(result.contains("truncated"));
757 assert!(result.len() < long.len());
758 }
759
760 #[test]
761 fn truncate_tool_output_notice_contains_count() {
762 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
763 let result = truncate_tool_output(&long);
764 assert!(result.contains("truncated"));
765 assert!(result.contains("chars"));
766 }
767
768 #[derive(Debug)]
769 struct DefaultExecutor;
770 impl ToolExecutor for DefaultExecutor {
771 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
772 Ok(None)
773 }
774 }
775
776 #[tokio::test]
777 async fn execute_tool_call_default_returns_none() {
778 let exec = DefaultExecutor;
779 let call = ToolCall {
780 tool_id: "anything".to_owned(),
781 params: serde_json::Map::new(),
782 };
783 let result = exec.execute_tool_call(&call).await.unwrap();
784 assert!(result.is_none());
785 }
786
787 #[test]
788 fn filter_stats_savings_pct() {
789 let fs = FilterStats {
790 raw_chars: 1000,
791 filtered_chars: 200,
792 ..Default::default()
793 };
794 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
795 }
796
797 #[test]
798 fn filter_stats_savings_pct_zero() {
799 let fs = FilterStats::default();
800 assert!((fs.savings_pct()).abs() < 0.01);
801 }
802
803 #[test]
804 fn filter_stats_estimated_tokens_saved() {
805 let fs = FilterStats {
806 raw_chars: 1000,
807 filtered_chars: 200,
808 ..Default::default()
809 };
810 assert_eq!(fs.estimated_tokens_saved(), 200); }
812
813 #[test]
814 fn filter_stats_format_inline() {
815 let fs = FilterStats {
816 raw_chars: 1000,
817 filtered_chars: 200,
818 raw_lines: 342,
819 filtered_lines: 28,
820 ..Default::default()
821 };
822 let line = fs.format_inline("shell");
823 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
824 }
825
826 #[test]
827 fn filter_stats_format_inline_zero() {
828 let fs = FilterStats::default();
829 let line = fs.format_inline("bash");
830 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
831 }
832
833 struct FixedExecutor {
836 tool_id: &'static str,
837 output: &'static str,
838 }
839
840 impl ToolExecutor for FixedExecutor {
841 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
842 Ok(Some(ToolOutput {
843 tool_name: self.tool_id.to_owned(),
844 summary: self.output.to_owned(),
845 blocks_executed: 1,
846 filter_stats: None,
847 diff: None,
848 streamed: false,
849 terminal_id: None,
850 locations: None,
851 raw_response: None,
852 }))
853 }
854
855 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
856 vec![]
857 }
858
859 async fn execute_tool_call(
860 &self,
861 _call: &ToolCall,
862 ) -> Result<Option<ToolOutput>, ToolError> {
863 Ok(Some(ToolOutput {
864 tool_name: self.tool_id.to_owned(),
865 summary: self.output.to_owned(),
866 blocks_executed: 1,
867 filter_stats: None,
868 diff: None,
869 streamed: false,
870 terminal_id: None,
871 locations: None,
872 raw_response: None,
873 }))
874 }
875 }
876
877 #[tokio::test]
878 async fn dyn_executor_execute_delegates() {
879 let inner = std::sync::Arc::new(FixedExecutor {
880 tool_id: "bash",
881 output: "hello",
882 });
883 let exec = DynExecutor(inner);
884 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
885 assert!(result.is_some());
886 assert_eq!(result.unwrap().summary, "hello");
887 }
888
889 #[tokio::test]
890 async fn dyn_executor_execute_confirmed_delegates() {
891 let inner = std::sync::Arc::new(FixedExecutor {
892 tool_id: "bash",
893 output: "confirmed",
894 });
895 let exec = DynExecutor(inner);
896 let result = exec.execute_confirmed("...").await.unwrap();
897 assert!(result.is_some());
898 assert_eq!(result.unwrap().summary, "confirmed");
899 }
900
901 #[test]
902 fn dyn_executor_tool_definitions_delegates() {
903 let inner = std::sync::Arc::new(FixedExecutor {
904 tool_id: "my_tool",
905 output: "",
906 });
907 let exec = DynExecutor(inner);
908 let defs = exec.tool_definitions();
910 assert!(defs.is_empty());
911 }
912
913 #[tokio::test]
914 async fn dyn_executor_execute_tool_call_delegates() {
915 let inner = std::sync::Arc::new(FixedExecutor {
916 tool_id: "bash",
917 output: "tool_call_result",
918 });
919 let exec = DynExecutor(inner);
920 let call = ToolCall {
921 tool_id: "bash".to_owned(),
922 params: serde_json::Map::new(),
923 };
924 let result = exec.execute_tool_call(&call).await.unwrap();
925 assert!(result.is_some());
926 assert_eq!(result.unwrap().summary, "tool_call_result");
927 }
928
929 #[test]
930 fn dyn_executor_set_effective_trust_delegates() {
931 use std::sync::atomic::{AtomicU8, Ordering};
932
933 struct TrustCapture(AtomicU8);
934 impl ToolExecutor for TrustCapture {
935 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
936 Ok(None)
937 }
938 fn set_effective_trust(&self, level: crate::TrustLevel) {
939 let v = match level {
941 crate::TrustLevel::Trusted => 0u8,
942 crate::TrustLevel::Verified => 1,
943 crate::TrustLevel::Quarantined => 2,
944 crate::TrustLevel::Blocked => 3,
945 };
946 self.0.store(v, Ordering::Relaxed);
947 }
948 }
949
950 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
951 let exec =
952 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
953 ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Quarantined);
954 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
955
956 ToolExecutor::set_effective_trust(&exec, crate::TrustLevel::Blocked);
957 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
958 }
959
960 #[test]
961 fn extract_fenced_blocks_no_prefix_match() {
962 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
964 assert_eq!(
966 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
967 vec!["foo"]
968 );
969 assert_eq!(
971 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
972 vec!["foo"]
973 );
974 }
975}