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: {}…{}",
556 &token[..8],
557 &token[token.len().saturating_sub(4)..]
558 );
559 } else {
560 tracing::warn!(
561 "Victauri MCP server auth DISABLED — any localhost process can access the MCP server"
562 );
563 }
564
565 let app_handle = app.clone();
566 let ready_state = state.clone();
567 tauri::async_runtime::spawn(async move {
568 match mcp::start_server_with_options(
569 app_handle, state, port, auth_token, shutdown_rx,
570 )
571 .await
572 {
573 Ok(()) => {
574 tracing::info!("Victauri MCP server stopped");
575 }
576 Err(e) => {
577 tracing::error!("Victauri MCP server failed: {e}");
578 }
579 }
580 });
581
582 if let Some(cb) = on_ready {
583 tauri::async_runtime::spawn(async move {
584 for _ in 0..50 {
585 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
586 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
587 if tokio::net::TcpStream::connect(format!(
588 "127.0.0.1:{actual_port}"
589 ))
590 .await
591 .is_ok()
592 {
593 cb(actual_port);
594 return;
595 }
596 }
597 let actual_port = ready_state.port.load(std::sync::atomic::Ordering::Relaxed);
598 tracing::warn!("Victauri on_ready: server did not become ready within 5s");
599 cb(actual_port);
600 });
601 }
602
603 tracing::info!("Victauri plugin initialized — MCP server on port {port}");
604 Ok(())
605 })
606 .on_event(|app, event| {
607 if let RunEvent::Exit = event
608 && let Some(state) = app.try_state::<Arc<VictauriState>>()
609 {
610 let _ = state.shutdown_tx.send(true);
611 tracing::info!("Victauri shutdown signal sent");
612 }
613 })
614 .js_init_script(js_init)
615 .invoke_handler(tauri::generate_handler![
616 tools::victauri_eval_js,
617 tools::victauri_eval_callback,
618 tools::victauri_get_window_state,
619 tools::victauri_list_windows,
620 tools::victauri_get_ipc_log,
621 tools::victauri_get_registry,
622 tools::victauri_get_memory_stats,
623 tools::victauri_dom_snapshot,
624 tools::victauri_verify_state,
625 tools::victauri_detect_ghost_commands,
626 tools::victauri_check_ipc_integrity,
627 ])
628 .build())
629 }
630 }
631}
632
633#[must_use]
647pub fn init<R: Runtime>() -> TauriPlugin<R> {
648 VictauriBuilder::new()
649 .build()
650 .expect("default Victauri configuration is always valid")
651}
652
653#[must_use]
662pub fn init_auto_discover<R: Runtime>() -> TauriPlugin<R> {
663 VictauriBuilder::new()
664 .auto_discover()
665 .build()
666 .expect("default Victauri configuration is always valid")
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn builder_default_values() {
675 let builder = VictauriBuilder::new();
676 assert_eq!(builder.event_capacity, DEFAULT_EVENT_CAPACITY);
677 assert_eq!(builder.recorder_capacity, DEFAULT_RECORDER_CAPACITY);
678 assert!(builder.auth_token.is_none());
680 assert!(!builder.auth_explicitly_disabled);
681 let resolved = builder.resolve_auth_token();
682 assert!(resolved.is_some(), "auth should be enabled by default");
683 assert_eq!(
684 resolved.unwrap().len(),
685 36,
686 "auto-generated token should be a UUID"
687 );
688 assert!(builder.disabled_tools.is_empty());
689 assert!(builder.command_allowlist.is_none());
690 assert!(builder.command_blocklist.is_empty());
691 assert!(!builder.redaction_enabled);
692 assert!(!builder.strict_privacy);
693 }
694
695 #[test]
696 fn builder_port_override() {
697 let builder = VictauriBuilder::new().port(9090);
698 assert_eq!(builder.resolve_port(), 9090);
699 }
700
701 #[test]
702 #[allow(unsafe_code)]
703 fn builder_default_port() {
704 let builder = VictauriBuilder::new();
705 unsafe { std::env::remove_var("VICTAURI_PORT") };
707 assert_eq!(builder.resolve_port(), DEFAULT_PORT);
708 }
709
710 #[test]
711 fn builder_auth_token_explicit() {
712 let builder = VictauriBuilder::new().auth_token("my-secret");
713 assert_eq!(builder.resolve_auth_token(), Some("my-secret".to_string()));
714 }
715
716 #[test]
717 fn builder_auth_token_generated() {
718 let builder = VictauriBuilder::new().generate_auth_token();
719 let token = builder.resolve_auth_token().unwrap();
720 assert_eq!(token.len(), 36);
721 }
722
723 #[test]
724 fn builder_auth_disabled() {
725 let builder = VictauriBuilder::new().auth_disabled();
726 assert!(builder.auth_explicitly_disabled);
727 assert!(
728 builder.resolve_auth_token().is_none(),
729 "auth_disabled should opt out of auto-generated token"
730 );
731 }
732
733 #[test]
734 fn builder_auth_disabled_overrides_explicit_token() {
735 let builder = VictauriBuilder::new()
736 .auth_token("my-secret")
737 .auth_disabled();
738 assert!(
739 builder.resolve_auth_token().is_none(),
740 "auth_disabled should override explicit token"
741 );
742 }
743
744 #[test]
745 fn builder_capacities() {
746 let builder = VictauriBuilder::new()
747 .event_capacity(500)
748 .recorder_capacity(2000);
749 assert_eq!(builder.event_capacity, 500);
750 assert_eq!(builder.recorder_capacity, 2000);
751 }
752
753 #[test]
754 fn builder_disable_tools() {
755 let builder = VictauriBuilder::new().disable_tools(&["eval_js", "screenshot"]);
756 assert_eq!(builder.disabled_tools.len(), 2);
757 assert!(builder.disabled_tools.contains(&"eval_js".to_string()));
758 }
759
760 #[test]
761 fn builder_command_allowlist() {
762 let builder = VictauriBuilder::new().command_allowlist(&["greet", "increment"]);
763 assert!(builder.command_allowlist.is_some());
764 assert_eq!(builder.command_allowlist.as_ref().unwrap().len(), 2);
765 }
766
767 #[test]
768 fn builder_command_blocklist() {
769 let builder = VictauriBuilder::new().command_blocklist(&["dangerous_cmd"]);
770 assert_eq!(builder.command_blocklist.len(), 1);
771 }
772
773 #[test]
774 fn builder_redaction() {
775 let builder = VictauriBuilder::new()
776 .add_redaction_pattern(r"SECRET_\w+")
777 .enable_redaction();
778 assert!(builder.redaction_enabled);
779 assert_eq!(builder.redaction_patterns.len(), 1);
780 }
781
782 #[test]
783 fn builder_strict_privacy_config() {
784 let builder = VictauriBuilder::new().strict_privacy_mode();
785 let config = builder.build_privacy_config();
786 assert!(config.redaction_enabled);
787 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Observe);
788 assert!(!config.is_tool_enabled("eval_js"));
789 assert!(!config.is_tool_enabled("screenshot"));
790 assert!(!config.is_tool_enabled("interact"));
791 assert!(config.is_tool_enabled("dom_snapshot"));
792 }
793
794 #[test]
795 fn builder_normal_privacy_config() {
796 let builder = VictauriBuilder::new()
797 .command_blocklist(&["secret_cmd"])
798 .disable_tools(&["eval_js"]);
799 let config = builder.build_privacy_config();
800 assert!(config.command_blocklist.contains("secret_cmd"));
801 assert!(!config.is_tool_enabled("eval_js"));
802 assert!(!config.redaction_enabled);
803 }
804
805 #[test]
806 fn builder_strict_with_extra_blocklist() {
807 let builder = VictauriBuilder::new()
808 .strict_privacy_mode()
809 .command_blocklist(&["extra_dangerous"]);
810 let config = builder.build_privacy_config();
811 assert!(config.command_blocklist.contains("extra_dangerous"));
812 assert!(!config.is_tool_enabled("eval_js"));
813 }
814
815 #[test]
816 fn builder_test_profile() {
817 let builder = VictauriBuilder::new().privacy_profile(crate::privacy::PrivacyProfile::Test);
818 let config = builder.build_privacy_config();
819 assert_eq!(config.profile, crate::privacy::PrivacyProfile::Test);
820 assert!(config.redaction_enabled);
821 assert!(config.is_tool_enabled("interact"));
822 assert!(config.is_tool_enabled("fill"));
823 assert!(config.is_tool_enabled("recording"));
824 assert!(!config.is_tool_enabled("eval_js"));
825 assert!(!config.is_tool_enabled("screenshot"));
826 assert!(!config.is_tool_enabled("navigate"));
827 }
828
829 #[test]
830 fn builder_profile_with_extra_disables() {
831 let builder = VictauriBuilder::new()
832 .privacy_profile(crate::privacy::PrivacyProfile::Test)
833 .disable_tools(&["interact"]);
834 let config = builder.build_privacy_config();
835 assert!(!config.is_tool_enabled("interact"));
836 assert!(config.is_tool_enabled("fill"));
837 }
838
839 #[test]
840 fn builder_bridge_capacities() {
841 let builder = VictauriBuilder::new()
842 .console_log_capacity(5000)
843 .network_log_capacity(2000)
844 .navigation_log_capacity(500);
845 assert_eq!(builder.bridge_capacities.console_logs, 5000);
846 assert_eq!(builder.bridge_capacities.network_log, 2000);
847 assert_eq!(builder.bridge_capacities.navigation_log, 500);
848 assert_eq!(builder.bridge_capacities.mutation_log, 500);
849 assert_eq!(builder.bridge_capacities.dialog_log, 100);
850 }
851
852 #[test]
853 fn builder_on_ready_sets_callback() {
854 let builder = VictauriBuilder::new().on_ready(|_port| {});
855 assert!(builder.on_ready.is_some());
856 }
857
858 #[test]
859 fn builder_file_navigation_disabled_by_default() {
860 let builder = VictauriBuilder::new();
861 assert!(
862 !builder.allow_file_navigation,
863 "file navigation should be disabled by default"
864 );
865 }
866
867 #[test]
868 fn builder_allow_file_navigation() {
869 let builder = VictauriBuilder::new().allow_file_navigation();
870 assert!(builder.allow_file_navigation);
871 }
872
873 #[test]
874 fn init_script_contains_custom_capacities() {
875 let caps = js_bridge::BridgeCapacities {
876 console_logs: 3000,
877 mutation_log: 750,
878 network_log: 5000,
879 navigation_log: 400,
880 dialog_log: 250,
881 long_tasks: 200,
882 };
883 let script = js_bridge::init_script(&caps);
884 assert!(script.contains("CAP_CONSOLE = 3000"));
885 assert!(script.contains("CAP_MUTATION = 750"));
886 assert!(script.contains("CAP_NETWORK = 5000"));
887 assert!(script.contains("CAP_NAVIGATION = 400"));
888 assert!(script.contains("CAP_DIALOG = 250"));
889 assert!(script.contains("CAP_LONG_TASKS = 200"));
890 }
891
892 #[test]
893 fn init_script_default_contains_standard_capacities() {
894 let caps = js_bridge::BridgeCapacities::default();
895 let script = js_bridge::init_script(&caps);
896 assert!(script.contains("CAP_CONSOLE = 1000"));
897 assert!(script.contains("CAP_NETWORK = 1000"));
898 assert!(script.contains("window.__VICTAURI__"));
899 }
900
901 #[test]
902 fn builder_validates_defaults() {
903 let builder = VictauriBuilder::new();
904 assert!(builder.validate().is_ok());
905 }
906
907 #[test]
908 fn builder_rejects_zero_port() {
909 let builder = VictauriBuilder::new().port(0);
910 let err = builder.validate().unwrap_err();
911 assert!(matches!(err, BuilderError::InvalidPort { port: 0, .. }));
912 }
913
914 #[test]
915 fn builder_rejects_zero_event_capacity() {
916 let builder = VictauriBuilder::new().event_capacity(0);
917 let err = builder.validate().unwrap_err();
918 assert!(matches!(
919 err,
920 BuilderError::InvalidEventCapacity { capacity: 0, .. }
921 ));
922 }
923
924 #[test]
925 fn builder_rejects_excessive_event_capacity() {
926 let builder = VictauriBuilder::new().event_capacity(2_000_000);
927 assert!(builder.validate().is_err());
928 }
929
930 #[test]
931 fn builder_rejects_zero_recorder_capacity() {
932 let builder = VictauriBuilder::new().recorder_capacity(0);
933 assert!(builder.validate().is_err());
934 }
935
936 #[test]
937 fn builder_rejects_zero_eval_timeout() {
938 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(0));
939 assert!(builder.validate().is_err());
940 }
941
942 #[test]
943 fn builder_rejects_excessive_eval_timeout() {
944 let builder = VictauriBuilder::new().eval_timeout(std::time::Duration::from_secs(600));
945 assert!(builder.validate().is_err());
946 }
947
948 #[test]
949 fn builder_accepts_edge_values() {
950 let builder = VictauriBuilder::new()
951 .port(1)
952 .event_capacity(1)
953 .recorder_capacity(1)
954 .eval_timeout(std::time::Duration::from_secs(1));
955 assert!(builder.validate().is_ok());
956
957 let builder = VictauriBuilder::new()
958 .port(65535)
959 .event_capacity(MAX_EVENT_CAPACITY)
960 .recorder_capacity(MAX_RECORDER_CAPACITY)
961 .eval_timeout(std::time::Duration::from_secs(MAX_EVAL_TIMEOUT_SECS));
962 assert!(builder.validate().is_ok());
963 }
964}