1#![deny(missing_docs)]
2pub mod bridge;
39pub mod error;
40pub mod js_bridge;
42pub mod mcp;
44mod memory;
45pub mod privacy;
47pub mod redaction;
49pub(crate) mod screenshot;
50mod tools;
51
52pub mod auth;
54
55use std::collections::{HashMap, HashSet};
56use std::sync::Arc;
57use std::sync::atomic::{AtomicU16, AtomicU64};
58use tauri::plugin::{Builder, TauriPlugin};
59use tauri::{Manager, RunEvent, Runtime};
60use tokio::sync::{Mutex, oneshot, watch};
61use victauri_core::{CommandRegistry, EventLog, EventRecorder};
62
63pub use error::BuilderError;
64pub use privacy::PrivacyProfile;
65
66pub use victauri_core::CommandInfo;
67pub use victauri_macros::inspectable;
68
69#[macro_export]
90macro_rules! register_commands {
91 ($app:expr, $($schema_call:expr),+ $(,)?) => {{
92 let state = $app.state::<std::sync::Arc<$crate::VictauriState>>();
93 $(
94 state.registry.register($schema_call);
95 )+
96 }};
97}
98
99const DEFAULT_PORT: u16 = 7373;
100const DEFAULT_EVENT_CAPACITY: usize = 10_000;
101const DEFAULT_RECORDER_CAPACITY: usize = 50_000;
102const DEFAULT_EVAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
103const MAX_EVENT_CAPACITY: usize = 1_000_000;
104const MAX_RECORDER_CAPACITY: usize = 1_000_000;
105const MAX_EVAL_TIMEOUT_SECS: u64 = 300;
106
107pub type PendingCallbacks = Arc<Mutex<HashMap<String, oneshot::Sender<String>>>>;
110
111pub struct VictauriState {
113 pub event_log: EventLog,
115 pub registry: CommandRegistry,
117 pub port: AtomicU16,
119 pub pending_evals: PendingCallbacks,
121 pub recorder: EventRecorder,
123 pub privacy: privacy::PrivacyConfig,
125 pub eval_timeout: std::time::Duration,
127 pub shutdown_tx: watch::Sender<bool>,
129 pub started_at: std::time::Instant,
131 pub tool_invocations: AtomicU64,
133 pub allow_file_navigation: bool,
137}
138
139pub struct VictauriBuilder {
150 port: Option<u16>,
151 event_capacity: usize,
152 recorder_capacity: usize,
153 eval_timeout: std::time::Duration,
154 auth_token: Option<String>,
155 auth_explicitly_disabled: bool,
156 disabled_tools: Vec<String>,
157 command_allowlist: Option<Vec<String>>,
158 command_blocklist: Vec<String>,
159 redaction_patterns: Vec<String>,
160 redaction_enabled: bool,
161 strict_privacy: bool,
162 privacy_profile: Option<privacy::PrivacyProfile>,
163 bridge_capacities: js_bridge::BridgeCapacities,
164 on_ready: Option<Box<dyn FnOnce(u16) + Send + 'static>>,
165 commands: Vec<victauri_core::CommandInfo>,
166 allow_file_navigation: bool,
167}
168
169impl Default for VictauriBuilder {
170 fn default() -> Self {
171 Self {
172 port: None,
173 event_capacity: DEFAULT_EVENT_CAPACITY,
174 recorder_capacity: DEFAULT_RECORDER_CAPACITY,
175 eval_timeout: DEFAULT_EVAL_TIMEOUT,
176 auth_token: None,
177 auth_explicitly_disabled: false,
178 disabled_tools: Vec::new(),
179 command_allowlist: None,
180 command_blocklist: Vec::new(),
181 redaction_patterns: Vec::new(),
182 redaction_enabled: false,
183 strict_privacy: false,
184 privacy_profile: None,
185 bridge_capacities: js_bridge::BridgeCapacities::default(),
186 on_ready: None,
187 commands: Vec::new(),
188 allow_file_navigation: false,
189 }
190 }
191}
192
193impl VictauriBuilder {
194 #[must_use]
196 pub fn new() -> Self {
197 Self::default()
198 }
199
200 #[must_use]
202 pub fn port(mut self, port: u16) -> Self {
203 self.port = Some(port);
204 self
205 }
206
207 #[must_use]
209 pub fn event_capacity(mut self, capacity: usize) -> Self {
210 self.event_capacity = capacity;
211 self
212 }
213
214 #[must_use]
216 pub fn recorder_capacity(mut self, capacity: usize) -> Self {
217 self.recorder_capacity = capacity;
218 self
219 }
220
221 #[must_use]
223 pub fn eval_timeout(mut self, timeout: std::time::Duration) -> Self {
224 self.eval_timeout = timeout;
225 self
226 }
227
228 #[must_use]
230 pub fn auth_token(mut self, token: impl Into<String>) -> Self {
231 self.auth_token = Some(token.into());
232 self
233 }
234
235 #[must_use]
237 pub fn generate_auth_token(mut self) -> Self {
238 self.auth_token = Some(auth::generate_token());
239 self
240 }
241
242 #[must_use]
248 pub fn auth_disabled(mut self) -> Self {
249 self.auth_explicitly_disabled = true;
250 self.auth_token = None;
251 self
252 }
253
254 #[must_use]
256 pub fn disable_tools(mut self, tools: &[&str]) -> Self {
257 self.disabled_tools = tools.iter().map(std::string::ToString::to_string).collect();
258 self
259 }
260
261 #[must_use]
263 pub fn command_allowlist(mut self, commands: &[&str]) -> Self {
264 self.command_allowlist = Some(
265 commands
266 .iter()
267 .map(std::string::ToString::to_string)
268 .collect(),
269 );
270 self
271 }
272
273 #[must_use]
275 pub fn command_blocklist(mut self, commands: &[&str]) -> Self {
276 self.command_blocklist = commands
277 .iter()
278 .map(std::string::ToString::to_string)
279 .collect();
280 self
281 }
282
283 #[must_use]
285 pub fn add_redaction_pattern(mut self, pattern: impl Into<String>) -> Self {
286 self.redaction_patterns.push(pattern.into());
287 self
288 }
289
290 #[must_use]
292 pub fn enable_redaction(mut self) -> Self {
293 self.redaction_enabled = true;
294 self
295 }
296
297 #[must_use]
304 pub fn strict_privacy_mode(mut self) -> Self {
305 self.strict_privacy = true;
306 self.privacy_profile = Some(privacy::PrivacyProfile::Observe);
307 self
308 }
309
310 #[must_use]
319 pub fn privacy_profile(mut self, profile: privacy::PrivacyProfile) -> Self {
320 self.privacy_profile = Some(profile);
321 if matches!(
322 profile,
323 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
324 ) {
325 self.redaction_enabled = true;
326 }
327 self
328 }
329
330 #[must_use]
332 pub fn console_log_capacity(mut self, capacity: usize) -> Self {
333 self.bridge_capacities.console_logs = capacity;
334 self
335 }
336
337 #[must_use]
339 pub fn network_log_capacity(mut self, capacity: usize) -> Self {
340 self.bridge_capacities.network_log = capacity;
341 self
342 }
343
344 #[must_use]
346 pub fn navigation_log_capacity(mut self, capacity: usize) -> Self {
347 self.bridge_capacities.navigation_log = capacity;
348 self
349 }
350
351 #[must_use]
366 pub fn commands(mut self, schemas: &[victauri_core::CommandInfo]) -> Self {
367 self.commands = schemas.to_vec();
368 self
369 }
370
371 #[must_use]
387 pub fn auto_discover(mut self) -> Self {
388 self.commands
389 .extend(victauri_core::auto_discovered_commands());
390 self
391 }
392
393 #[must_use]
402 pub fn allow_file_navigation(mut self) -> Self {
403 self.allow_file_navigation = true;
404 self
405 }
406
407 #[must_use]
410 pub fn on_ready(mut self, f: impl FnOnce(u16) + Send + 'static) -> Self {
411 self.on_ready = Some(Box::new(f));
412 self
413 }
414
415 fn resolve_port(&self) -> u16 {
416 self.port
417 .or_else(|| std::env::var("VICTAURI_PORT").ok()?.parse().ok())
418 .unwrap_or(DEFAULT_PORT)
419 }
420
421 fn resolve_auth_token(&self) -> Option<String> {
422 if self.auth_explicitly_disabled {
423 return None;
424 }
425 self.auth_token
426 .clone()
427 .or_else(|| std::env::var("VICTAURI_AUTH_TOKEN").ok())
428 .or_else(|| Some(auth::generate_token()))
429 }
430
431 fn resolve_eval_timeout(&self) -> std::time::Duration {
432 std::env::var("VICTAURI_EVAL_TIMEOUT")
433 .ok()
434 .and_then(|s| s.parse::<u64>().ok())
435 .map_or(self.eval_timeout, std::time::Duration::from_secs)
436 }
437
438 fn build_privacy_config(&self) -> privacy::PrivacyConfig {
439 let profile = self
440 .privacy_profile
441 .unwrap_or(privacy::PrivacyProfile::FullControl);
442
443 let redaction_enabled = self.redaction_enabled
444 || self.strict_privacy
445 || matches!(
446 profile,
447 privacy::PrivacyProfile::Observe | privacy::PrivacyProfile::Test
448 );
449
450 privacy::PrivacyConfig {
451 profile,
452 command_allowlist: self
453 .command_allowlist
454 .as_ref()
455 .map(|v| v.iter().cloned().collect::<HashSet<String>>()),
456 command_blocklist: self.command_blocklist.iter().cloned().collect(),
457 disabled_tools: self.disabled_tools.iter().cloned().collect(),
458 redactor: redaction::Redactor::new(&self.redaction_patterns),
459 redaction_enabled,
460 }
461 }
462
463 fn validate(&self) -> Result<(), BuilderError> {
464 let port = self.resolve_port();
465 if port == 0 {
466 return Err(BuilderError::InvalidPort {
467 port,
468 reason: "port 0 is reserved".to_string(),
469 });
470 }
471
472 if self.event_capacity == 0 || self.event_capacity > MAX_EVENT_CAPACITY {
473 return Err(BuilderError::InvalidEventCapacity {
474 capacity: self.event_capacity,
475 reason: format!("must be between 1 and {MAX_EVENT_CAPACITY}"),
476 });
477 }
478
479 if self.recorder_capacity == 0 || self.recorder_capacity > MAX_RECORDER_CAPACITY {
480 return Err(BuilderError::InvalidRecorderCapacity {
481 capacity: self.recorder_capacity,
482 reason: format!("must be between 1 and {MAX_RECORDER_CAPACITY}"),
483 });
484 }
485
486 let timeout = self.resolve_eval_timeout();
487 if timeout.as_secs() == 0 || timeout.as_secs() > MAX_EVAL_TIMEOUT_SECS {
488 return Err(BuilderError::InvalidEvalTimeout {
489 timeout_secs: timeout.as_secs(),
490 reason: format!("must be between 1 and {MAX_EVAL_TIMEOUT_SECS} seconds"),
491 });
492 }
493
494 Ok(())
495 }
496
497 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, BuilderError> {
507 #[cfg(not(debug_assertions))]
508 {
509 Ok(Builder::new("victauri").build())
510 }
511
512 #[cfg(debug_assertions)]
513 {
514 self.validate()?;
515
516 let port = self.resolve_port();
517 let event_capacity = self.event_capacity;
518 let recorder_capacity = self.recorder_capacity;
519 let eval_timeout = self.resolve_eval_timeout();
520 let auth_token = self.resolve_auth_token();
521 let privacy_config = self.build_privacy_config();
522 let allow_file_navigation = self.allow_file_navigation;
523 let on_ready = self.on_ready;
524 let commands = self.commands;
525 let js_init = js_bridge::init_script(&self.bridge_capacities);
526
527 Ok(Builder::new("victauri")
528 .setup(move |app, _api| {
529 let event_log = EventLog::new(event_capacity);
530 let registry = CommandRegistry::new();
531 let (shutdown_tx, shutdown_rx) = watch::channel(false);
532
533 let state = Arc::new(VictauriState {
534 event_log,
535 registry,
536 port: AtomicU16::new(port),
537 pending_evals: Arc::new(Mutex::new(HashMap::new())),
538 recorder: EventRecorder::new(recorder_capacity),
539 privacy: privacy_config,
540 eval_timeout,
541 shutdown_tx,
542 started_at: std::time::Instant::now(),
543 tool_invocations: AtomicU64::new(0),
544 allow_file_navigation,
545 });
546
547 app.manage(state.clone());
548
549 for cmd in commands {
550 state.registry.register(cmd);
551 }
552
553 if let Some(ref token) = auth_token {
554 tracing::info!(
555 "Victauri MCP server auth enabled — token: {token}"
556 );
557 } else {
558 tracing::warn!(
559 "Victauri MCP server auth DISABLED — any localhost process can access the MCP server"
560 );
561 }
562
563 let app_handle = app.clone();
564 let ready_state = state.clone();
565 tauri::async_runtime::spawn(async move {
566 match mcp::start_server_with_options(
567 app_handle, state, port, auth_token, shutdown_rx,
568 )
569 .await
570 {
571 Ok(()) => {
572 tracing::info!("Victauri MCP server stopped");
573 }
574 Err(e) => {
575 tracing::error!("Victauri MCP server failed: {e}");
576 }
577 }
578 });
579
580 if let Some(cb) = on_ready {
581 tauri::async_runtime::spawn(async move {
582 for _ in 0..50 {
583 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
584 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
585 if tokio::net::TcpStream::connect(format!(
586 "127.0.0.1:{actual_port}"
587 ))
588 .await
589 .is_ok()
590 {
591 cb(actual_port);
592 return;
593 }
594 }
595 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
596 tracing::warn!("Victauri on_ready: server did not become ready within 5s");
597 cb(actual_port);
598 });
599 }
600
601 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
602 Ok(())
603 })
604 .on_event(|app, event| {
605 if let RunEvent::Exit = event
606 && let Some(state) = app.try_state::<Arc<VictauriState>>()
607 {
608 let _ = state.shutdown_tx.send(true);
609 tracing::info!("Victauri shutdown signal sent");
610 }
611 })
612 .js_init_script(js_init)
613 .invoke_handler(tauri::generate_handler![
614 tools::victauri_eval_js,
615 tools::victauri_eval_callback,
616 tools::victauri_get_window_state,
617 tools::victauri_list_windows,
618 tools::victauri_get_ipc_log,
619 tools::victauri_get_registry,
620 tools::victauri_get_memory_stats,
621 tools::victauri_dom_snapshot,
622 tools::victauri_verify_state,
623 tools::victauri_detect_ghost_commands,
624 tools::victauri_check_ipc_integrity,
625 ])
626 .build())
627 }
628 }
629}
630
631#[must_use]
645pub fn init<R: Runtime>() -> TauriPlugin<R> {
646 VictauriBuilder::new()
647 .build()
648 .expect("default Victauri configuration is always valid")
649}
650
651#[must_use]
660pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
661 VictauriBuilder::new()
662 .auto_discover()
663 .build()
664 .expect("default Victauri configuration is always valid")
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn builder_default_values() {
673 let builder = VictauriBuilder::new();
674 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
675 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
676 assert!(builder.auth_token.is_none());
678 assert!(!builder.auth_explicitly_disabled);
679 let resolved = builder.resolve_auth_token();
680 assert!(resolved.is_some(), "auth should be enabled by default");
681 assert_eq!(
682 resolved.unwrap().len(),
683 36,
684 "auto-generated token should be a UUID"
685 );
686 assert!(builder.disabled_tools.is_empty());
687 assert!(builder.command_allowlist.is_none());
688 assert!(builder.command_blocklist.is_empty());
689 assert!(!builder.redaction_enabled);
690 assert!(!builder.strict_privacy);
691 }
692
693 #[test]
694 fn builder_port_override() {
695 let builder = VictauriBuilder::new().port(9090);
696 assert_eq!(builder.resolve_port(), 9090);
697 }
698
699 #[test]
700 #[allow(unsafe_code)]
701 fn builder_default_port() {
702 let builder = VictauriBuilder::new();
703 unsafe { std::env::remove_var("VICTAURI_PORT") };
705 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
706 }
707
708 #[test]
709 fn builder_auth_token_explicit() {
710 let builder = VictauriBuilder::new().auth_token("my-secret");
711 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
712 }
713
714 #[test]
715 fn builder_auth_token_generated() {
716 let builder = VictauriBuilder::new().generate_auth_token();
717 let token = builder.resolve_auth_token().unwrap();
718 assert_eq!(token.len(), 36);
719 }
720
721 #[test]
722 fn builder_auth_disabled() {
723 let builder = VictauriBuilder::new().auth_disabled();
724 assert!(builder.auth_explicitly_disabled);
725 assert!(
726 builder.resolve_auth_token().is_none(),
727 "auth_disabled should opt out of auto-generated token"
728 );
729 }
730
731 #[test]
732 fn builder_auth_disabled_overrides_explicit_token() {
733 let builder = VictauriBuilder::new()
734 .auth_token("my-secret")
735 .auth_disabled();
736 assert!(
737 builder.resolve_auth_token().is_none(),
738 "auth_disabled should override explicit token"
739 );
740 }
741
742 #[test]
743 fn builder_capacities() {
744 let builder = VictauriBuilder::new()
745 .event_capacity(500)
746 .recorder_capacity(2000);
747 assert_eq!(builder.event_capacity, 500);
748 assert_eq!(builder.recorder_capacity, 2000);
749 }
750
751 #[test]
752 fn builder_disable_tools() {
753 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
754 assert_eq!(builder.disabled_tools.len(), 2);
755 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
756 }
757
758 #[test]
759 fn builder_command_allowlist() {
760 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
761 assert!(builder.command_allowlist.is_some());
762 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
763 }
764
765 #[test]
766 fn builder_command_blocklist() {
767 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
768 assert_eq!(builder.command_blocklist.len(), 1);
769 }
770
771 #[test]
772 fn builder_redaction() {
773 let builder = VictauriBuilder::new()
774 .add_redaction_pattern(r"SECRET_\w+")
775 .enable_redaction();
776 assert!(builder.redaction_enabled);
777 assert_eq!(builder.redaction_patterns.len(), 1);
778 }
779
780 #[test]
781 fn builder_strict_privacy_config() {
782 let builder = VictauriBuilder::new().strict_privacy_mode();
783 let config = builder.build_privacy_config();
784 assert!(config.redaction_enabled);
785 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
786 assert!(!config.is_tool_enabled("eval_js"));
787 assert!(!config.is_tool_enabled("screenshot"));
788 assert!(!config.is_tool_enabled("interact"));
789 assert!(config.is_tool_enabled("dom_snapshot"));
790 }
791
792 #[test]
793 fn builder_normal_privacy_config() {
794 let builder = VictauriBuilder::new()
795 .command_blocklist(&["secret_cmd"])
796 .disable_tools(&["eval_js"]);
797 let config = builder.build_privacy_config();
798 assert!(config.command_blocklist.contains("secret_cmd"));
799 assert!(!config.is_tool_enabled("eval_js"));
800 assert!(!config.redaction_enabled);
801 }
802
803 #[test]
804 fn builder_strict_with_extra_blocklist() {
805 let builder = VictauriBuilder::new()
806 .strict_privacy_mode()
807 .command_blocklist(&["extra_dangerous"]);
808 let config = builder.build_privacy_config();
809 assert!(config.command_blocklist.contains("extra_dangerous"));
810 assert!(!config.is_tool_enabled("eval_js"));
811 }
812
813 #[test]
814 fn builder_test_profile() {
815 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
816 let config = builder.build_privacy_config();
817 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
818 assert!(config.redaction_enabled);
819 assert!(config.is_tool_enabled("interact"));
820 assert!(config.is_tool_enabled("fill"));
821 assert!(config.is_tool_enabled("recording"));
822 assert!(!config.is_tool_enabled("eval_js"));
823 assert!(!config.is_tool_enabled("screenshot"));
824 assert!(!config.is_tool_enabled("navigate"));
825 }
826
827 #[test]
828 fn builder_profile_with_extra_disables() {
829 let builder = VictauriBuilder::new()
830 .privacy_profile(crate::privacy::PrivacyProfile::Test)
831 .disable_tools(&["interact"]);
832 let config = builder.build_privacy_config();
833 assert!(!config.is_tool_enabled("interact"));
834 assert!(config.is_tool_enabled("fill"));
835 }
836
837 #[test]
838 fn builder_bridge_capacities() {
839 let builder = VictauriBuilder::new()
840 .console_log_capacity(5000)
841 .network_log_capacity(2000)
842 .navigation_log_capacity(500);
843 assert_eq!(builder.bridge_capacities.console_logs, 5000);
844 assert_eq!(builder.bridge_capacities.network_log, 2000);
845 assert_eq!(builder.bridge_capacities.navigation_log, 500);
846 assert_eq!(builder.bridge_capacities.mutation_log, 500);
847 assert_eq!(builder.bridge_capacities.dialog_log, 100);
848 }
849
850 #[test]
851 fn builder_on_ready_sets_callback() {
852 let builder = VictauriBuilder::new().on_ready(|_port| {});
853 assert!(builder.on_ready.is_some());
854 }
855
856 #[test]
857 fn builder_file_navigation_disabled_by_default() {
858 let builder = VictauriBuilder::new();
859 assert!(
860 !builder.allow_file_navigation,
861 "file navigation should be disabled by default"
862 );
863 }
864
865 #[test]
866 fn builder_allow_file_navigation() {
867 let builder = VictauriBuilder::new().allow_file_navigation();
868 assert!(builder.allow_file_navigation);
869 }
870
871 #[test]
872 fn init_script_contains_custom_capacities() {
873 let caps = js_bridge::BridgeCapacities {
874 console_logs: 3000,
875 mutation_log: 750,
876 network_log: 5000,
877 navigation_log: 400,
878 dialog_log: 250,
879 long_tasks: 200,
880 };
881 let script = js_bridge::init_script(&caps);
882 assert!(script.contains("CAP_CONSOLE = 3000"));
883 assert!(script.contains("CAP_MUTATION = 750"));
884 assert!(script.contains("CAP_NETWORK = 5000"));
885 assert!(script.contains("CAP_NAVIGATION = 400"));
886 assert!(script.contains("CAP_DIALOG = 250"));
887 assert!(script.contains("CAP_LONG_TASKS = 200"));
888 }
889
890 #[test]
891 fn init_script_default_contains_standard_capacities() {
892 let caps = js_bridge::BridgeCapacities::default();
893 let script = js_bridge::init_script(&caps);
894 assert!(script.contains("CAP_CONSOLE = 1000"));
895 assert!(script.contains("CAP_NETWORK = 1000"));
896 assert!(script.contains("window.__VICTAURI__"));
897 }
898
899 #[test]
900 fn builder_validates_defaults() {
901 let builder = VictauriBuilder::new();
902 assert!(builder.validate().is_ok());
903 }
904
905 #[test]
906 fn builder_rejects_zero_port() {
907 let builder = VictauriBuilder::new().port(0);
908 let err = builder.validate().unwrap_err();
909 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
910 }
911
912 #[test]
913 fn builder_rejects_zero_event_capacity() {
914 let builder = VictauriBuilder::new().event_capacity(0);
915 let err = builder.validate().unwrap_err();
916 assert!(matches!(
917 err,
918 BuilderError::InvalidEventCapacity { capacity: 0, .. }
919 ));
920 }
921
922 #[test]
923 fn builder_rejects_excessive_event_capacity() {
924 let builder = VictauriBuilder::new().event_capacity(2_000_000);
925 assert!(builder.validate().is_err());
926 }
927
928 #[test]
929 fn builder_rejects_zero_recorder_capacity() {
930 let builder = VictauriBuilder::new().recorder_capacity(0);
931 assert!(builder.validate().is_err());
932 }
933
934 #[test]
935 fn builder_rejects_zero_eval_timeout() {
936 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
937 assert!(builder.validate().is_err());
938 }
939
940 #[test]
941 fn builder_rejects_excessive_eval_timeout() {
942 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
943 assert!(builder.validate().is_err());
944 }
945
946 #[test]
947 fn builder_accepts_edge_values() {
948 let builder = VictauriBuilder::new()
949 .port(1)
950 .event_capacity(1)
951 .recorder_capacity(1)
952 .eval_timeout(std::time::Duration::from_secs(1));
953 assert!(builder.validate().is_ok());
954
955 let builder = VictauriBuilder::new()
956 .port(65535)
957 .event_capacity(MAX_EVENT_CAPACITY)
958 .recorder_capacity(MAX_RECORDER_CAPACITY)
959 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
960 assert!(builder.validate().is_ok());
961 }
962}