1use serde::{Deserialize, Serialize};
6use std::{
7 ffi::OsString,
8 fs,
9 io::{self, Write},
10 net::IpAddr,
11 path::{Path, PathBuf},
12 time::{SystemTime, UNIX_EPOCH},
13};
14
15pub const DEFAULT_BRIDGE_HOST: &str = "localhost:7600";
17
18pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8787";
26
27pub const DEFAULT_TUNING_MRFT_DELAY_SECS: u32 = 0;
29pub const DEFAULT_TUNING_POLL_INTERVAL_MS: u64 = 800;
31pub const DEFAULT_TUNING_TIMEOUT_SECS: u64 = 3_600;
33pub const DEFAULT_TUNING_OP_TIMEOUT_SECS: u64 = 30;
35pub const DEFAULT_TUNING_RESTORE_TIMEOUT_SECS: u64 = 30;
37pub const MAX_TUNING_MRFT_DELAY_SECS: u32 = 3_600;
39pub const MIN_OPC_RESTORE_TIMEOUT_SECS: u64 = 4;
41pub const DEMO_SESSION_TTL_SECS: u64 = 86_400;
43pub const DEMO_POLL_INTERVAL_MS: u64 = 200;
45pub const DEMO_RUN_TIMEOUT_SECS: u64 = 30;
47pub const DEMO_MAX_ACTIVE_RUNS_GLOBAL: u32 = 8;
49pub const DEMO_MAX_ACTIVE_RUNS_PER_VISITOR: u32 = 1;
51pub const DEMO_ACCEPTED_STARTS_PER_TOKEN: u32 = 6;
53pub const DEMO_ACCEPTED_STARTS_PER_CLIENT_IP: u32 = 6;
55pub const DEMO_ACCEPTED_START_WINDOW_SECS: u64 = 600;
57pub const DEMO_MAX_RUNS_PER_SESSION: u32 = 10;
59pub const DEMO_RETAINED_RUNS_PER_VISITOR: u32 = 10;
61pub const DEMO_MAX_TUNE_RUN_ROWS_GLOBAL: u32 = 5_000;
63pub const DEMO_MAX_JSON_BODY_BYTES: u64 = 32_768;
65pub const DEMO_MAX_SSE_PER_VISITOR: u32 = 2;
67pub const DEMO_MAX_SSE_GLOBAL: u32 = 32;
69pub const DEMO_SSE_LIFETIME_SECS: u64 = 45;
71pub const DEMO_ORDINARY_REQUEST_CONCURRENCY: u32 = 64;
73pub const DEMO_ORDINARY_REQUEST_TIMEOUT_SECS: u64 = 10;
75pub const DEMO_CLEANUP_INTERVAL_SECS: u64 = 300;
77pub const DEMO_TEMPLATE_NAME: &str = "Yokogawa CentumVP";
79pub const DEMO_TAG_NAME: &str = "Simulator demo";
84pub const DEMO_RANGE_LOW: f32 = 0.0;
86pub const DEMO_RANGE_HIGH: f32 = 100.0;
88pub const DEMO_RANGE_ENDPOINT_MIN: f32 = -1_000.0;
90pub const DEMO_RANGE_ENDPOINT_MAX: f32 = 1_000.0;
92pub const DEMO_RANGE_SPAN_MIN: f32 = 1.0;
94pub const DEMO_RANGE_SPAN_MAX: f32 = 1_000.0;
96pub const DEMO_SIM_GAIN_ABS_MIN: f32 = 0.1;
98pub const DEMO_SIM_GAIN_MAX: f32 = 5.0;
100pub const DEMO_SIM_TAU_MIN: f32 = 0.05;
102pub const DEMO_SIM_TAU_MAX: f32 = 5.0;
104pub const DEMO_SIM_DEAD_TIME_MIN: f32 = 0.0;
106pub const DEMO_SIM_DEAD_TIME_MAX: f32 = 2.0;
108pub const DEMO_SIM_NOISE_MIN: f32 = 0.0;
110pub const DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION: f32 = 0.05;
112pub const DEMO_SIM_SEED_MAX: u64 = i32::MAX as u64;
114pub const DEMO_RELAY_AMP_MIN: f32 = 1.0;
116pub const DEMO_RELAY_AMP_MAX: f32 = 20.0;
118pub const DEMO_RELAY_AMP_DEFAULT: f32 = 10.0;
120pub const DEMO_CYCLES_SKIP_MIN: u32 = 0;
122pub const DEMO_CYCLES_SKIP_MAX: u32 = 2;
124pub const DEMO_CYCLES_SKIP_DEFAULT: u32 = 1;
126pub const DEMO_CYCLES_COUNT_MIN: u32 = 1;
128pub const DEMO_CYCLES_COUNT_MAX: u32 = 3;
130pub const DEMO_CYCLES_COUNT_DEFAULT: u32 = 2;
132pub const DEMO_NOISE_PROTECTION_SECS_MIN: u32 = 0;
134pub const DEMO_NOISE_PROTECTION_SECS_MAX: u32 = 3;
136pub const DEMO_NOISE_PROTECTION_SECS_DEFAULT: u32 = 0;
138pub const DEMO_SIM_GAIN_DEFAULT: f32 = 1.0;
140pub const DEMO_SIM_TAU_DEFAULT: f32 = 0.5;
142pub const DEMO_SIM_DEAD_TIME_DEFAULT: f32 = 1.0;
144pub const DEMO_SIM_NOISE_DEFAULT: f32 = 0.0;
146pub const DEMO_SIM_SEED_DEFAULT: u64 = 0;
148pub const DEMO_SIM_INITIAL_VALUE_DEFAULT: f32 = 50.0;
150pub const DEMO_COOKIE_NAME: &str = "__Host-bhtune_demo_session";
152
153#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default, utoipa::ToSchema)]
156#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
157#[serde(rename_all = "lowercase")]
158pub enum ServerMode {
159 #[default]
160 Full,
161 Demo,
162}
163
164#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, utoipa::ToSchema)]
166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
167pub struct DemoPolicy {
168 #[schema(minimum = 86_400, maximum = 86_400)]
170 #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
171 pub session_ttl_secs: u64,
172 #[schema(minimum = 200, maximum = 200)]
174 #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
175 pub poll_interval_ms: u64,
176 #[schema(minimum = 30, maximum = 30)]
178 #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
179 pub run_timeout_secs: u64,
180 #[schema(minimum = 8, maximum = 8)]
182 #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
183 pub max_active_runs_global: u32,
184 #[schema(minimum = 1, maximum = 1)]
186 #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
187 pub max_active_runs_per_visitor: u32,
188 #[schema(minimum = 6, maximum = 6)]
190 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
191 pub accepted_starts_per_token: u32,
192 #[schema(minimum = 6, maximum = 6)]
194 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
195 pub accepted_starts_per_client_ip: u32,
196 #[schema(minimum = 600, maximum = 600)]
198 #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
199 pub accepted_start_window_secs: u64,
200 #[schema(minimum = 10, maximum = 10)]
202 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
203 pub retained_runs_per_visitor: u32,
204 pub max_runs_per_session: u32,
206 #[schema(minimum = 5_000, maximum = 5_000)]
208 #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
209 pub max_tune_run_rows_global: u32,
210 #[schema(minimum = 32_768, maximum = 32_768)]
212 #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
213 pub max_json_body_bytes: u64,
214 #[schema(minimum = 2, maximum = 2)]
216 #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
217 pub max_sse_per_visitor: u32,
218 #[schema(minimum = 32, maximum = 32)]
220 #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
221 pub max_sse_global: u32,
222 #[schema(minimum = 45, maximum = 45)]
224 #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
225 pub sse_lifetime_secs: u64,
226 #[schema(minimum = 64, maximum = 64)]
228 #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
229 pub ordinary_request_concurrency: u32,
230 #[schema(minimum = 10, maximum = 10)]
232 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
233 pub ordinary_request_timeout_secs: u64,
234 #[schema(minimum = 300, maximum = 300)]
236 #[cfg_attr(feature = "schemars", schemars(range(min = 300, max = 300)))]
237 pub cleanup_interval_secs: u64,
238}
239
240impl Default for DemoPolicy {
241 fn default() -> Self {
242 Self {
243 session_ttl_secs: DEMO_SESSION_TTL_SECS,
244 poll_interval_ms: DEMO_POLL_INTERVAL_MS,
245 run_timeout_secs: DEMO_RUN_TIMEOUT_SECS,
246 max_active_runs_global: DEMO_MAX_ACTIVE_RUNS_GLOBAL,
247 max_active_runs_per_visitor: DEMO_MAX_ACTIVE_RUNS_PER_VISITOR,
248 accepted_starts_per_token: DEMO_ACCEPTED_STARTS_PER_TOKEN,
249 accepted_starts_per_client_ip: DEMO_ACCEPTED_STARTS_PER_CLIENT_IP,
250 accepted_start_window_secs: DEMO_ACCEPTED_START_WINDOW_SECS,
251 retained_runs_per_visitor: DEMO_RETAINED_RUNS_PER_VISITOR,
252 max_runs_per_session: DEMO_MAX_RUNS_PER_SESSION,
253 max_tune_run_rows_global: DEMO_MAX_TUNE_RUN_ROWS_GLOBAL,
254 max_json_body_bytes: DEMO_MAX_JSON_BODY_BYTES,
255 max_sse_per_visitor: DEMO_MAX_SSE_PER_VISITOR,
256 max_sse_global: DEMO_MAX_SSE_GLOBAL,
257 sse_lifetime_secs: DEMO_SSE_LIFETIME_SECS,
258 ordinary_request_concurrency: DEMO_ORDINARY_REQUEST_CONCURRENCY,
259 ordinary_request_timeout_secs: DEMO_ORDINARY_REQUEST_TIMEOUT_SECS,
260 cleanup_interval_secs: DEMO_CLEANUP_INTERVAL_SECS,
261 }
262 }
263}
264
265impl DemoPolicy {
266 pub fn validate(&self) -> Result<(), String> {
267 validate_demo_value(
268 "session_ttl_secs",
269 self.session_ttl_secs,
270 DEMO_SESSION_TTL_SECS,
271 )?;
272 validate_demo_value(
273 "poll_interval_ms",
274 self.poll_interval_ms,
275 DEMO_POLL_INTERVAL_MS,
276 )?;
277 validate_demo_value(
278 "run_timeout_secs",
279 self.run_timeout_secs,
280 DEMO_RUN_TIMEOUT_SECS,
281 )?;
282 validate_demo_value(
283 "max_active_runs_global",
284 self.max_active_runs_global,
285 DEMO_MAX_ACTIVE_RUNS_GLOBAL,
286 )?;
287 validate_demo_value(
288 "max_active_runs_per_visitor",
289 self.max_active_runs_per_visitor,
290 DEMO_MAX_ACTIVE_RUNS_PER_VISITOR,
291 )?;
292 validate_demo_value(
293 "accepted_starts_per_token",
294 self.accepted_starts_per_token,
295 DEMO_ACCEPTED_STARTS_PER_TOKEN,
296 )?;
297 validate_demo_value(
298 "accepted_starts_per_client_ip",
299 self.accepted_starts_per_client_ip,
300 DEMO_ACCEPTED_STARTS_PER_CLIENT_IP,
301 )?;
302 validate_demo_value(
303 "accepted_start_window_secs",
304 self.accepted_start_window_secs,
305 DEMO_ACCEPTED_START_WINDOW_SECS,
306 )?;
307 validate_demo_value(
308 "retained_runs_per_visitor",
309 self.retained_runs_per_visitor,
310 DEMO_RETAINED_RUNS_PER_VISITOR,
311 )?;
312 validate_demo_value(
313 "max_runs_per_session",
314 self.max_runs_per_session,
315 DEMO_MAX_RUNS_PER_SESSION,
316 )?;
317 validate_demo_value(
318 "max_tune_run_rows_global",
319 self.max_tune_run_rows_global,
320 DEMO_MAX_TUNE_RUN_ROWS_GLOBAL,
321 )?;
322 validate_demo_value(
323 "max_json_body_bytes",
324 self.max_json_body_bytes,
325 DEMO_MAX_JSON_BODY_BYTES,
326 )?;
327 validate_demo_value(
328 "max_sse_per_visitor",
329 self.max_sse_per_visitor,
330 DEMO_MAX_SSE_PER_VISITOR,
331 )?;
332 validate_demo_value("max_sse_global", self.max_sse_global, DEMO_MAX_SSE_GLOBAL)?;
333 validate_demo_value(
334 "sse_lifetime_secs",
335 self.sse_lifetime_secs,
336 DEMO_SSE_LIFETIME_SECS,
337 )?;
338 validate_demo_value(
339 "ordinary_request_concurrency",
340 self.ordinary_request_concurrency,
341 DEMO_ORDINARY_REQUEST_CONCURRENCY,
342 )?;
343 validate_demo_value(
344 "ordinary_request_timeout_secs",
345 self.ordinary_request_timeout_secs,
346 DEMO_ORDINARY_REQUEST_TIMEOUT_SECS,
347 )?;
348 validate_demo_value(
349 "cleanup_interval_secs",
350 self.cleanup_interval_secs,
351 DEMO_CLEANUP_INTERVAL_SECS,
352 )?;
353 Ok(())
354 }
355}
356
357fn validate_demo_value<T>(field: &str, actual: T, expected: T) -> Result<(), String>
358where
359 T: PartialEq + std::fmt::Display,
360{
361 if actual == expected {
362 Ok(())
363 } else {
364 Err(format!("demo.{field} must be exactly {expected}"))
365 }
366}
367
368#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, utoipa::ToSchema)]
373#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
374#[serde(deny_unknown_fields)]
375pub struct DemoPolicyConfig {
376 #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
378 pub session_ttl_secs: Option<u64>,
379 #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
381 pub poll_interval_ms: Option<u64>,
382 #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
384 pub run_timeout_secs: Option<u64>,
385 #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
387 pub max_active_runs_global: Option<u32>,
388 #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
390 pub max_active_runs_per_visitor: Option<u32>,
391 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
393 pub accepted_starts_per_token: Option<u32>,
394 #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
396 pub accepted_starts_per_client_ip: Option<u32>,
397 #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
399 pub accepted_start_window_secs: Option<u64>,
400 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
402 pub retained_runs_per_visitor: Option<u32>,
403 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
405 pub max_runs_per_session: Option<u32>,
406 #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
408 pub max_tune_run_rows_global: Option<u32>,
409 #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
411 pub max_json_body_bytes: Option<u64>,
412 #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
414 pub max_sse_per_visitor: Option<u32>,
415 #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
417 pub max_sse_global: Option<u32>,
418 #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
420 pub sse_lifetime_secs: Option<u64>,
421 #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
423 pub ordinary_request_concurrency: Option<u32>,
424 #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
426 pub ordinary_request_timeout_secs: Option<u64>,
427 #[cfg_attr(feature = "schemars", schemars(range(min = 300, max = 300)))]
429 pub cleanup_interval_secs: Option<u64>,
430}
431
432pub fn resolve_demo_policy(config: &DemoPolicyConfig) -> Result<DemoPolicy, String> {
433 let defaults = DemoPolicy::default();
434 let policy = DemoPolicy {
435 session_ttl_secs: config.session_ttl_secs.unwrap_or(defaults.session_ttl_secs),
436 poll_interval_ms: config.poll_interval_ms.unwrap_or(defaults.poll_interval_ms),
437 run_timeout_secs: config.run_timeout_secs.unwrap_or(defaults.run_timeout_secs),
438 max_active_runs_global: config
439 .max_active_runs_global
440 .unwrap_or(defaults.max_active_runs_global),
441 max_active_runs_per_visitor: config
442 .max_active_runs_per_visitor
443 .unwrap_or(defaults.max_active_runs_per_visitor),
444 accepted_starts_per_token: config
445 .accepted_starts_per_token
446 .unwrap_or(defaults.accepted_starts_per_token),
447 accepted_starts_per_client_ip: config
448 .accepted_starts_per_client_ip
449 .unwrap_or(defaults.accepted_starts_per_client_ip),
450 accepted_start_window_secs: config
451 .accepted_start_window_secs
452 .unwrap_or(defaults.accepted_start_window_secs),
453 retained_runs_per_visitor: config
454 .retained_runs_per_visitor
455 .unwrap_or(defaults.retained_runs_per_visitor),
456 max_runs_per_session: config
457 .max_runs_per_session
458 .unwrap_or(defaults.max_runs_per_session),
459 max_tune_run_rows_global: config
460 .max_tune_run_rows_global
461 .unwrap_or(defaults.max_tune_run_rows_global),
462 max_json_body_bytes: config
463 .max_json_body_bytes
464 .unwrap_or(defaults.max_json_body_bytes),
465 max_sse_per_visitor: config
466 .max_sse_per_visitor
467 .unwrap_or(defaults.max_sse_per_visitor),
468 max_sse_global: config.max_sse_global.unwrap_or(defaults.max_sse_global),
469 sse_lifetime_secs: config
470 .sse_lifetime_secs
471 .unwrap_or(defaults.sse_lifetime_secs),
472 ordinary_request_concurrency: config
473 .ordinary_request_concurrency
474 .unwrap_or(defaults.ordinary_request_concurrency),
475 ordinary_request_timeout_secs: config
476 .ordinary_request_timeout_secs
477 .unwrap_or(defaults.ordinary_request_timeout_secs),
478 cleanup_interval_secs: config
479 .cleanup_interval_secs
480 .unwrap_or(defaults.cleanup_interval_secs),
481 };
482 policy.validate()?;
483 Ok(policy)
484}
485
486pub fn resolve_server_mode(
487 env_mode: Option<&str>,
488 config: &BhtuneConfig,
489) -> Result<ServerMode, String> {
490 let raw = env_mode.map(str::to_owned).or_else(|| {
491 config.server_mode.map(|mode| match mode {
492 ServerMode::Full => "full".to_owned(),
493 ServerMode::Demo => "demo".to_owned(),
494 })
495 });
496 match raw
497 .as_deref()
498 .unwrap_or("full")
499 .to_ascii_lowercase()
500 .as_str()
501 {
502 "full" => Ok(ServerMode::Full),
503 "demo" => Ok(ServerMode::Demo),
504 other => Err(format!(
505 "invalid server mode '{other}'; expected 'full' or 'demo'"
506 )),
507 }
508}
509
510pub fn resolve_demo_policy_from_config(config: &BhtuneConfig) -> Result<DemoPolicy, String> {
511 resolve_demo_policy(&config.demo)
512}
513
514#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
520#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
521pub struct TuningConfig {
522 #[cfg_attr(feature = "schemars", schemars(range(max = 3_600)))]
523 pub mrft_delay_secs: Option<u32>,
524 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
525 pub poll_interval_ms: Option<u64>,
526 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
527 pub timeout_secs: Option<u64>,
528 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
529 pub op_timeout_secs: Option<u64>,
530 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
531 pub restore_timeout_secs: Option<u64>,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub struct EffectiveTuningConfig {
537 pub mrft_delay_secs: u32,
538 pub poll_interval_ms: u64,
539 pub timeout_secs: u64,
540 pub op_timeout_secs: u64,
541 pub restore_timeout_secs: u64,
542}
543
544impl Default for EffectiveTuningConfig {
545 fn default() -> Self {
546 Self {
547 mrft_delay_secs: DEFAULT_TUNING_MRFT_DELAY_SECS,
548 poll_interval_ms: DEFAULT_TUNING_POLL_INTERVAL_MS,
549 timeout_secs: DEFAULT_TUNING_TIMEOUT_SECS,
550 op_timeout_secs: DEFAULT_TUNING_OP_TIMEOUT_SECS,
551 restore_timeout_secs: DEFAULT_TUNING_RESTORE_TIMEOUT_SECS,
552 }
553 }
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub enum TuningConfigSource {
559 Toml,
560 BuiltInDefault,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub struct TuningConfigSources {
566 pub mrft_delay_secs: TuningConfigSource,
567 pub poll_interval_ms: TuningConfigSource,
568 pub timeout_secs: TuningConfigSource,
569 pub op_timeout_secs: TuningConfigSource,
570 pub restore_timeout_secs: TuningConfigSource,
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum TuningConfigError {
576 MrftDelayOutOfRange { value: u32 },
577 PollIntervalTooSmall { value: u64 },
578 TimeoutTooSmall { value: u64 },
579 OpTimeoutTooSmall { value: u64 },
580 RestoreTimeoutTooSmall { value: u64, minimum: u64 },
581}
582
583impl std::fmt::Display for TuningConfigError {
584 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585 match self {
586 Self::MrftDelayOutOfRange { value } => write!(
587 f,
588 "tuning.mrft_delay_secs must be between 0 and {MAX_TUNING_MRFT_DELAY_SECS}, got {value}"
589 ),
590 Self::PollIntervalTooSmall { value } => {
591 write!(f, "tuning.poll_interval_ms must be at least 1, got {value}")
592 }
593 Self::TimeoutTooSmall { value } => {
594 write!(f, "tuning.timeout_secs must be at least 1, got {value}")
595 }
596 Self::OpTimeoutTooSmall { value } => {
597 write!(f, "tuning.op_timeout_secs must be at least 1, got {value}")
598 }
599 Self::RestoreTimeoutTooSmall { value, minimum } => write!(
600 f,
601 "tuning.restore_timeout_secs must be at least {minimum}, got {value}"
602 ),
603 }
604 }
605}
606
607impl std::error::Error for TuningConfigError {}
608
609pub fn resolve_tuning_config(config: &TuningConfig) -> EffectiveTuningConfig {
611 EffectiveTuningConfig {
612 mrft_delay_secs: config
613 .mrft_delay_secs
614 .unwrap_or(DEFAULT_TUNING_MRFT_DELAY_SECS),
615 poll_interval_ms: config
616 .poll_interval_ms
617 .unwrap_or(DEFAULT_TUNING_POLL_INTERVAL_MS),
618 timeout_secs: config.timeout_secs.unwrap_or(DEFAULT_TUNING_TIMEOUT_SECS),
619 op_timeout_secs: config
620 .op_timeout_secs
621 .unwrap_or(DEFAULT_TUNING_OP_TIMEOUT_SECS),
622 restore_timeout_secs: config
623 .restore_timeout_secs
624 .unwrap_or(DEFAULT_TUNING_RESTORE_TIMEOUT_SECS),
625 }
626}
627
628pub fn tuning_config_sources(config: &TuningConfig) -> TuningConfigSources {
630 fn source<T>(value: Option<T>) -> TuningConfigSource {
631 if value.is_some() {
632 TuningConfigSource::Toml
633 } else {
634 TuningConfigSource::BuiltInDefault
635 }
636 }
637
638 TuningConfigSources {
639 mrft_delay_secs: source(config.mrft_delay_secs),
640 poll_interval_ms: source(config.poll_interval_ms),
641 timeout_secs: source(config.timeout_secs),
642 op_timeout_secs: source(config.op_timeout_secs),
643 restore_timeout_secs: source(config.restore_timeout_secs),
644 }
645}
646
647pub fn validate_tuning_config(
652 config: &EffectiveTuningConfig,
653 require_opc_restore_minimum: bool,
654) -> Result<(), TuningConfigError> {
655 if config.mrft_delay_secs > MAX_TUNING_MRFT_DELAY_SECS {
656 return Err(TuningConfigError::MrftDelayOutOfRange {
657 value: config.mrft_delay_secs,
658 });
659 }
660 if config.poll_interval_ms == 0 {
661 return Err(TuningConfigError::PollIntervalTooSmall {
662 value: config.poll_interval_ms,
663 });
664 }
665 if config.timeout_secs == 0 {
666 return Err(TuningConfigError::TimeoutTooSmall {
667 value: config.timeout_secs,
668 });
669 }
670 if config.op_timeout_secs == 0 {
671 return Err(TuningConfigError::OpTimeoutTooSmall {
672 value: config.op_timeout_secs,
673 });
674 }
675 let restore_minimum = if require_opc_restore_minimum {
676 MIN_OPC_RESTORE_TIMEOUT_SECS
677 } else {
678 1
679 };
680 if config.restore_timeout_secs < restore_minimum {
681 return Err(TuningConfigError::RestoreTimeoutTooSmall {
682 value: config.restore_timeout_secs,
683 minimum: restore_minimum,
684 });
685 }
686 Ok(())
687}
688
689pub fn resolve_and_validate_tuning_config(
691 config: &TuningConfig,
692 require_opc_restore_minimum: bool,
693) -> Result<EffectiveTuningConfig, TuningConfigError> {
694 let effective = resolve_tuning_config(config);
695 validate_tuning_config(&effective, require_opc_restore_minimum)?;
696 Ok(effective)
697}
698
699#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
704pub struct BhtuneConfig {
705 #[serde(default)]
707 pub server_mode: Option<ServerMode>,
708 #[serde(default)]
710 pub demo: DemoPolicyConfig,
711 pub db: Option<PathBuf>,
713 pub bridge_host: Option<String>,
716 pub server: Option<String>,
720 pub templates: Option<PathBuf>,
725 pub bind: Option<String>,
728 #[serde(default)]
732 pub origin: Option<String>,
733 #[serde(default)]
736 pub trusted_proxy: Option<String>,
737 #[serde(default, deserialize_with = "deserialize_retention_days")]
746 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
747 pub retention_days: Option<u32>,
748 #[serde(default = "default_allow_uncertain_quality")]
753 pub allow_uncertain_quality: bool,
754 #[serde(default)]
757 pub tuning: TuningConfig,
758 #[serde(default)]
761 pub log: LogConfig,
762}
763
764#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
769#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
770pub struct LogConfig {
771 pub level: Option<String>,
772 pub dir: Option<String>,
773 pub format: Option<String>,
774 pub rotation: Option<String>,
775}
776
777pub const fn default_allow_uncertain_quality() -> bool {
780 true
781}
782
783fn deserialize_retention_days<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
784where
785 D: serde::Deserializer<'de>,
786{
787 let days = Option::<u32>::deserialize(deserializer)?;
788 match days {
789 Some(0) => Err(serde::de::Error::custom(
790 "retention_days must be at least 1 or omitted",
791 )),
792 other => Ok(other),
793 }
794}
795
796impl Default for BhtuneConfig {
797 fn default() -> Self {
798 Self {
799 server_mode: None,
800 demo: DemoPolicyConfig::default(),
801 db: None,
802 bridge_host: None,
803 server: None,
804 templates: None,
805 bind: None,
806 origin: None,
807 trusted_proxy: None,
808 retention_days: None,
809 allow_uncertain_quality: default_allow_uncertain_quality(),
810 tuning: TuningConfig::default(),
811 log: LogConfig::default(),
812 }
813 }
814}
815
816#[derive(Debug, Clone, PartialEq, Eq)]
819pub struct ConfigPathResolution {
820 pub path: Option<PathBuf>,
821 pub missing_is_allowed: bool,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
826pub struct LoadedConfigStore {
827 pub path: Option<PathBuf>,
828 pub missing_is_allowed: bool,
829 pub original_raw: Option<String>,
830 pub config: BhtuneConfig,
831 pub revision: String,
832 pub toml_allow_uncertain_quality: Option<bool>,
835 pub toml_tuning: TuningConfig,
837 pub tuning_sources: TuningConfigSources,
839}
840
841#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct ConfigPolicyUpdate {
845 pub allow_uncertain_quality: bool,
846 pub retention_days: Option<u32>,
847 pub mrft_delay_secs: Option<u32>,
848 pub poll_interval_ms: Option<u64>,
849 pub timeout_secs: Option<u64>,
850 pub op_timeout_secs: Option<u64>,
851 pub restore_timeout_secs: Option<u64>,
852}
853
854impl ConfigPolicyUpdate {
855 pub fn tuning(&self) -> TuningConfig {
856 TuningConfig {
857 mrft_delay_secs: self.mrft_delay_secs,
858 poll_interval_ms: self.poll_interval_ms,
859 timeout_secs: self.timeout_secs,
860 op_timeout_secs: self.op_timeout_secs,
861 restore_timeout_secs: self.restore_timeout_secs,
862 }
863 }
864}
865
866impl Default for ConfigPolicyUpdate {
867 fn default() -> Self {
868 Self {
869 allow_uncertain_quality: default_allow_uncertain_quality(),
870 retention_days: None,
871 mrft_delay_secs: None,
872 poll_interval_ms: None,
873 timeout_secs: None,
874 op_timeout_secs: None,
875 restore_timeout_secs: None,
876 }
877 }
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct ConfigSaveResult {
883 pub backup_path: Option<PathBuf>,
884 pub state: LoadedConfigStore,
885}
886
887#[derive(Debug)]
889pub enum ConfigStoreError {
890 PathNotResolved,
891 Missing {
892 path: PathBuf,
893 },
894 Unreadable {
895 path: PathBuf,
896 source: io::Error,
897 },
898 Malformed {
899 path: Option<PathBuf>,
900 source: String,
901 },
902 Conflict {
903 path: Option<PathBuf>,
904 message: String,
905 },
906 Write {
907 path: PathBuf,
908 action: &'static str,
909 source: io::Error,
910 },
911}
912
913impl std::fmt::Display for ConfigStoreError {
914 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
915 match self {
916 Self::PathNotResolved => write!(
917 f,
918 "no config path could be resolved from --config / XDG_CONFIG_HOME / HOME / APPDATA"
919 ),
920 Self::Missing { path } => write!(f, "config file not found: {}", path.display()),
921 Self::Unreadable { path, source } => {
922 write!(f, "failed to read config file {}: {source}", path.display())
923 }
924 Self::Malformed {
925 path: Some(path),
926 source,
927 } => write!(
928 f,
929 "failed to parse config file {}: {source}",
930 path.display()
931 ),
932 Self::Malformed { path: None, source } => {
933 write!(f, "failed to parse config contents: {source}")
934 }
935 Self::Conflict {
936 path: Some(path),
937 message,
938 } => write!(f, "config store conflict for {}: {message}", path.display()),
939 Self::Conflict {
940 path: None,
941 message,
942 } => write!(f, "config store conflict: {message}"),
943 Self::Write {
944 path,
945 action,
946 source,
947 } => write!(f, "failed to {action} {}: {source}", path.display()),
948 }
949 }
950}
951
952impl std::error::Error for ConfigStoreError {
953 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
954 match self {
955 Self::Unreadable { source, .. } | Self::Write { source, .. } => Some(source),
956 _ => None,
957 }
958 }
959}
960
961pub fn config_path_from(
969 xdg_config_home: Option<&str>,
970 home: Option<&str>,
971 appdata: Option<&str>,
972 is_windows: bool,
973) -> Option<PathBuf> {
974 if is_windows {
975 return appdata.map(|dir| Path::new(dir).join("bhtune").join("bhtune.toml"));
976 }
977 if let Some(dir) = xdg_config_home {
978 return Some(Path::new(dir).join("bhtune").join("bhtune.toml"));
979 }
980 home.map(|dir| {
981 Path::new(dir)
982 .join(".config")
983 .join("bhtune")
984 .join("bhtune.toml")
985 })
986}
987
988pub fn resolve_config_store_path(
993 explicit_path: Option<&Path>,
994 xdg_config_home: Option<&str>,
995 home: Option<&str>,
996 appdata: Option<&str>,
997 is_windows: bool,
998) -> ConfigPathResolution {
999 match explicit_path {
1000 Some(path) => ConfigPathResolution {
1001 path: Some(path.to_path_buf()),
1002 missing_is_allowed: false,
1003 },
1004 None => ConfigPathResolution {
1005 path: config_path_from(xdg_config_home, home, appdata, is_windows),
1006 missing_is_allowed: true,
1007 },
1008 }
1009}
1010
1011const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
1012const FNV1A_PRIME: u64 = 0x100000001b3;
1013
1014fn stable_revision_hash(bytes: &[u8]) -> u64 {
1015 let mut hash = FNV1A_OFFSET_BASIS;
1016 for byte in bytes {
1017 hash ^= u64::from(*byte);
1018 hash = hash.wrapping_mul(FNV1A_PRIME);
1019 }
1020 hash
1021}
1022
1023fn revision_token_for_raw(raw: Option<&str>) -> String {
1024 match raw {
1025 Some(raw) => format!(
1026 "present:v1:{}:{:016x}",
1027 raw.len(),
1028 stable_revision_hash(raw.as_bytes())
1029 ),
1030 None => "absent:v1".to_string(),
1031 }
1032}
1033
1034fn config_malformed(path: Option<&Path>, error: impl std::fmt::Display) -> ConfigStoreError {
1035 ConfigStoreError::Malformed {
1036 path: path.map(Path::to_path_buf),
1037 source: error.to_string(),
1038 }
1039}
1040
1041fn load_config_store_from_resolution(
1042 resolution: ConfigPathResolution,
1043) -> Result<LoadedConfigStore, ConfigStoreError> {
1044 match resolution.path {
1045 Some(path) => match fs::read(&path) {
1046 Ok(bytes) => {
1047 let raw = String::from_utf8(bytes).map_err(|e| {
1048 config_malformed(Some(&path), format!("config file is not valid UTF-8: {e}"))
1049 })?;
1050 let config =
1051 parse_config_contents(&raw).map_err(|e| config_malformed(Some(&path), e))?;
1052 let toml_allow_uncertain_quality = raw
1053 .parse::<toml_edit::DocumentMut>()
1054 .ok()
1055 .and_then(|document| {
1056 document
1057 .get("allow_uncertain_quality")
1058 .and_then(|item| item.as_value())
1059 .and_then(|value| value.as_bool())
1060 });
1061 let toml_tuning = config.tuning;
1062 let tuning_sources = tuning_config_sources(&toml_tuning);
1063 let revision = revision_token_for_raw(Some(&raw));
1064 Ok(LoadedConfigStore {
1065 path: Some(path),
1066 missing_is_allowed: resolution.missing_is_allowed,
1067 original_raw: Some(raw),
1068 config,
1069 revision,
1070 toml_allow_uncertain_quality,
1071 toml_tuning,
1072 tuning_sources,
1073 })
1074 }
1075 Err(e) if e.kind() == io::ErrorKind::NotFound && resolution.missing_is_allowed => {
1076 let revision = revision_token_for_raw(None);
1077 Ok(LoadedConfigStore {
1078 path: Some(path),
1079 missing_is_allowed: true,
1080 original_raw: None,
1081 config: BhtuneConfig::default(),
1082 revision,
1083 toml_allow_uncertain_quality: None,
1084 toml_tuning: TuningConfig::default(),
1085 tuning_sources: tuning_config_sources(&TuningConfig::default()),
1086 })
1087 }
1088 Err(e) if e.kind() == io::ErrorKind::NotFound => {
1089 Err(ConfigStoreError::Missing { path })
1090 }
1091 Err(e) => Err(ConfigStoreError::Unreadable { path, source: e }),
1092 },
1093 None => Ok(LoadedConfigStore {
1094 path: None,
1095 missing_is_allowed: true,
1096 original_raw: None,
1097 config: BhtuneConfig::default(),
1098 revision: revision_token_for_raw(None),
1099 toml_allow_uncertain_quality: None,
1100 toml_tuning: TuningConfig::default(),
1101 tuning_sources: tuning_config_sources(&TuningConfig::default()),
1102 }),
1103 }
1104}
1105
1106pub fn templates_path_from(
1117 xdg_config_home: Option<&str>,
1118 home: Option<&str>,
1119 appdata: Option<&str>,
1120 is_windows: bool,
1121) -> Option<PathBuf> {
1122 if is_windows {
1123 return appdata.map(|dir| Path::new(dir).join("bhtune").join("templates.toml"));
1124 }
1125 if let Some(dir) = xdg_config_home {
1126 return Some(Path::new(dir).join("bhtune").join("templates.toml"));
1127 }
1128 home.map(|dir| {
1129 Path::new(dir)
1130 .join(".config")
1131 .join("bhtune")
1132 .join("templates.toml")
1133 })
1134}
1135
1136pub fn default_db_path_from(
1148 xdg_data_home: Option<&str>,
1149 home: Option<&str>,
1150 appdata: Option<&str>,
1151 is_windows: bool,
1152) -> PathBuf {
1153 if is_windows {
1154 return appdata
1155 .map(|dir| Path::new(dir).join("bhtune").join("bhtune.db"))
1156 .unwrap_or_else(|| PathBuf::from("bhtune.db"));
1157 }
1158 if let Some(dir) = xdg_data_home {
1159 return Path::new(dir).join("bhtune").join("bhtune.db");
1160 }
1161 home.map(|dir| {
1162 Path::new(dir)
1163 .join(".local")
1164 .join("share")
1165 .join("bhtune")
1166 .join("bhtune.db")
1167 })
1168 .unwrap_or_else(|| PathBuf::from("bhtune.db"))
1169}
1170
1171pub fn default_log_dir_from(
1183 xdg_data_home: Option<&str>,
1184 home: Option<&str>,
1185 appdata: Option<&str>,
1186 is_windows: bool,
1187) -> PathBuf {
1188 if is_windows {
1189 return appdata
1190 .map(|dir| Path::new(dir).join("bhtune").join("logs"))
1191 .unwrap_or_else(|| PathBuf::from("logs"));
1192 }
1193 if let Some(dir) = xdg_data_home {
1194 return Path::new(dir).join("bhtune").join("logs");
1195 }
1196 home.map(|dir| {
1197 Path::new(dir)
1198 .join(".local")
1199 .join("share")
1200 .join("bhtune")
1201 .join("logs")
1202 })
1203 .unwrap_or_else(|| PathBuf::from("logs"))
1204}
1205
1206pub fn load_config_file(path: &Path, missing_is_error: bool) -> anyhow::Result<BhtuneConfig> {
1213 load_config_store_from_resolution(ConfigPathResolution {
1214 path: Some(path.to_path_buf()),
1215 missing_is_allowed: !missing_is_error,
1216 })
1217 .map(|store| store.config)
1218 .map_err(|e| anyhow::anyhow!(e.to_string()))
1219}
1220
1221pub fn parse_config_contents(contents: &str) -> anyhow::Result<BhtuneConfig> {
1226 toml::from_str(contents).map_err(Into::into)
1227}
1228
1229fn patch_config_contents<F>(raw: Option<&str>, mutator: F) -> Result<(String, BhtuneConfig), String>
1230where
1231 F: FnOnce(&mut toml_edit::DocumentMut),
1232{
1233 let mut document = raw
1234 .unwrap_or_default()
1235 .parse::<toml_edit::DocumentMut>()
1236 .map_err(|e| e.to_string())?;
1237 mutator(&mut document);
1238 let patched = document.to_string();
1239 let parsed = parse_config_contents(&patched).map_err(|e| e.to_string())?;
1240 Ok((patched, parsed))
1241}
1242
1243pub fn patch_allow_uncertain_quality(
1246 raw: Option<&str>,
1247 allow_uncertain_quality: bool,
1248) -> Result<String, ConfigStoreError> {
1249 patch_config_contents(raw, |document| {
1250 document["allow_uncertain_quality"] = toml_edit::value(allow_uncertain_quality);
1251 })
1252 .map_err(|source| config_malformed(None, source))
1253 .map(|(patched, _)| patched)
1254}
1255
1256pub fn patch_retention_days(
1259 raw: Option<&str>,
1260 retention_days: Option<u32>,
1261) -> Result<String, ConfigStoreError> {
1262 patch_config_contents(raw, |document| match retention_days {
1263 Some(days) => {
1264 document["retention_days"] = toml_edit::value(i64::from(days));
1265 }
1266 None => {
1267 document.as_table_mut().remove("retention_days");
1268 }
1269 })
1270 .map_err(|source| config_malformed(None, source))
1271 .map(|(patched, _)| patched)
1272}
1273
1274fn patch_optional_tuning_value<T>(
1275 document: &mut toml_edit::DocumentMut,
1276 key: &str,
1277 value: Option<T>,
1278) where
1279 T: Into<toml_edit::Value>,
1280{
1281 match value {
1282 Some(value) => {
1283 if document.get("tuning").is_none() {
1284 document["tuning"] = toml_edit::table();
1285 }
1286 document["tuning"][key] = toml_edit::value(value);
1287 }
1288 None => {
1289 if let Some(table) = document
1290 .get_mut("tuning")
1291 .and_then(toml_edit::Item::as_table_like_mut)
1292 {
1293 table.remove(key);
1294 }
1295 }
1296 }
1297}
1298
1299fn optional_u64_to_toml_integer(
1300 field: &'static str,
1301 value: Option<u64>,
1302) -> Result<Option<i64>, String> {
1303 value
1304 .map(|value| {
1305 i64::try_from(value)
1306 .map_err(|_| format!("tuning.{field} is too large to store as a TOML integer"))
1307 })
1308 .transpose()
1309}
1310
1311pub fn patch_tuning_config(
1314 raw: Option<&str>,
1315 tuning: &TuningConfig,
1316) -> Result<String, ConfigStoreError> {
1317 resolve_and_validate_tuning_config(tuning, false)
1318 .map_err(|source| config_malformed(None, source))?;
1319 let poll_interval_ms =
1320 optional_u64_to_toml_integer("poll_interval_ms", tuning.poll_interval_ms)
1321 .map_err(|source| config_malformed(None, source))?;
1322 let timeout_secs = optional_u64_to_toml_integer("timeout_secs", tuning.timeout_secs)
1323 .map_err(|source| config_malformed(None, source))?;
1324 let op_timeout_secs = optional_u64_to_toml_integer("op_timeout_secs", tuning.op_timeout_secs)
1325 .map_err(|source| config_malformed(None, source))?;
1326 let restore_timeout_secs =
1327 optional_u64_to_toml_integer("restore_timeout_secs", tuning.restore_timeout_secs)
1328 .map_err(|source| config_malformed(None, source))?;
1329 let (patched, parsed) = patch_config_contents(raw, |document| {
1330 patch_optional_tuning_value(
1331 document,
1332 "mrft_delay_secs",
1333 tuning.mrft_delay_secs.map(i64::from),
1334 );
1335 patch_optional_tuning_value(document, "poll_interval_ms", poll_interval_ms);
1336 patch_optional_tuning_value(document, "timeout_secs", timeout_secs);
1337 patch_optional_tuning_value(document, "op_timeout_secs", op_timeout_secs);
1338 patch_optional_tuning_value(document, "restore_timeout_secs", restore_timeout_secs);
1339 })
1340 .map_err(|source| config_malformed(None, source))?;
1341 resolve_and_validate_tuning_config(&parsed.tuning, false)
1342 .map_err(|source| config_malformed(None, source))?;
1343 Ok(patched)
1344}
1345
1346fn patch_config_policy(
1347 raw: Option<&str>,
1348 update: &ConfigPolicyUpdate,
1349) -> Result<(String, BhtuneConfig), String> {
1350 let tuning = update.tuning();
1351 resolve_and_validate_tuning_config(&tuning, false).map_err(|e| e.to_string())?;
1352 let poll_interval_ms =
1353 optional_u64_to_toml_integer("poll_interval_ms", tuning.poll_interval_ms)?;
1354 let timeout_secs = optional_u64_to_toml_integer("timeout_secs", tuning.timeout_secs)?;
1355 let op_timeout_secs = optional_u64_to_toml_integer("op_timeout_secs", tuning.op_timeout_secs)?;
1356 let restore_timeout_secs =
1357 optional_u64_to_toml_integer("restore_timeout_secs", tuning.restore_timeout_secs)?;
1358 let result = patch_config_contents(raw, |document| {
1359 document["allow_uncertain_quality"] = toml_edit::value(update.allow_uncertain_quality);
1360 match update.retention_days {
1361 Some(days) => {
1362 document["retention_days"] = toml_edit::value(i64::from(days));
1363 }
1364 None => {
1365 document.as_table_mut().remove("retention_days");
1366 }
1367 }
1368 patch_optional_tuning_value(
1369 document,
1370 "mrft_delay_secs",
1371 tuning.mrft_delay_secs.map(i64::from),
1372 );
1373 patch_optional_tuning_value(document, "poll_interval_ms", poll_interval_ms);
1374 patch_optional_tuning_value(document, "timeout_secs", timeout_secs);
1375 patch_optional_tuning_value(document, "op_timeout_secs", op_timeout_secs);
1376 patch_optional_tuning_value(document, "restore_timeout_secs", restore_timeout_secs);
1377 })?;
1378 resolve_and_validate_tuning_config(&result.1.tuning, false).map_err(|e| e.to_string())?;
1379 Ok(result)
1380}
1381
1382pub fn load_config_store(
1384 explicit_path: Option<&Path>,
1385) -> Result<LoadedConfigStore, ConfigStoreError> {
1386 load_config_store_from(
1387 explicit_path,
1388 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
1389 std::env::var("HOME").ok().as_deref(),
1390 std::env::var("APPDATA").ok().as_deref(),
1391 cfg!(target_os = "windows"),
1392 )
1393}
1394
1395pub fn load_config_store_from(
1399 explicit_path: Option<&Path>,
1400 xdg_config_home: Option<&str>,
1401 home: Option<&str>,
1402 appdata: Option<&str>,
1403 is_windows: bool,
1404) -> Result<LoadedConfigStore, ConfigStoreError> {
1405 load_config_store_from_resolution(resolve_config_store_path(
1406 explicit_path,
1407 xdg_config_home,
1408 home,
1409 appdata,
1410 is_windows,
1411 ))
1412}
1413
1414fn ensure_parent_dir(path: &Path) -> Result<(), ConfigStoreError> {
1415 path.parent()
1416 .filter(|parent| !parent.as_os_str().is_empty())
1417 .map(|parent| {
1418 fs::create_dir_all(parent).map_err(|e| ConfigStoreError::Write {
1419 path: path.to_path_buf(),
1420 action: "create config directory",
1421 source: e,
1422 })
1423 })
1424 .transpose()
1425 .map(|_| ())
1426}
1427
1428fn unique_suffix() -> String {
1429 let timestamp = SystemTime::now()
1430 .duration_since(UNIX_EPOCH)
1431 .unwrap_or_default();
1432 format!(
1433 "{}-{}-{:09}",
1434 std::process::id(),
1435 timestamp.as_secs(),
1436 timestamp.subsec_nanos()
1437 )
1438}
1439
1440fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
1441 let mut file_name = path
1442 .file_name()
1443 .map(OsString::from)
1444 .unwrap_or_else(|| OsString::from("bhtune.toml"));
1445 file_name.push(format!(".{suffix}-{}", unique_suffix()));
1446 path.with_file_name(file_name)
1447}
1448
1449fn backup_path_for(path: &Path) -> PathBuf {
1450 let mut file_name = path
1451 .file_name()
1452 .map(OsString::from)
1453 .unwrap_or_else(|| OsString::from("bhtune.toml"));
1454 file_name.push(format!(".backup-{}.bak", unique_suffix()));
1455 path.with_file_name(file_name)
1456}
1457
1458fn create_temp_file(path: &Path) -> Result<(PathBuf, fs::File), ConfigStoreError> {
1459 create_temp_file_with(path, || sibling_with_suffix(path, "tmp"))
1460}
1461
1462fn create_temp_file_with<F>(
1463 path: &Path,
1464 mut next_path: F,
1465) -> Result<(PathBuf, fs::File), ConfigStoreError>
1466where
1467 F: FnMut() -> PathBuf,
1468{
1469 for _ in 0..16 {
1470 let temp_path = next_path();
1471 match fs::OpenOptions::new()
1472 .create_new(true)
1473 .truncate(true)
1474 .write(true)
1475 .open(&temp_path)
1476 {
1477 Ok(file) => return Ok((temp_path, file)),
1478 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
1479 Err(e) => {
1480 return Err(ConfigStoreError::Write {
1481 path: path.to_path_buf(),
1482 action: "create temporary config file",
1483 source: e,
1484 });
1485 }
1486 }
1487 }
1488
1489 Err(ConfigStoreError::Write {
1490 path: path.to_path_buf(),
1491 action: "create temporary config file",
1492 source: io::Error::new(
1493 io::ErrorKind::AlreadyExists,
1494 "exhausted unique temp-file names",
1495 ),
1496 })
1497}
1498
1499trait SyncConfigFile {
1500 fn sync_config(&self) -> io::Result<()>;
1501}
1502
1503impl SyncConfigFile for fs::File {
1504 fn sync_config(&self) -> io::Result<()> {
1505 self.sync_all()
1506 }
1507}
1508
1509fn write_and_flush_temp_file<T>(
1510 path: &Path,
1511 temp_path: &Path,
1512 bytes: &[u8],
1513 temp_file: &mut T,
1514) -> Result<(), ConfigStoreError>
1515where
1516 T: Write + SyncConfigFile,
1517{
1518 if let Err(e) = temp_file.write_all(bytes) {
1519 let _ = fs::remove_file(temp_path);
1520 return Err(ConfigStoreError::Write {
1521 path: path.to_path_buf(),
1522 action: "write temporary config file",
1523 source: e,
1524 });
1525 }
1526 if let Err(e) = temp_file.sync_config() {
1527 let _ = fs::remove_file(temp_path);
1528 return Err(ConfigStoreError::Write {
1529 path: path.to_path_buf(),
1530 action: "flush temporary config file",
1531 source: e,
1532 });
1533 }
1534 Ok(())
1535}
1536
1537#[cfg(unix)]
1538fn sync_parent_dir(path: &Path) {
1539 if let Some(parent) = path
1540 .parent()
1541 .filter(|parent| !parent.as_os_str().is_empty())
1542 && let Ok(dir) = fs::File::open(parent)
1543 {
1544 let _ = dir.sync_all();
1545 }
1546}
1547
1548#[cfg(not(unix))]
1549fn sync_parent_dir(_path: &Path) {}
1550
1551fn write_config_file_atomically(
1552 path: &Path,
1553 bytes: &[u8],
1554 create_parent_dir: bool,
1555) -> Result<Option<PathBuf>, ConfigStoreError> {
1556 write_config_file_atomically_with(
1557 path,
1558 bytes,
1559 create_parent_dir,
1560 |source, destination| fs::copy(source, destination),
1561 |source, destination| fs::rename(source, destination),
1562 )
1563}
1564
1565fn write_config_file_atomically_with<Copy, Rename>(
1566 path: &Path,
1567 bytes: &[u8],
1568 create_parent_dir: bool,
1569 copy_backup: Copy,
1570 replace: Rename,
1571) -> Result<Option<PathBuf>, ConfigStoreError>
1572where
1573 Copy: Fn(&Path, &Path) -> io::Result<u64>,
1574 Rename: Fn(&Path, &Path) -> io::Result<()>,
1575{
1576 if create_parent_dir {
1577 ensure_parent_dir(path)?;
1578 }
1579
1580 let (temp_path, mut temp_file) = create_temp_file(path)?;
1581 write_and_flush_temp_file(path, &temp_path, bytes, &mut temp_file)?;
1582 drop(temp_file);
1583
1584 let backup_path = if path.exists() {
1585 let backup_path = backup_path_for(path);
1586 if let Err(e) = copy_backup(path, &backup_path) {
1587 let _ = fs::remove_file(&temp_path);
1588 return Err(ConfigStoreError::Write {
1589 path: path.to_path_buf(),
1590 action: "create config backup",
1591 source: e,
1592 });
1593 }
1594 Some(backup_path)
1595 } else {
1596 None
1597 };
1598
1599 let replace_result = replace(&temp_path, path);
1600
1601 if let Err(e) = replace_result {
1602 let _ = fs::remove_file(&temp_path);
1603 return Err(ConfigStoreError::Write {
1604 path: path.to_path_buf(),
1605 action: "replace config file",
1606 source: e,
1607 });
1608 }
1609
1610 sync_parent_dir(path);
1611 Ok(backup_path)
1612}
1613
1614pub fn save_config_store(
1620 state: &LoadedConfigStore,
1621 expected_revision: &str,
1622 update: &ConfigPolicyUpdate,
1623) -> Result<ConfigSaveResult, ConfigStoreError> {
1624 if state.revision != expected_revision {
1625 return Err(ConfigStoreError::Conflict {
1626 path: state.path.clone(),
1627 message: format!(
1628 "stale config revision token: expected {expected_revision}, latest {}",
1629 state.revision
1630 ),
1631 });
1632 }
1633
1634 let path = state
1635 .path
1636 .clone()
1637 .ok_or(ConfigStoreError::PathNotResolved)?;
1638
1639 let current_bytes = match fs::read(&path) {
1640 Ok(bytes) => Some(bytes),
1641 Err(e) if e.kind() == io::ErrorKind::NotFound => None,
1642 Err(e) => {
1643 return Err(ConfigStoreError::Unreadable {
1644 path: path.clone(),
1645 source: e,
1646 });
1647 }
1648 };
1649 let loaded_bytes = state.original_raw.as_ref().map(String::as_bytes);
1650 let disk_matches_loaded = match (loaded_bytes, current_bytes.as_deref()) {
1651 (None, None) => true,
1652 (Some(loaded), Some(current)) => loaded == current,
1653 _ => false,
1654 };
1655 if !disk_matches_loaded {
1656 return Err(ConfigStoreError::Conflict {
1657 path: Some(path.clone()),
1658 message: "config file changed on disk since it was loaded".to_string(),
1659 });
1660 }
1661
1662 if state.original_raw.is_none() && !state.missing_is_allowed {
1663 return Err(ConfigStoreError::Missing { path });
1664 }
1665
1666 let (patched_raw, config) = patch_config_policy(state.original_raw.as_deref(), update)
1667 .map_err(|source| config_malformed(Some(&path), source))?;
1668 let backup_path =
1669 write_config_file_atomically(&path, patched_raw.as_bytes(), state.original_raw.is_none())?;
1670 let revision = revision_token_for_raw(Some(&patched_raw));
1671 let toml_allow_uncertain_quality = Some(update.allow_uncertain_quality);
1672 let toml_tuning = update.tuning();
1673 let tuning_sources = tuning_config_sources(&toml_tuning);
1674
1675 Ok(ConfigSaveResult {
1676 backup_path,
1677 state: LoadedConfigStore {
1678 path: Some(path),
1679 missing_is_allowed: state.missing_is_allowed,
1680 original_raw: Some(patched_raw),
1681 config,
1682 revision,
1683 toml_allow_uncertain_quality,
1684 toml_tuning,
1685 tuning_sources,
1686 },
1687 })
1688}
1689
1690fn load_discovered_config(path: Option<PathBuf>) -> anyhow::Result<BhtuneConfig> {
1696 load_config_store_from_resolution(ConfigPathResolution {
1697 path,
1698 missing_is_allowed: true,
1699 })
1700 .map(|store| store.config)
1701 .map_err(|e| anyhow::anyhow!(e.to_string()))
1702}
1703
1704pub fn load_config(explicit_path: Option<&Path>) -> anyhow::Result<BhtuneConfig> {
1708 match explicit_path {
1709 Some(path) => load_config_file(path, true),
1710 None => {
1711 let path = config_path_from(
1712 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
1713 std::env::var("HOME").ok().as_deref(),
1714 std::env::var("APPDATA").ok().as_deref(),
1715 cfg!(target_os = "windows"),
1716 );
1717 load_discovered_config(path)
1718 }
1719 }
1720}
1721
1722pub fn load_user_templates(
1740 cli_templates: Option<PathBuf>,
1741 config: &BhtuneConfig,
1742 xdg_config_home: Option<&str>,
1743 home: Option<&str>,
1744 appdata: Option<&str>,
1745 is_windows: bool,
1746) -> anyhow::Result<Option<Vec<bhtune_core::DcsTemplate>>> {
1747 let (path, missing_is_error) = match cli_templates.or_else(|| config.templates.clone()) {
1748 Some(explicit) => (Some(explicit), true),
1749 None => (
1750 templates_path_from(xdg_config_home, home, appdata, is_windows),
1751 false,
1752 ),
1753 };
1754 let Some(path) = path else {
1755 return Ok(None);
1756 };
1757
1758 match std::fs::read_to_string(&path) {
1759 Ok(contents) => bhtune_core::template::parse_catalog(&contents)
1760 .map(Some)
1761 .map_err(|e| anyhow::anyhow!("failed to parse templates file {}: {e}", path.display())),
1762 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !missing_is_error => Ok(None),
1763 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow::anyhow!(
1764 "templates file not found: {}",
1765 path.display()
1766 )),
1767 Err(e) => Err(anyhow::anyhow!(
1768 "failed to read templates file {}: {e}",
1769 path.display()
1770 )),
1771 }
1772}
1773
1774#[allow(clippy::too_many_arguments)]
1780pub fn resolve_db_path(
1781 cli_db: Option<PathBuf>,
1782 config: &BhtuneConfig,
1783 xdg_data_home: Option<&str>,
1784 home: Option<&str>,
1785 appdata: Option<&str>,
1786 is_windows: bool,
1787) -> PathBuf {
1788 cli_db
1789 .or_else(|| config.db.clone())
1790 .unwrap_or_else(|| default_db_path_from(xdg_data_home, home, appdata, is_windows))
1791}
1792
1793pub fn resolve_bridge_host(cli_host: Option<String>, config: &BhtuneConfig) -> String {
1797 cli_host
1798 .or_else(|| config.bridge_host.clone())
1799 .unwrap_or_else(|| DEFAULT_BRIDGE_HOST.to_string())
1800}
1801
1802pub fn resolve_retention_days(cli_days: Option<u32>, config: &BhtuneConfig) -> Option<u32> {
1808 cli_days.or(config.retention_days)
1809}
1810
1811pub fn resolve_bind_addr(cli_bind: Option<String>, config: &BhtuneConfig) -> String {
1818 cli_bind
1819 .or_else(|| config.bind.clone())
1820 .unwrap_or_else(|| DEFAULT_BIND_ADDR.to_string())
1821}
1822
1823pub fn resolve_origin(
1824 env_origin: Option<String>,
1825 config: &BhtuneConfig,
1826 bind_addr: &str,
1827 mode: ServerMode,
1828) -> Result<String, String> {
1829 let origin = env_origin
1830 .or_else(|| config.origin.clone())
1831 .unwrap_or_else(|| format!("http://{bind_addr}"));
1832 if mode == ServerMode::Demo {
1833 validate_demo_origin(&origin)?;
1834 }
1835 Ok(origin)
1836}
1837
1838pub fn validate_demo_origin(origin: &str) -> Result<(), String> {
1844 if origin.trim() != origin || origin.chars().any(char::is_whitespace) {
1845 return Err("demo origin must not contain whitespace".to_owned());
1846 }
1847 let (scheme, authority) = origin
1848 .split_once("://")
1849 .ok_or_else(|| "demo origin must be an absolute HTTPS origin".to_owned())?;
1850 if authority.is_empty()
1851 || authority.contains(['/', '?', '#', '@'])
1852 || !valid_origin_authority(authority)
1853 {
1854 return Err(
1855 "demo origin must contain only a host and optional port, with no path or credentials"
1856 .to_owned(),
1857 );
1858 }
1859 match scheme.to_ascii_lowercase().as_str() {
1860 "https" => Ok(()),
1861 "http" if authority_host(authority).is_some_and(is_loopback_host) => Ok(()),
1862 "http" => Err(
1863 "demo origin must use HTTPS; HTTP is allowed only for an explicit loopback origin"
1864 .to_owned(),
1865 ),
1866 _ => Err("demo origin must use HTTPS".to_owned()),
1867 }
1868}
1869
1870fn valid_origin_authority(authority: &str) -> bool {
1871 let Some((host, port)) = split_authority(authority) else {
1872 return false;
1873 };
1874 !host.is_empty() && port.is_none_or(|port| !port.is_empty() && port.parse::<u16>().is_ok())
1875}
1876
1877fn authority_host(authority: &str) -> Option<&str> {
1878 split_authority(authority).map(|(host, _)| host)
1879}
1880
1881fn split_authority(authority: &str) -> Option<(&str, Option<&str>)> {
1882 if let Some(rest) = authority.strip_prefix('[') {
1883 let closing = rest.find(']')?;
1884 let host = &rest[..closing];
1885 let suffix = &rest[closing + 1..];
1886 return match suffix {
1887 "" => Some((host, None)),
1888 _ => suffix.strip_prefix(':').map(|port| (host, Some(port))),
1889 };
1890 }
1891 match authority.rsplit_once(':') {
1892 Some((host, port)) if !host.contains(':') => Some((host, Some(port))),
1893 Some(_) => None,
1894 None => Some((authority, None)),
1895 }
1896}
1897
1898fn is_loopback_host(host: &str) -> bool {
1899 host.eq_ignore_ascii_case("localhost")
1900 || host
1901 .parse::<IpAddr>()
1902 .is_ok_and(|address| address.is_loopback())
1903}
1904
1905pub fn validate_demo_trusted_proxy(trusted_proxy: Option<&str>) -> Result<(), String> {
1909 let Some(value) = trusted_proxy else {
1910 return Ok(());
1911 };
1912 if value.trim() != value || value.is_empty() {
1913 return Err("demo trusted_proxy must be a non-empty IP address or CIDR".to_owned());
1914 }
1915 if value.parse::<IpAddr>().is_ok() {
1916 return Ok(());
1917 }
1918 let (network, prefix) = value
1919 .split_once('/')
1920 .ok_or_else(|| "demo trusted_proxy must be an IP address or CIDR".to_owned())?;
1921 let network = network
1922 .parse::<IpAddr>()
1923 .map_err(|_| "demo trusted_proxy CIDR must use a valid IP address".to_owned())?;
1924 let prefix = prefix
1925 .parse::<u32>()
1926 .map_err(|_| "demo trusted_proxy CIDR prefix is invalid".to_owned())?;
1927 let maximum = match network {
1928 IpAddr::V4(_) => 32,
1929 IpAddr::V6(_) => 128,
1930 };
1931 if prefix > maximum {
1932 return Err(format!(
1933 "demo trusted_proxy CIDR prefix must be between 0 and {maximum}"
1934 ));
1935 }
1936 Ok(())
1937}
1938
1939pub fn resolve_server(cli_server: Option<String>, config: &BhtuneConfig) -> anyhow::Result<String> {
1942 cli_server.or_else(|| config.server.clone()).ok_or_else(|| {
1943 anyhow::anyhow!(
1944 "no OPC server specified: pass --server or set `server` in the bhtune config file"
1945 )
1946 })
1947}
1948
1949#[cfg(test)]
1950mod tests {
1951 use super::*;
1952 use proptest::prelude::*;
1953 use std::{fs, io::Write};
1954
1955 proptest::proptest! {
1956 #[test]
1957 fn serialized_configs_round_trip(
1958 db in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
1959 bridge_host in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
1960 server in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
1961 templates in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
1962 bind in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
1963 retention_days in prop::option::of(1u32..),
1964 allow_uncertain_quality in any::<bool>(),
1965 mrft_delay_secs in prop::option::of(0u32..=MAX_TUNING_MRFT_DELAY_SECS),
1966 poll_interval_ms in prop::option::of(1u64..=1_000_000),
1967 timeout_secs in prop::option::of(1u64..=1_000_000),
1968 op_timeout_secs in prop::option::of(1u64..=1_000_000),
1969 restore_timeout_secs in prop::option::of(1u64..=1_000_000),
1970 level in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
1971 dir in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
1972 format in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
1973 rotation in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
1974 ) {
1975 let config = BhtuneConfig {
1976 server_mode: None,
1977 demo: DemoPolicyConfig::default(),
1978 db: db.map(PathBuf::from),
1979 bridge_host,
1980 server,
1981 templates: templates.map(PathBuf::from),
1982 bind,
1983 origin: None,
1984 trusted_proxy: None,
1985 retention_days,
1986 allow_uncertain_quality,
1987 tuning: TuningConfig {
1988 mrft_delay_secs,
1989 poll_interval_ms,
1990 timeout_secs,
1991 op_timeout_secs,
1992 restore_timeout_secs,
1993 },
1994 log: LogConfig {
1995 level,
1996 dir,
1997 format,
1998 rotation,
1999 },
2000 };
2001 let encoded = toml::to_string(&config).unwrap();
2002 prop_assert_eq!(parse_config_contents(&encoded).unwrap(), config);
2003 }
2004
2005 #[test]
2006 fn arbitrary_config_text_never_panics(input in any::<String>()) {
2007 let _ = parse_config_contents(&input);
2008 }
2009 }
2010
2011 #[test]
2012 fn config_path_from_windows_with_appdata() {
2013 let path = config_path_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
2014 assert_eq!(
2015 path,
2016 Some(PathBuf::from(
2017 r"C:\Users\me\AppData\Roaming/bhtune/bhtune.toml"
2018 ))
2019 );
2020 }
2021
2022 #[test]
2023 fn parse_config_rejects_zero_retention_days() {
2024 let error = parse_config_contents("retention_days = 0").unwrap_err();
2025 assert!(
2026 error
2027 .to_string()
2028 .contains("retention_days must be at least 1")
2029 );
2030 }
2031
2032 #[test]
2033 fn missing_tuning_table_preserves_none_and_resolves_built_in_defaults() {
2034 let config = parse_config_contents("bridge_host = \"gateway:7600\"\n").unwrap();
2035
2036 assert_eq!(config.tuning, TuningConfig::default());
2037 assert_eq!(
2038 resolve_and_validate_tuning_config(&config.tuning, true).unwrap(),
2039 EffectiveTuningConfig::default()
2040 );
2041 }
2042
2043 #[test]
2044 fn tuning_table_round_trips_all_optional_values() {
2045 let raw = r#"
2046[tuning]
2047mrft_delay_secs = 12
2048poll_interval_ms = 900
2049timeout_secs = 4000
2050op_timeout_secs = 31
2051restore_timeout_secs = 32
2052"#;
2053 let config = parse_config_contents(raw).unwrap();
2054
2055 assert_eq!(
2056 config.tuning,
2057 TuningConfig {
2058 mrft_delay_secs: Some(12),
2059 poll_interval_ms: Some(900),
2060 timeout_secs: Some(4_000),
2061 op_timeout_secs: Some(31),
2062 restore_timeout_secs: Some(32),
2063 }
2064 );
2065 let encoded = toml::to_string(&config).unwrap();
2066 assert_eq!(parse_config_contents(&encoded).unwrap(), config);
2067 }
2068
2069 #[test]
2070 fn tuning_resolution_preserves_partial_raw_values_and_tracks_sources() {
2071 let raw = TuningConfig {
2072 poll_interval_ms: Some(250),
2073 restore_timeout_secs: Some(8),
2074 ..Default::default()
2075 };
2076
2077 assert_eq!(
2078 resolve_tuning_config(&raw),
2079 EffectiveTuningConfig {
2080 poll_interval_ms: 250,
2081 restore_timeout_secs: 8,
2082 ..Default::default()
2083 }
2084 );
2085 assert_eq!(
2086 tuning_config_sources(&raw),
2087 TuningConfigSources {
2088 mrft_delay_secs: TuningConfigSource::BuiltInDefault,
2089 poll_interval_ms: TuningConfigSource::Toml,
2090 timeout_secs: TuningConfigSource::BuiltInDefault,
2091 op_timeout_secs: TuningConfigSource::BuiltInDefault,
2092 restore_timeout_secs: TuningConfigSource::Toml,
2093 }
2094 );
2095 }
2096
2097 #[test]
2098 fn tuning_validation_rejects_each_invalid_general_value() {
2099 let cases = [
2100 (
2101 EffectiveTuningConfig {
2102 mrft_delay_secs: MAX_TUNING_MRFT_DELAY_SECS + 1,
2103 ..Default::default()
2104 },
2105 TuningConfigError::MrftDelayOutOfRange {
2106 value: MAX_TUNING_MRFT_DELAY_SECS + 1,
2107 },
2108 ),
2109 (
2110 EffectiveTuningConfig {
2111 poll_interval_ms: 0,
2112 ..Default::default()
2113 },
2114 TuningConfigError::PollIntervalTooSmall { value: 0 },
2115 ),
2116 (
2117 EffectiveTuningConfig {
2118 timeout_secs: 0,
2119 ..Default::default()
2120 },
2121 TuningConfigError::TimeoutTooSmall { value: 0 },
2122 ),
2123 (
2124 EffectiveTuningConfig {
2125 op_timeout_secs: 0,
2126 ..Default::default()
2127 },
2128 TuningConfigError::OpTimeoutTooSmall { value: 0 },
2129 ),
2130 (
2131 EffectiveTuningConfig {
2132 restore_timeout_secs: 0,
2133 ..Default::default()
2134 },
2135 TuningConfigError::RestoreTimeoutTooSmall {
2136 value: 0,
2137 minimum: 1,
2138 },
2139 ),
2140 ];
2141
2142 for (config, expected) in cases {
2143 assert_eq!(validate_tuning_config(&config, false), Err(expected));
2144 assert!(!expected.to_string().is_empty());
2145 }
2146 }
2147
2148 #[test]
2149 fn opc_restore_timeout_requires_four_seconds_but_general_validation_allows_one() {
2150 let config = EffectiveTuningConfig {
2151 restore_timeout_secs: MIN_OPC_RESTORE_TIMEOUT_SECS - 1,
2152 ..Default::default()
2153 };
2154
2155 assert_eq!(validate_tuning_config(&config, false), Ok(()));
2156 assert_eq!(
2157 validate_tuning_config(&config, true),
2158 Err(TuningConfigError::RestoreTimeoutTooSmall {
2159 value: MIN_OPC_RESTORE_TIMEOUT_SECS - 1,
2160 minimum: MIN_OPC_RESTORE_TIMEOUT_SECS,
2161 })
2162 );
2163 assert!(
2164 validate_tuning_config(
2165 &EffectiveTuningConfig {
2166 restore_timeout_secs: MIN_OPC_RESTORE_TIMEOUT_SECS,
2167 ..Default::default()
2168 },
2169 true,
2170 )
2171 .is_ok()
2172 );
2173 }
2174
2175 #[test]
2176 fn config_path_from_windows_no_appdata() {
2177 assert_eq!(
2178 config_path_from(Some("/xdg"), Some("/home"), None, true),
2179 None
2180 );
2181 }
2182
2183 #[test]
2184 fn config_path_from_unix_xdg_config_home() {
2185 let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
2186 assert_eq!(path, Some(PathBuf::from("/xdg/bhtune/bhtune.toml")));
2187 }
2188
2189 #[test]
2190 fn config_path_from_unix_falls_back_to_home() {
2191 let path = config_path_from(None, Some("/home/me"), None, false);
2192 assert_eq!(
2193 path,
2194 Some(PathBuf::from("/home/me/.config/bhtune/bhtune.toml"))
2195 );
2196 }
2197
2198 #[test]
2199 fn config_path_from_unix_no_env_vars() {
2200 assert_eq!(config_path_from(None, None, None, false), None);
2201 }
2202
2203 #[test]
2204 fn config_path_from_unix_xdg_takes_precedence_over_home() {
2205 let path = config_path_from(Some("/xdg"), Some("/home/me"), None, false);
2206 assert_eq!(path, Some(PathBuf::from("/xdg/bhtune/bhtune.toml")));
2207 }
2208
2209 #[test]
2210 fn templates_path_from_windows_with_appdata() {
2211 let path = templates_path_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
2212 assert_eq!(
2213 path,
2214 Some(PathBuf::from(
2215 r"C:\Users\me\AppData\Roaming/bhtune/templates.toml"
2216 ))
2217 );
2218 }
2219
2220 #[test]
2221 fn templates_path_from_windows_no_appdata() {
2222 assert_eq!(
2223 templates_path_from(Some("/xdg"), Some("/home"), None, true),
2224 None
2225 );
2226 }
2227
2228 #[test]
2229 fn templates_path_from_unix_xdg_config_home() {
2230 let path = templates_path_from(Some("/xdg"), Some("/home/me"), None, false);
2231 assert_eq!(path, Some(PathBuf::from("/xdg/bhtune/templates.toml")));
2232 }
2233
2234 #[test]
2235 fn templates_path_from_unix_falls_back_to_home() {
2236 let path = templates_path_from(None, Some("/home/me"), None, false);
2237 assert_eq!(
2238 path,
2239 Some(PathBuf::from("/home/me/.config/bhtune/templates.toml"))
2240 );
2241 }
2242
2243 #[test]
2244 fn templates_path_from_unix_no_env_vars() {
2245 assert_eq!(templates_path_from(None, None, None, false), None);
2246 }
2247
2248 #[test]
2249 fn default_db_path_from_windows_with_appdata() {
2250 let path = default_db_path_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
2251 assert_eq!(
2252 path,
2253 PathBuf::from(r"C:\Users\me\AppData\Roaming/bhtune/bhtune.db")
2254 );
2255 }
2256
2257 #[test]
2258 fn default_db_path_from_windows_no_appdata_falls_back_to_cwd() {
2259 assert_eq!(
2260 default_db_path_from(None, None, None, true),
2261 PathBuf::from("bhtune.db")
2262 );
2263 }
2264
2265 #[test]
2266 fn default_db_path_from_unix_xdg_data_home() {
2267 let path = default_db_path_from(Some("/xdg-data"), Some("/home/me"), None, false);
2268 assert_eq!(path, PathBuf::from("/xdg-data/bhtune/bhtune.db"));
2269 }
2270
2271 #[test]
2272 fn default_db_path_from_unix_falls_back_to_home() {
2273 let path = default_db_path_from(None, Some("/home/me"), None, false);
2274 assert_eq!(
2275 path,
2276 PathBuf::from("/home/me/.local/share/bhtune/bhtune.db")
2277 );
2278 }
2279
2280 #[test]
2281 fn default_db_path_from_unix_no_env_vars_falls_back_to_cwd() {
2282 assert_eq!(
2283 default_db_path_from(None, None, None, false),
2284 PathBuf::from("bhtune.db")
2285 );
2286 }
2287
2288 #[test]
2289 fn default_log_dir_from_windows_with_appdata() {
2290 let path = default_log_dir_from(None, None, Some(r"C:\Users\me\AppData\Roaming"), true);
2291 assert_eq!(
2292 path,
2293 PathBuf::from(r"C:\Users\me\AppData\Roaming/bhtune/logs")
2294 );
2295 }
2296
2297 #[test]
2298 fn default_log_dir_from_windows_no_appdata_falls_back_to_cwd() {
2299 assert_eq!(
2300 default_log_dir_from(None, None, None, true),
2301 PathBuf::from("logs")
2302 );
2303 }
2304
2305 #[test]
2306 fn default_log_dir_from_unix_xdg_data_home() {
2307 let path = default_log_dir_from(Some("/xdg-data"), Some("/home/me"), None, false);
2308 assert_eq!(path, PathBuf::from("/xdg-data/bhtune/logs"));
2309 }
2310
2311 #[test]
2312 fn default_log_dir_from_unix_falls_back_to_home() {
2313 let path = default_log_dir_from(None, Some("/home/me"), None, false);
2314 assert_eq!(path, PathBuf::from("/home/me/.local/share/bhtune/logs"));
2315 }
2316
2317 #[test]
2318 fn default_log_dir_from_unix_no_env_vars_falls_back_to_cwd() {
2319 assert_eq!(
2320 default_log_dir_from(None, None, None, false),
2321 PathBuf::from("logs")
2322 );
2323 }
2324
2325 #[test]
2326 fn default_allow_uncertain_quality_is_true() {
2327 assert!(BhtuneConfig::default().allow_uncertain_quality);
2328 }
2329
2330 #[test]
2331 fn load_config_file_missing_allow_uncertain_quality_key_defaults_to_true() {
2332 let mut file = tempfile::NamedTempFile::new().unwrap();
2333 writeln!(file, "bridge_host = \"gateway:7600\"").unwrap();
2334 let config = load_config_file(file.path(), true).unwrap();
2335 assert!(config.allow_uncertain_quality);
2336 assert_eq!(config.bridge_host, Some("gateway:7600".to_string()));
2337 }
2338
2339 #[test]
2340 fn resolve_config_store_path_prefers_an_explicit_path() {
2341 let resolution = resolve_config_store_path(
2342 Some(Path::new("/explicit/bhtune.toml")),
2343 Some("/xdg"),
2344 Some("/home/me"),
2345 None,
2346 false,
2347 );
2348 assert_eq!(
2349 resolution,
2350 ConfigPathResolution {
2351 path: Some(PathBuf::from("/explicit/bhtune.toml")),
2352 missing_is_allowed: false,
2353 }
2354 );
2355 }
2356
2357 #[test]
2358 fn resolve_config_store_path_falls_back_to_auto_discovery() {
2359 let resolution =
2360 resolve_config_store_path(None, Some("/xdg"), Some("/home/me"), None, false);
2361 assert_eq!(
2362 resolution,
2363 ConfigPathResolution {
2364 path: Some(PathBuf::from("/xdg/bhtune/bhtune.toml")),
2365 missing_is_allowed: true,
2366 }
2367 );
2368 }
2369
2370 #[test]
2371 fn load_config_file_valid() {
2372 let mut file = tempfile::NamedTempFile::new().unwrap();
2373 writeln!(
2374 file,
2375 "db = \"/data/bhtune.db\"\nbridge_host = \"gateway:7600\"\nserver = \"Kepware.KEPServerEX.V6\""
2376 )
2377 .unwrap();
2378 let config = load_config_file(file.path(), true).unwrap();
2379 assert_eq!(config.db, Some(PathBuf::from("/data/bhtune.db")));
2380 assert_eq!(config.bridge_host, Some("gateway:7600".to_string()));
2381 assert_eq!(config.server, Some("Kepware.KEPServerEX.V6".to_string()));
2382 assert_eq!(config.log, LogConfig::default());
2383 }
2384
2385 #[test]
2386 fn load_config_file_parses_the_log_table() {
2387 let mut file = tempfile::NamedTempFile::new().unwrap();
2388 writeln!(
2389 file,
2390 "[log]\nlevel = \"debug\"\ndir = \"/var/log/bhtune\"\nformat = \"json\"\nrotation = \"hourly\""
2391 )
2392 .unwrap();
2393 let config = load_config_file(file.path(), true).unwrap();
2394 assert_eq!(
2395 config.log,
2396 LogConfig {
2397 level: Some("debug".to_string()),
2398 dir: Some("/var/log/bhtune".to_string()),
2399 format: Some("json".to_string()),
2400 rotation: Some("hourly".to_string()),
2401 }
2402 );
2403 }
2404
2405 #[test]
2406 fn load_config_file_empty_is_all_defaults() {
2407 let file = tempfile::NamedTempFile::new().unwrap();
2408 let config = load_config_file(file.path(), true).unwrap();
2409 assert_eq!(config, BhtuneConfig::default());
2410 }
2411
2412 #[test]
2413 fn load_config_file_malformed() {
2414 let mut file = tempfile::NamedTempFile::new().unwrap();
2415 writeln!(file, "db = 12345").unwrap();
2416 let err = load_config_file(file.path(), true).unwrap_err();
2417 assert!(err.to_string().contains("failed to parse config file"));
2418 }
2419
2420 #[test]
2421 fn load_config_file_missing_not_error() {
2422 let config = load_config_file(Path::new("/nonexistent/bhtune.toml"), false).unwrap();
2423 assert_eq!(config, BhtuneConfig::default());
2424 }
2425
2426 #[test]
2427 fn load_config_file_missing_is_error() {
2428 let err = load_config_file(Path::new("/nonexistent/bhtune.toml"), true).unwrap_err();
2429 assert!(err.to_string().contains("config file not found"));
2430 }
2431
2432 #[test]
2433 fn load_config_file_generic_io_error() {
2434 let dir = tempfile::tempdir().unwrap();
2438 let err = load_config_file(dir.path(), true).unwrap_err();
2439 assert!(err.to_string().contains("failed to read config file"));
2440 }
2441
2442 #[test]
2443 fn load_config_explicit_path() {
2444 let mut file = tempfile::NamedTempFile::new().unwrap();
2445 writeln!(file, "bridge_host = \"custom:9999\"").unwrap();
2446 let config = load_config(Some(file.path())).unwrap();
2447 assert_eq!(config.bridge_host, Some("custom:9999".to_string()));
2448 }
2449
2450 #[test]
2451 fn load_config_explicit_path_missing_errors() {
2452 let err = load_config(Some(Path::new("/nonexistent/bhtune.toml"))).unwrap_err();
2453 assert!(err.to_string().contains("config file not found"));
2454 }
2455
2456 #[test]
2457 fn load_config_default_discovery() {
2458 let config = load_config(None).unwrap();
2462 assert_eq!(config, BhtuneConfig::default());
2463 }
2464
2465 #[test]
2466 fn load_discovered_config_with_no_path_found_is_all_defaults() {
2467 let config = load_discovered_config(None).unwrap();
2471 assert_eq!(config, BhtuneConfig::default());
2472 }
2473
2474 #[test]
2475 fn load_discovered_config_reads_a_valid_discovered_file() {
2476 let file = tempfile::NamedTempFile::new().unwrap();
2477 fs::write(file.path(), "bridge_host = \"discovered:7600\"\n").unwrap();
2478
2479 let config = load_discovered_config(Some(file.path().to_path_buf())).unwrap();
2480
2481 assert_eq!(config.bridge_host, Some("discovered:7600".to_string()));
2482 }
2483
2484 #[test]
2485 fn revision_hash_uses_the_stable_fnv1a_algorithm() {
2486 assert_eq!(stable_revision_hash(b"bhtune"), 0xeeeb3aadbd6c2361);
2487 }
2488
2489 #[test]
2490 fn unique_suffix_has_process_and_timestamp_components() {
2491 let suffix = unique_suffix();
2492 let components: Vec<_> = suffix.split('-').collect();
2493
2494 assert_eq!(components.len(), 3);
2495 assert_eq!(components[0], std::process::id().to_string());
2496 assert!(components[1].parse::<u64>().is_ok());
2497 assert!(
2498 components[2]
2499 .parse::<u32>()
2500 .is_ok_and(|n| n < 1_000_000_000)
2501 );
2502 }
2503
2504 fn backup_and_temp_siblings(path: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
2505 let parent = path.parent().unwrap();
2506 let file_name = path.file_name().unwrap().to_string_lossy().to_string();
2507 let mut backups = Vec::new();
2508 let mut temps = Vec::new();
2509
2510 for entry in fs::read_dir(parent).unwrap() {
2511 let entry = entry.unwrap();
2512 let name = entry.file_name().to_string_lossy().to_string();
2513 if name.starts_with(&format!("{file_name}.backup-")) {
2514 backups.push(entry.path());
2515 }
2516 if name.starts_with(&format!("{file_name}.tmp-")) {
2517 temps.push(entry.path());
2518 }
2519 }
2520
2521 backups.sort();
2522 temps.sort();
2523 (backups, temps)
2524 }
2525
2526 #[test]
2527 fn backup_and_temp_siblings_finds_temporary_siblings() {
2528 let dir = tempfile::tempdir().unwrap();
2529 let path = dir.path().join("bhtune.toml");
2530 let temp_path = dir.path().join("bhtune.toml.tmp-leftover");
2531 fs::write(&temp_path, b"leftover").unwrap();
2532
2533 let (_backups, temps) = backup_and_temp_siblings(&path);
2534
2535 assert_eq!(temps, vec![temp_path]);
2536 }
2537
2538 #[test]
2539 fn load_config_store_from_missing_auto_path_returns_a_path_aware_default_store() {
2540 let dir = tempfile::tempdir().unwrap();
2541 let store =
2542 load_config_store_from(None, Some(dir.path().to_str().unwrap()), None, None, false)
2543 .unwrap();
2544
2545 assert_eq!(
2546 store.path,
2547 Some(dir.path().join("bhtune").join("bhtune.toml"))
2548 );
2549 assert!(store.missing_is_allowed);
2550 assert_eq!(store.original_raw, None);
2551 assert_eq!(store.config, BhtuneConfig::default());
2552 assert_eq!(store.revision, "absent:v1");
2553 assert_eq!(store.toml_tuning, TuningConfig::default());
2554 assert_eq!(
2555 store.tuning_sources,
2556 tuning_config_sources(&TuningConfig::default())
2557 );
2558 }
2559
2560 #[test]
2561 fn load_config_store_tracks_raw_tuning_values_and_sources() {
2562 let mut file = tempfile::NamedTempFile::new().unwrap();
2563 writeln!(
2564 file,
2565 "[tuning]\npoll_interval_ms = 250\nrestore_timeout_secs = 8"
2566 )
2567 .unwrap();
2568
2569 let store = load_config_store(Some(file.path())).unwrap();
2570
2571 assert_eq!(
2572 store.toml_tuning,
2573 TuningConfig {
2574 poll_interval_ms: Some(250),
2575 restore_timeout_secs: Some(8),
2576 ..Default::default()
2577 }
2578 );
2579 assert_eq!(
2580 store.tuning_sources,
2581 TuningConfigSources {
2582 mrft_delay_secs: TuningConfigSource::BuiltInDefault,
2583 poll_interval_ms: TuningConfigSource::Toml,
2584 timeout_secs: TuningConfigSource::BuiltInDefault,
2585 op_timeout_secs: TuningConfigSource::BuiltInDefault,
2586 restore_timeout_secs: TuningConfigSource::Toml,
2587 }
2588 );
2589 }
2590
2591 #[test]
2592 fn load_config_store_wrapper_uses_the_explicit_path() {
2593 let file = tempfile::NamedTempFile::new().unwrap();
2594 let store = load_config_store(Some(file.path())).unwrap();
2595 assert_eq!(store.path, Some(file.path().to_path_buf()));
2596 assert_eq!(store.config, BhtuneConfig::default());
2597 }
2598
2599 #[test]
2600 fn load_config_store_from_explicit_missing_path_is_a_typed_error() {
2601 let path = PathBuf::from("/nonexistent/path-aware-bhtune.toml");
2602 let err = load_config_store_from(Some(&path), None, None, None, false).unwrap_err();
2603 assert!(matches!(err, ConfigStoreError::Missing { path: actual } if actual == path));
2604 }
2605
2606 #[test]
2607 fn load_config_store_from_malformed_input_is_a_typed_error() {
2608 let mut file = tempfile::NamedTempFile::new().unwrap();
2609 writeln!(file, "db = 12345").unwrap();
2610
2611 let err = load_config_store_from(Some(file.path()), None, None, None, false).unwrap_err();
2612 assert!(matches!(
2613 err,
2614 ConfigStoreError::Malformed {
2615 path: Some(path), ..
2616 } if path == file.path()
2617 ));
2618 }
2619
2620 #[test]
2621 fn load_config_store_from_unreadable_path_is_a_typed_error() {
2622 let dir = tempfile::tempdir().unwrap();
2623 let err = load_config_store_from(Some(dir.path()), None, None, None, false).unwrap_err();
2624 assert!(matches!(
2625 err,
2626 ConfigStoreError::Unreadable { path, .. } if path == dir.path()
2627 ));
2628 }
2629
2630 #[test]
2631 fn load_config_store_from_rejects_non_utf8_contents() {
2632 let dir = tempfile::tempdir().unwrap();
2633 let path = dir.path().join("bhtune.toml");
2634 fs::write(&path, [0xff, 0xfe]).unwrap();
2635
2636 let err = load_config_store_from(Some(&path), None, None, None, false).unwrap_err();
2637 let message = err.to_string();
2638 assert!(matches!(
2639 err,
2640 ConfigStoreError::Malformed {
2641 path: Some(actual), ..
2642 } if actual == path
2643 ));
2644 assert!(message.contains("not valid UTF-8"));
2645 }
2646
2647 #[test]
2648 fn patch_helpers_preserve_comments_unknown_keys_and_unrelated_values() {
2649 let raw = r#"# keep this comment
2650bridge_host = "gateway:7600"
2651unknown_key = "keep me"
2652
2653[log]
2654level = "info"
2655"#;
2656
2657 let patched = patch_allow_uncertain_quality(Some(raw), false).unwrap();
2658 let patched = patch_retention_days(Some(&patched), Some(30)).unwrap();
2659
2660 assert!(patched.contains("# keep this comment"));
2661 assert!(patched.contains("bridge_host = \"gateway:7600\""));
2662 assert!(patched.contains("unknown_key = \"keep me\""));
2663 assert!(patched.contains("[log]"));
2664 assert!(patched.contains("level = \"info\""));
2665 assert!(patched.contains("allow_uncertain_quality = false"));
2666 assert!(patched.contains("retention_days = 30"));
2667
2668 let parsed = parse_config_contents(&patched).unwrap();
2669 assert_eq!(parsed.bridge_host, Some("gateway:7600".to_string()));
2670 assert_eq!(parsed.log.level, Some("info".to_string()));
2671 assert!(!parsed.allow_uncertain_quality);
2672 assert_eq!(parsed.retention_days, Some(30));
2673 }
2674
2675 #[test]
2676 fn patch_retention_days_removes_an_existing_key() {
2677 let patched = patch_retention_days(Some("retention_days = 30\n"), None).unwrap();
2678 assert!(!patched.contains("retention_days"));
2679 assert_eq!(
2680 parse_config_contents(&patched).unwrap().retention_days,
2681 None
2682 );
2683 }
2684
2685 #[test]
2686 fn patch_tuning_config_updates_values_and_preserves_comments_and_unknown_keys() {
2687 let raw = r#"# keep root comment
2688unknown_key = "keep me"
2689
2690[tuning]
2691# keep tuning comment
2692mrft_delay_secs = 1
2693unknown_tuning_key = "keep this too"
2694"#;
2695 let patched = patch_tuning_config(
2696 Some(raw),
2697 &TuningConfig {
2698 mrft_delay_secs: Some(10),
2699 poll_interval_ms: Some(250),
2700 timeout_secs: Some(900),
2701 op_timeout_secs: Some(5),
2702 restore_timeout_secs: Some(6),
2703 },
2704 )
2705 .unwrap();
2706
2707 assert!(patched.contains("# keep root comment"));
2708 assert!(patched.contains("# keep tuning comment"));
2709 assert!(patched.contains("unknown_key = \"keep me\""));
2710 assert!(patched.contains("unknown_tuning_key = \"keep this too\""));
2711 assert_eq!(
2712 parse_config_contents(&patched).unwrap().tuning,
2713 TuningConfig {
2714 mrft_delay_secs: Some(10),
2715 poll_interval_ms: Some(250),
2716 timeout_secs: Some(900),
2717 op_timeout_secs: Some(5),
2718 restore_timeout_secs: Some(6),
2719 }
2720 );
2721 }
2722
2723 #[test]
2724 fn patch_tuning_config_none_removes_all_managed_keys_but_keeps_unknown_content() {
2725 let raw = r#"
2726[tuning]
2727mrft_delay_secs = 10
2728poll_interval_ms = 250
2729timeout_secs = 900
2730op_timeout_secs = 5
2731restore_timeout_secs = 6
2732unknown_tuning_key = "keep"
2733"#;
2734
2735 let patched = patch_tuning_config(Some(raw), &TuningConfig::default()).unwrap();
2736
2737 for key in [
2738 "mrft_delay_secs",
2739 "poll_interval_ms",
2740 "timeout_secs",
2741 "op_timeout_secs",
2742 "restore_timeout_secs",
2743 ] {
2744 assert!(!patched.contains(key));
2745 }
2746 assert!(patched.contains("unknown_tuning_key = \"keep\""));
2747 assert_eq!(
2748 parse_config_contents(&patched).unwrap().tuning,
2749 TuningConfig::default()
2750 );
2751 }
2752
2753 #[test]
2754 fn patch_tuning_config_rejects_invalid_and_unrepresentable_values() {
2755 let invalid = patch_tuning_config(
2756 None,
2757 &TuningConfig {
2758 poll_interval_ms: Some(0),
2759 ..Default::default()
2760 },
2761 )
2762 .unwrap_err();
2763 assert!(invalid.to_string().contains("poll_interval_ms"));
2764
2765 let too_large = patch_tuning_config(
2766 None,
2767 &TuningConfig {
2768 timeout_secs: Some(u64::MAX),
2769 ..Default::default()
2770 },
2771 )
2772 .unwrap_err();
2773 assert!(too_large.to_string().contains("too large"));
2774 }
2775
2776 #[test]
2777 fn patch_helpers_report_malformed_toml_without_modifying_it() {
2778 let malformed = "not = [valid";
2779
2780 let quality_error = patch_allow_uncertain_quality(Some(malformed), false).unwrap_err();
2781 assert!(matches!(
2782 quality_error,
2783 ConfigStoreError::Malformed { path: None, .. }
2784 ));
2785
2786 let retention_error = patch_retention_days(Some(malformed), Some(7)).unwrap_err();
2787 assert!(matches!(
2788 retention_error,
2789 ConfigStoreError::Malformed { path: None, .. }
2790 ));
2791 }
2792
2793 #[test]
2794 fn patch_policy_reports_malformed_toml_without_a_path() {
2795 let error = patch_config_policy(
2796 Some("not = [valid"),
2797 &ConfigPolicyUpdate {
2798 allow_uncertain_quality: false,
2799 retention_days: Some(7),
2800 ..Default::default()
2801 },
2802 )
2803 .unwrap_err();
2804 assert!(!error.is_empty());
2805 }
2806
2807 #[test]
2808 fn generated_sibling_names_fall_back_when_a_path_has_no_file_name() {
2809 let path = Path::new("");
2810 assert!(
2811 sibling_with_suffix(path, "tmp")
2812 .file_name()
2813 .unwrap()
2814 .to_string_lossy()
2815 .starts_with("bhtune.toml.tmp-")
2816 );
2817 assert!(
2818 backup_path_for(path)
2819 .file_name()
2820 .unwrap()
2821 .to_string_lossy()
2822 .starts_with("bhtune.toml.backup-")
2823 );
2824 }
2825
2826 #[test]
2827 fn discovered_config_propagates_an_unreadable_path() {
2828 let error = load_discovered_config(Some(PathBuf::from("."))).unwrap_err();
2829 assert!(error.to_string().contains("failed to read config file"));
2830 }
2831
2832 #[test]
2833 fn atomic_writer_reports_a_backup_copy_failure_from_the_real_filesystem() {
2834 let dir = tempfile::tempdir().unwrap();
2835 let error = write_config_file_atomically(dir.path(), b"replacement", false).unwrap_err();
2836 assert!(matches!(
2837 error,
2838 ConfigStoreError::Write {
2839 action: "create config backup",
2840 ..
2841 }
2842 ));
2843 }
2844
2845 #[test]
2846 fn patch_config_policy_removes_an_existing_retention_key() {
2847 let (patched, config) = patch_config_policy(
2848 Some("allow_uncertain_quality = true\nretention_days = 30\n"),
2849 &ConfigPolicyUpdate {
2850 allow_uncertain_quality: false,
2851 retention_days: None,
2852 ..Default::default()
2853 },
2854 )
2855 .unwrap();
2856 assert!(!patched.contains("retention_days"));
2857 assert!(!config.allow_uncertain_quality);
2858 assert_eq!(config.retention_days, None);
2859 }
2860
2861 #[test]
2862 fn config_store_error_display_and_sources_cover_all_variants() {
2863 let path = PathBuf::from("/tmp/bhtune.toml");
2864 let errors = [
2865 ConfigStoreError::PathNotResolved,
2866 ConfigStoreError::Missing { path: path.clone() },
2867 ConfigStoreError::Unreadable {
2868 path: path.clone(),
2869 source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
2870 },
2871 ConfigStoreError::Malformed {
2872 path: Some(path.clone()),
2873 source: "bad".to_string(),
2874 },
2875 ConfigStoreError::Malformed {
2876 path: None,
2877 source: "bad".to_string(),
2878 },
2879 ConfigStoreError::Conflict {
2880 path: Some(path.clone()),
2881 message: "stale".to_string(),
2882 },
2883 ConfigStoreError::Conflict {
2884 path: None,
2885 message: "stale".to_string(),
2886 },
2887 ConfigStoreError::Write {
2888 path,
2889 action: "write config",
2890 source: io::Error::other("failed"),
2891 },
2892 ];
2893
2894 for error in errors {
2895 assert!(!error.to_string().is_empty());
2896 let has_source = std::error::Error::source(&error).is_some();
2897 assert_eq!(
2898 has_source,
2899 matches!(
2900 error,
2901 ConfigStoreError::Unreadable { .. } | ConfigStoreError::Write { .. }
2902 )
2903 );
2904 }
2905 }
2906
2907 #[test]
2908 fn create_temp_file_retries_after_a_name_collision() {
2909 let dir = tempfile::tempdir().unwrap();
2910 let path = dir.path().join("bhtune.toml");
2911 let collision = dir.path().join("bhtune.toml.tmp-collision");
2912 let available = dir.path().join("bhtune.toml.tmp-available");
2913 fs::write(&collision, b"already here").unwrap();
2914
2915 let mut candidates = vec![collision.clone(), available.clone()];
2916 let (created, file) = create_temp_file_with(&path, || candidates.remove(0)).unwrap();
2917 assert_eq!(created, available);
2918 drop(file);
2919 assert!(created.exists());
2920 fs::remove_file(created).unwrap();
2921 }
2922
2923 #[test]
2924 fn create_temp_file_reports_exhausted_name_collisions() {
2925 let dir = tempfile::tempdir().unwrap();
2926 let path = dir.path().join("bhtune.toml");
2927 let collision = dir.path().join("bhtune.toml.tmp-collision");
2928 fs::write(&collision, b"already here").unwrap();
2929
2930 let err = create_temp_file_with(&path, || collision.clone()).unwrap_err();
2931 assert!(matches!(
2932 err,
2933 ConfigStoreError::Write { source, .. }
2934 if source.kind() == io::ErrorKind::AlreadyExists
2935 ));
2936 }
2937
2938 #[test]
2939 fn create_temp_file_reports_non_collision_errors_immediately() {
2940 let dir = tempfile::tempdir().unwrap();
2941 let blocker = dir.path().join("not-a-directory");
2942 fs::write(&blocker, b"file").unwrap();
2943 let path = dir.path().join("bhtune.toml");
2944 let candidate = blocker.join("bhtune.toml.tmp");
2945
2946 let err = create_temp_file_with(&path, || candidate.clone()).unwrap_err();
2947
2948 assert!(matches!(
2949 err,
2950 ConfigStoreError::Write { source, .. }
2951 if source.kind() != io::ErrorKind::AlreadyExists
2952 ));
2953 }
2954
2955 struct TestTempFile {
2956 bytes: Vec<u8>,
2957 fail_write: bool,
2958 fail_sync: bool,
2959 }
2960
2961 impl Write for TestTempFile {
2962 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
2963 if self.fail_write {
2964 Err(io::Error::other("write failed"))
2965 } else {
2966 self.bytes.extend_from_slice(bytes);
2967 Ok(bytes.len())
2968 }
2969 }
2970
2971 fn flush(&mut self) -> io::Result<()> {
2972 Ok(())
2973 }
2974 }
2975
2976 impl SyncConfigFile for TestTempFile {
2977 fn sync_config(&self) -> io::Result<()> {
2978 if self.fail_sync {
2979 Err(io::Error::other("sync failed"))
2980 } else {
2981 Ok(())
2982 }
2983 }
2984 }
2985
2986 #[test]
2987 fn write_and_flush_temp_file_reports_write_and_sync_failures() {
2988 let dir = tempfile::tempdir().unwrap();
2989 let path = dir.path().join("bhtune.toml");
2990 let write_temp = dir.path().join("write.tmp");
2991 fs::write(&write_temp, b"placeholder").unwrap();
2992 let mut writer = TestTempFile {
2993 bytes: Vec::new(),
2994 fail_write: true,
2995 fail_sync: false,
2996 };
2997 writer.flush().unwrap();
2998 let err =
2999 write_and_flush_temp_file(&path, &write_temp, b"config", &mut writer).unwrap_err();
3000 assert!(matches!(
3001 err,
3002 ConfigStoreError::Write { action, .. } if action == "write temporary config file"
3003 ));
3004 assert!(!write_temp.exists());
3005
3006 let sync_temp = dir.path().join("sync.tmp");
3007 fs::write(&sync_temp, b"placeholder").unwrap();
3008 let mut writer = TestTempFile {
3009 bytes: Vec::new(),
3010 fail_write: false,
3011 fail_sync: true,
3012 };
3013 let err = write_and_flush_temp_file(&path, &sync_temp, b"config", &mut writer).unwrap_err();
3014 assert!(matches!(
3015 err,
3016 ConfigStoreError::Write { action, .. } if action == "flush temporary config file"
3017 ));
3018 assert!(!sync_temp.exists());
3019 }
3020
3021 #[test]
3022 fn atomic_writer_reports_parent_and_temp_creation_failures() {
3023 let dir = tempfile::tempdir().unwrap();
3024 let blocker = dir.path().join("blocker");
3025 fs::write(&blocker, b"not a directory").unwrap();
3026 let target = blocker.join("bhtune.toml");
3027
3028 let err = write_config_file_atomically(&target, b"config", true).unwrap_err();
3029 assert!(matches!(
3030 err,
3031 ConfigStoreError::Write { action, .. } if action == "create config directory"
3032 ));
3033
3034 let err = write_config_file_atomically(&target, b"config", false).unwrap_err();
3035 assert!(matches!(
3036 err,
3037 ConfigStoreError::Write { action, .. } if action == "create temporary config file"
3038 ));
3039 }
3040
3041 #[test]
3042 fn atomic_writer_reports_backup_and_replace_failures() {
3043 let dir = tempfile::tempdir().unwrap();
3044 let existing = dir.path().join("existing.toml");
3045 fs::write(&existing, b"old").unwrap();
3046 let noop_replace = |_source: &Path, _destination: &Path| Ok(());
3047 let err = write_config_file_atomically_with(
3048 &existing,
3049 b"new",
3050 false,
3051 |_source, _destination| Err(io::Error::other("backup failed")),
3052 noop_replace,
3053 )
3054 .unwrap_err();
3055 assert!(matches!(
3056 err,
3057 ConfigStoreError::Write { action, .. } if action == "create config backup"
3058 ));
3059 assert_eq!(fs::read(&existing).unwrap(), b"old");
3060
3061 let successful_target = dir.path().join("successful.toml");
3062 fs::write(&successful_target, b"old").unwrap();
3063 let successful_backup = write_config_file_atomically_with(
3064 &successful_target,
3065 b"new",
3066 false,
3067 |_source, _destination| Ok(0),
3068 noop_replace,
3069 )
3070 .unwrap();
3071 assert!(successful_backup.is_some());
3072 assert_eq!(fs::read(&successful_target).unwrap(), b"old");
3073
3074 let replace_target = dir.path().join("replace.toml");
3075 fs::write(&replace_target, b"old").unwrap();
3076 let err = write_config_file_atomically_with(
3077 &replace_target,
3078 b"new",
3079 false,
3080 |_source, _destination| Ok(0),
3081 |_source, _destination| Err(io::Error::other("replace failed")),
3082 )
3083 .unwrap_err();
3084 assert!(matches!(
3085 err,
3086 ConfigStoreError::Write { action, .. } if action == "replace config file"
3087 ));
3088 assert_eq!(fs::read(&replace_target).unwrap(), b"old");
3089
3090 let mut successful_sync = TestTempFile {
3091 bytes: Vec::new(),
3092 fail_write: false,
3093 fail_sync: false,
3094 };
3095 write_and_flush_temp_file(
3096 &replace_target,
3097 &dir.path().join("successful.tmp"),
3098 b"config",
3099 &mut successful_sync,
3100 )
3101 .unwrap();
3102 assert_eq!(successful_sync.bytes, b"config");
3103 successful_sync.sync_config().unwrap();
3104 }
3105
3106 #[test]
3107 fn save_config_store_creates_a_missing_auto_discovered_file_and_parent_dirs() {
3108 let dir = tempfile::tempdir().unwrap();
3109 let store =
3110 load_config_store_from(None, Some(dir.path().to_str().unwrap()), None, None, false)
3111 .unwrap();
3112 let expected_path = dir.path().join("bhtune").join("bhtune.toml");
3113
3114 let result = save_config_store(
3115 &store,
3116 &store.revision,
3117 &ConfigPolicyUpdate {
3118 allow_uncertain_quality: false,
3119 retention_days: Some(14),
3120 mrft_delay_secs: Some(12),
3121 poll_interval_ms: Some(250),
3122 timeout_secs: Some(900),
3123 op_timeout_secs: Some(5),
3124 restore_timeout_secs: Some(6),
3125 },
3126 )
3127 .unwrap();
3128
3129 assert_eq!(result.backup_path, None);
3130 assert!(expected_path.exists());
3131 assert_eq!(result.state.path, Some(expected_path.clone()));
3132 assert_eq!(result.state.config.retention_days, Some(14));
3133 assert!(!result.state.config.allow_uncertain_quality);
3134 assert_eq!(
3135 result.state.config.tuning,
3136 TuningConfig {
3137 mrft_delay_secs: Some(12),
3138 poll_interval_ms: Some(250),
3139 timeout_secs: Some(900),
3140 op_timeout_secs: Some(5),
3141 restore_timeout_secs: Some(6),
3142 }
3143 );
3144 assert_eq!(result.state.toml_tuning, result.state.config.tuning);
3145 assert_eq!(
3146 result.state.tuning_sources,
3147 tuning_config_sources(&result.state.toml_tuning)
3148 );
3149 let saved = fs::read_to_string(expected_path).unwrap();
3150 assert_eq!(result.state.original_raw.as_deref(), Some(saved.as_str()));
3151 }
3152
3153 #[test]
3154 fn save_config_store_creates_a_timestamped_backup_for_existing_files() {
3155 let dir = tempfile::tempdir().unwrap();
3156 let path = dir.path().join("bhtune.toml");
3157 let original = "bridge_host = \"before:7600\"\nretention_days = 7\n";
3158 fs::write(&path, original).unwrap();
3159
3160 let store = load_config_store_from(Some(&path), None, None, None, false).unwrap();
3161 let result = save_config_store(
3162 &store,
3163 &store.revision,
3164 &ConfigPolicyUpdate {
3165 allow_uncertain_quality: false,
3166 retention_days: Some(21),
3167 ..Default::default()
3168 },
3169 )
3170 .unwrap();
3171
3172 let backup_path = result.backup_path.clone().unwrap();
3173 assert!(backup_path.exists());
3174 assert_eq!(fs::read_to_string(backup_path).unwrap(), original);
3175 assert_eq!(
3176 fs::read_to_string(&path).unwrap(),
3177 result.state.original_raw.unwrap()
3178 );
3179 }
3180
3181 #[test]
3182 fn save_config_store_replaces_the_target_file_and_cleans_up_temp_files() {
3183 let dir = tempfile::tempdir().unwrap();
3184 let path = dir.path().join("bhtune.toml");
3185 fs::write(&path, "bridge_host = \"before:7600\"\n").unwrap();
3186
3187 let store = load_config_store_from(Some(&path), None, None, None, false).unwrap();
3188 let result = save_config_store(
3189 &store,
3190 &store.revision,
3191 &ConfigPolicyUpdate {
3192 allow_uncertain_quality: true,
3193 retention_days: Some(9),
3194 ..Default::default()
3195 },
3196 )
3197 .unwrap();
3198
3199 let final_raw = fs::read_to_string(&path).unwrap();
3200 assert_eq!(final_raw, result.state.original_raw.unwrap());
3201 assert!(final_raw.contains("allow_uncertain_quality = true"));
3202 assert!(final_raw.contains("retention_days = 9"));
3203 let (_backups, temps) = backup_and_temp_siblings(&path);
3204 assert!(temps.is_empty(), "temporary config files were left behind");
3205 }
3206
3207 #[test]
3208 fn save_config_store_rejects_a_stale_revision_token() {
3209 let dir = tempfile::tempdir().unwrap();
3210 let path = dir.path().join("bhtune.toml");
3211 fs::write(&path, "bridge_host = \"before:7600\"\n").unwrap();
3212
3213 let store = load_config_store_from(Some(&path), None, None, None, false).unwrap();
3214 let err = save_config_store(
3215 &store,
3216 "present:v1:stale",
3217 &ConfigPolicyUpdate {
3218 allow_uncertain_quality: false,
3219 retention_days: Some(5),
3220 poll_interval_ms: Some(250),
3221 ..Default::default()
3222 },
3223 )
3224 .unwrap_err();
3225
3226 assert!(matches!(
3227 err,
3228 ConfigStoreError::Conflict { message, .. }
3229 if message.contains("stale config revision token")
3230 ));
3231 }
3232
3233 #[test]
3234 fn save_config_store_resets_tuning_keys_without_removing_unknown_tuning_content() {
3235 let dir = tempfile::tempdir().unwrap();
3236 let path = dir.path().join("bhtune.toml");
3237 fs::write(
3238 &path,
3239 "[tuning]\npoll_interval_ms = 250\nrestore_timeout_secs = 8\nunknown = \"keep\"\n",
3240 )
3241 .unwrap();
3242 let store = load_config_store(Some(&path)).unwrap();
3243
3244 let result =
3245 save_config_store(&store, &store.revision, &ConfigPolicyUpdate::default()).unwrap();
3246
3247 let saved = fs::read_to_string(path).unwrap();
3248 assert!(!saved.contains("poll_interval_ms"));
3249 assert!(!saved.contains("restore_timeout_secs"));
3250 assert!(saved.contains("unknown = \"keep\""));
3251 assert_eq!(result.state.toml_tuning, TuningConfig::default());
3252 assert_eq!(result.state.config.tuning, TuningConfig::default());
3253 }
3254
3255 #[test]
3256 fn save_config_store_rejects_invalid_tuning_updates_before_writing() {
3257 let dir = tempfile::tempdir().unwrap();
3258 let path = dir.path().join("bhtune.toml");
3259 let original = "bridge_host = \"before:7600\"\n";
3260 fs::write(&path, original).unwrap();
3261 let store = load_config_store(Some(&path)).unwrap();
3262
3263 let error = save_config_store(
3264 &store,
3265 &store.revision,
3266 &ConfigPolicyUpdate {
3267 poll_interval_ms: Some(0),
3268 ..Default::default()
3269 },
3270 )
3271 .unwrap_err();
3272
3273 assert!(matches!(error, ConfigStoreError::Malformed { .. }));
3274 assert_eq!(fs::read_to_string(path).unwrap(), original);
3275 }
3276
3277 #[test]
3278 fn save_config_store_rejects_external_disk_edits() {
3279 let dir = tempfile::tempdir().unwrap();
3280 let path = dir.path().join("bhtune.toml");
3281 fs::write(&path, "bridge_host = \"before:7600\"\n").unwrap();
3282
3283 let store = load_config_store_from(Some(&path), None, None, None, false).unwrap();
3284 fs::write(&path, "bridge_host = \"outside:7600\"\n").unwrap();
3285
3286 let err = save_config_store(
3287 &store,
3288 &store.revision,
3289 &ConfigPolicyUpdate {
3290 allow_uncertain_quality: false,
3291 retention_days: Some(5),
3292 ..Default::default()
3293 },
3294 )
3295 .unwrap_err();
3296
3297 assert!(matches!(
3298 err,
3299 ConfigStoreError::Conflict { message, .. }
3300 if message.contains("changed on disk since it was loaded")
3301 ));
3302 }
3303
3304 #[test]
3305 fn save_config_store_rejects_unresolved_and_explicit_missing_paths() {
3306 let unresolved = load_config_store_from(None, None, None, None, false).unwrap();
3307 let err = save_config_store(
3308 &unresolved,
3309 &unresolved.revision,
3310 &ConfigPolicyUpdate {
3311 allow_uncertain_quality: true,
3312 retention_days: None,
3313 ..Default::default()
3314 },
3315 )
3316 .unwrap_err();
3317 assert!(matches!(err, ConfigStoreError::PathNotResolved));
3318
3319 let path = PathBuf::from("/nonexistent/explicit-save-config.toml");
3320 let state = LoadedConfigStore {
3321 path: Some(path.clone()),
3322 missing_is_allowed: false,
3323 original_raw: None,
3324 config: BhtuneConfig::default(),
3325 revision: revision_token_for_raw(None),
3326 toml_allow_uncertain_quality: None,
3327 toml_tuning: TuningConfig::default(),
3328 tuning_sources: tuning_config_sources(&TuningConfig::default()),
3329 };
3330 let err = save_config_store(
3331 &state,
3332 &state.revision,
3333 &ConfigPolicyUpdate {
3334 allow_uncertain_quality: true,
3335 retention_days: None,
3336 ..Default::default()
3337 },
3338 )
3339 .unwrap_err();
3340 assert!(matches!(err, ConfigStoreError::Missing { path: actual } if actual == path));
3341
3342 let dir = tempfile::tempdir().unwrap();
3343 let appeared_path = dir.path().join("appeared.toml");
3344 fs::write(&appeared_path, "bridge_host = \"external:7600\"\n").unwrap();
3345 let appeared = LoadedConfigStore {
3346 path: Some(appeared_path.clone()),
3347 missing_is_allowed: true,
3348 original_raw: None,
3349 config: BhtuneConfig::default(),
3350 revision: revision_token_for_raw(None),
3351 toml_allow_uncertain_quality: None,
3352 toml_tuning: TuningConfig::default(),
3353 tuning_sources: tuning_config_sources(&TuningConfig::default()),
3354 };
3355 let err = save_config_store(
3356 &appeared,
3357 &appeared.revision,
3358 &ConfigPolicyUpdate {
3359 allow_uncertain_quality: true,
3360 retention_days: None,
3361 ..Default::default()
3362 },
3363 )
3364 .unwrap_err();
3365 assert!(matches!(
3366 err,
3367 ConfigStoreError::Conflict {
3368 path: Some(actual), ..
3369 } if actual == appeared_path
3370 ));
3371 }
3372
3373 #[test]
3374 fn save_config_store_rejects_unreadable_and_malformed_stored_documents() {
3375 let dir = tempfile::tempdir().unwrap();
3376 let unreadable_path = dir.path().join("config-directory");
3377 fs::create_dir(&unreadable_path).unwrap();
3378 let unreadable = LoadedConfigStore {
3379 path: Some(unreadable_path.clone()),
3380 missing_is_allowed: false,
3381 original_raw: Some(String::new()),
3382 config: BhtuneConfig::default(),
3383 revision: revision_token_for_raw(Some("")),
3384 toml_allow_uncertain_quality: None,
3385 toml_tuning: TuningConfig::default(),
3386 tuning_sources: tuning_config_sources(&TuningConfig::default()),
3387 };
3388 let err = save_config_store(
3389 &unreadable,
3390 &unreadable.revision,
3391 &ConfigPolicyUpdate {
3392 allow_uncertain_quality: true,
3393 retention_days: None,
3394 ..Default::default()
3395 },
3396 )
3397 .unwrap_err();
3398 assert!(matches!(
3399 err,
3400 ConfigStoreError::Unreadable { path, .. } if path == unreadable_path
3401 ));
3402
3403 let malformed_path = dir.path().join("malformed.toml");
3404 fs::write(&malformed_path, "[").unwrap();
3405 let malformed = LoadedConfigStore {
3406 path: Some(malformed_path.clone()),
3407 missing_is_allowed: false,
3408 original_raw: Some("[".to_string()),
3409 config: BhtuneConfig::default(),
3410 revision: revision_token_for_raw(Some("[")),
3411 toml_allow_uncertain_quality: None,
3412 toml_tuning: TuningConfig::default(),
3413 tuning_sources: tuning_config_sources(&TuningConfig::default()),
3414 };
3415 let err = save_config_store(
3416 &malformed,
3417 &malformed.revision,
3418 &ConfigPolicyUpdate {
3419 allow_uncertain_quality: true,
3420 retention_days: None,
3421 ..Default::default()
3422 },
3423 )
3424 .unwrap_err();
3425 assert!(matches!(
3426 err,
3427 ConfigStoreError::Malformed {
3428 path: Some(path), ..
3429 } if path == malformed_path
3430 ));
3431 }
3432
3433 fn valid_templates_toml(name: &str) -> String {
3437 format!(
3438 r#"
3439[[template]]
3440name = "{name}"
3441revert_mode = false
3442proportional_type = "band"
3443integral_type = "reset_time"
3444integral_unit = "seconds"
3445derivative_type = "derivative_time"
3446derivative_unit = "seconds"
3447process_variable_suffix = "PV"
3448manipulated_variable_suffix = "MV"
3449setpoint_variable_suffix = "SV"
3450controller_direction_suffix = ""
3451controller_mode_suffix = ""
3452mode_attribute_suffix = ""
3453upper_pv_range_suffix = "SH"
3454lower_pv_range_suffix = "SL"
3455upper_mv_range_suffix = "MSH"
3456lower_mv_range_suffix = "MSL"
3457proportional_constant_suffix = "P"
3458integral_constant_suffix = "I"
3459derivative_constant_suffix = "D"
3460mode_manual_value = ""
3461mode_auto_value = ""
3462controller_action_direct_value = "0"
3463"#
3464 )
3465 }
3466
3467 #[test]
3468 fn load_user_templates_nothing_explicit_and_nothing_discovered_returns_none() {
3469 let templates =
3470 load_user_templates(None, &BhtuneConfig::default(), None, None, None, false).unwrap();
3471 assert_eq!(templates, None);
3472 }
3473
3474 #[test]
3475 fn load_user_templates_auto_discovered_path_missing_is_not_an_error() {
3476 let dir = tempfile::tempdir().unwrap();
3481 let templates = load_user_templates(
3482 None,
3483 &BhtuneConfig::default(),
3484 Some(dir.path().to_str().unwrap()),
3485 None,
3486 None,
3487 false,
3488 )
3489 .unwrap();
3490 assert_eq!(templates, None);
3491 }
3492
3493 #[test]
3494 fn load_user_templates_explicit_cli_path_missing_is_an_error() {
3495 let err = load_user_templates(
3496 Some(PathBuf::from("/nonexistent/templates.toml")),
3497 &BhtuneConfig::default(),
3498 None,
3499 None,
3500 None,
3501 false,
3502 )
3503 .unwrap_err();
3504 assert!(err.to_string().contains("templates file not found"));
3505 }
3506
3507 #[test]
3508 fn load_user_templates_explicit_config_key_path_missing_is_an_error() {
3509 let config = BhtuneConfig {
3510 templates: Some(PathBuf::from("/nonexistent/templates.toml")),
3511 ..Default::default()
3512 };
3513 let err = load_user_templates(None, &config, None, None, None, false).unwrap_err();
3514 assert!(err.to_string().contains("templates file not found"));
3515 }
3516
3517 #[test]
3518 fn load_user_templates_valid_file_is_parsed_and_validated() {
3519 let mut file = tempfile::NamedTempFile::new().unwrap();
3520 write!(file, "{}", valid_templates_toml("Test Template")).unwrap();
3521 let templates = load_user_templates(
3522 Some(file.path().to_path_buf()),
3523 &BhtuneConfig::default(),
3524 None,
3525 None,
3526 None,
3527 false,
3528 )
3529 .unwrap()
3530 .unwrap();
3531 assert_eq!(templates.len(), 1);
3532 assert_eq!(templates[0].name, "Test Template");
3533 }
3534
3535 #[test]
3536 fn load_user_templates_malformed_toml_is_an_error_naming_the_file() {
3537 let mut file = tempfile::NamedTempFile::new().unwrap();
3538 writeln!(file, "this is not valid toml [[[").unwrap();
3539 let err = load_user_templates(
3540 Some(file.path().to_path_buf()),
3541 &BhtuneConfig::default(),
3542 None,
3543 None,
3544 None,
3545 false,
3546 )
3547 .unwrap_err();
3548 let message = err.to_string();
3549 assert!(message.contains("failed to parse templates file"));
3550 assert!(message.contains(&file.path().display().to_string()));
3551 }
3552
3553 #[test]
3554 fn load_user_templates_a_template_failing_validation_is_an_error() {
3555 let mut file = tempfile::NamedTempFile::new().unwrap();
3559 let toml = valid_templates_toml("Broken Template").replace(
3560 "manipulated_variable_suffix = \"MV\"",
3561 "manipulated_variable_suffix = \"\"",
3562 );
3563 write!(file, "{toml}").unwrap();
3564 let err = load_user_templates(
3565 Some(file.path().to_path_buf()),
3566 &BhtuneConfig::default(),
3567 None,
3568 None,
3569 None,
3570 false,
3571 )
3572 .unwrap_err();
3573 assert!(
3574 err.to_string()
3575 .contains("manipulated_variable_suffix must not be empty")
3576 );
3577 }
3578
3579 #[test]
3580 fn load_user_templates_generic_io_error_is_an_error() {
3581 let dir = tempfile::tempdir().unwrap();
3585 let err = load_user_templates(
3586 Some(dir.path().to_path_buf()),
3587 &BhtuneConfig::default(),
3588 None,
3589 None,
3590 None,
3591 false,
3592 )
3593 .unwrap_err();
3594 assert!(err.to_string().contains("failed to read templates file"));
3595 }
3596
3597 #[test]
3598 fn load_user_templates_cli_flag_wins_over_config_key() {
3599 let mut cli_file = tempfile::NamedTempFile::new().unwrap();
3600 write!(cli_file, "{}", valid_templates_toml("From CLI Flag")).unwrap();
3601 let mut config_file = tempfile::NamedTempFile::new().unwrap();
3602 write!(config_file, "{}", valid_templates_toml("From Config File")).unwrap();
3603
3604 let config = BhtuneConfig {
3605 templates: Some(config_file.path().to_path_buf()),
3606 ..Default::default()
3607 };
3608 let templates = load_user_templates(
3609 Some(cli_file.path().to_path_buf()),
3610 &config,
3611 None,
3612 None,
3613 None,
3614 false,
3615 )
3616 .unwrap()
3617 .unwrap();
3618 assert_eq!(templates[0].name, "From CLI Flag");
3619 }
3620
3621 #[test]
3622 fn load_user_templates_config_key_is_used_when_no_cli_flag_is_given() {
3623 let mut config_file = tempfile::NamedTempFile::new().unwrap();
3624 write!(config_file, "{}", valid_templates_toml("From Config File")).unwrap();
3625 let config = BhtuneConfig {
3626 templates: Some(config_file.path().to_path_buf()),
3627 ..Default::default()
3628 };
3629 let templates = load_user_templates(None, &config, None, None, None, false)
3630 .unwrap()
3631 .unwrap();
3632 assert_eq!(templates[0].name, "From Config File");
3633 }
3634
3635 #[test]
3636 fn resolve_db_path_cli_wins() {
3637 let config = BhtuneConfig {
3638 db: Some(PathBuf::from("/config/bhtune.db")),
3639 ..Default::default()
3640 };
3641 let resolved = resolve_db_path(
3642 Some(PathBuf::from("/cli/bhtune.db")),
3643 &config,
3644 Some("/xdg-data"),
3645 Some("/home/me"),
3646 None,
3647 false,
3648 );
3649 assert_eq!(resolved, PathBuf::from("/cli/bhtune.db"));
3650 }
3651
3652 #[test]
3653 fn resolve_db_path_config_wins_over_platform_default() {
3654 let config = BhtuneConfig {
3655 db: Some(PathBuf::from("/config/bhtune.db")),
3656 ..Default::default()
3657 };
3658 let resolved = resolve_db_path(None, &config, Some("/xdg-data"), None, None, false);
3659 assert_eq!(resolved, PathBuf::from("/config/bhtune.db"));
3660 }
3661
3662 #[test]
3663 fn resolve_db_path_falls_back_to_platform_default() {
3664 let resolved = resolve_db_path(
3665 None,
3666 &BhtuneConfig::default(),
3667 Some("/xdg-data"),
3668 Some("/home/me"),
3669 None,
3670 false,
3671 );
3672 assert_eq!(resolved, PathBuf::from("/xdg-data/bhtune/bhtune.db"));
3673 }
3674
3675 #[test]
3676 fn resolve_bridge_host_cli_wins() {
3677 let config = BhtuneConfig {
3678 bridge_host: Some("configured:1".into()),
3679 ..Default::default()
3680 };
3681 assert_eq!(
3682 resolve_bridge_host(Some("cli:2".to_string()), &config),
3683 "cli:2".to_string()
3684 );
3685 }
3686
3687 #[test]
3688 fn resolve_bridge_host_config_wins_over_default() {
3689 let config = BhtuneConfig {
3690 bridge_host: Some("configured:1".into()),
3691 ..Default::default()
3692 };
3693 assert_eq!(
3694 resolve_bridge_host(None, &config),
3695 "configured:1".to_string()
3696 );
3697 }
3698
3699 #[test]
3700 fn resolve_bridge_host_default() {
3701 assert_eq!(
3702 resolve_bridge_host(None, &BhtuneConfig::default()),
3703 DEFAULT_BRIDGE_HOST.to_string()
3704 );
3705 }
3706
3707 #[test]
3708 fn resolve_bind_addr_cli_wins() {
3709 let config = BhtuneConfig {
3710 bind: Some("0.0.0.0:9999".into()),
3711 ..Default::default()
3712 };
3713 assert_eq!(
3714 resolve_bind_addr(Some("127.0.0.1:1234".to_string()), &config),
3715 "127.0.0.1:1234".to_string()
3716 );
3717 }
3718
3719 #[test]
3720 fn resolve_bind_addr_config_wins_over_default() {
3721 let config = BhtuneConfig {
3722 bind: Some("0.0.0.0:9999".into()),
3723 ..Default::default()
3724 };
3725 assert_eq!(resolve_bind_addr(None, &config), "0.0.0.0:9999".to_string());
3726 }
3727
3728 #[test]
3729 fn resolve_bind_addr_default() {
3730 assert_eq!(
3731 resolve_bind_addr(None, &BhtuneConfig::default()),
3732 DEFAULT_BIND_ADDR.to_string()
3733 );
3734 }
3735
3736 #[test]
3737 fn resolve_retention_days_cli_wins() {
3738 let config = BhtuneConfig {
3739 retention_days: Some(90),
3740 ..Default::default()
3741 };
3742 assert_eq!(resolve_retention_days(Some(30), &config), Some(30));
3743 }
3744
3745 #[test]
3746 fn resolve_retention_days_config_wins_over_default() {
3747 let config = BhtuneConfig {
3748 retention_days: Some(90),
3749 ..Default::default()
3750 };
3751 assert_eq!(resolve_retention_days(None, &config), Some(90));
3752 }
3753
3754 #[test]
3755 fn resolve_retention_days_default_is_retain_forever() {
3756 assert_eq!(resolve_retention_days(None, &BhtuneConfig::default()), None);
3760 }
3761
3762 #[test]
3763 fn resolve_server_cli_wins() {
3764 let config = BhtuneConfig {
3765 server: Some("ConfigServer".into()),
3766 ..Default::default()
3767 };
3768 assert_eq!(
3769 resolve_server(Some("CliServer".to_string()), &config).unwrap(),
3770 "CliServer"
3771 );
3772 }
3773
3774 #[test]
3775 fn resolve_server_config_fallback() {
3776 let config = BhtuneConfig {
3777 server: Some("ConfigServer".into()),
3778 ..Default::default()
3779 };
3780 assert_eq!(resolve_server(None, &config).unwrap(), "ConfigServer");
3781 }
3782
3783 #[test]
3784 fn resolve_server_neither_set_errors() {
3785 let err = resolve_server(None, &BhtuneConfig::default()).unwrap_err();
3786 assert!(err.to_string().contains("no OPC server specified"));
3787 }
3788
3789 #[test]
3790 fn demo_policy_defaults_are_valid_and_safe() {
3791 let policy = resolve_demo_policy(&DemoPolicyConfig::default()).unwrap();
3792 assert_eq!(policy, DemoPolicy::default());
3793 assert!(policy.validate().is_ok());
3794 assert_eq!(policy.session_ttl_secs, DEMO_SESSION_TTL_SECS);
3795 assert_eq!(policy.poll_interval_ms, DEMO_POLL_INTERVAL_MS);
3796 assert_eq!(policy.run_timeout_secs, DEMO_RUN_TIMEOUT_SECS);
3797 assert_eq!(policy.max_active_runs_global, DEMO_MAX_ACTIVE_RUNS_GLOBAL);
3798 assert_eq!(
3799 policy.max_active_runs_per_visitor,
3800 DEMO_MAX_ACTIVE_RUNS_PER_VISITOR
3801 );
3802 assert_eq!(
3803 policy.accepted_starts_per_token,
3804 DEMO_ACCEPTED_STARTS_PER_TOKEN
3805 );
3806 assert_eq!(
3807 policy.accepted_starts_per_client_ip,
3808 DEMO_ACCEPTED_STARTS_PER_CLIENT_IP
3809 );
3810 assert_eq!(
3811 policy.accepted_start_window_secs,
3812 DEMO_ACCEPTED_START_WINDOW_SECS
3813 );
3814 assert_eq!(
3815 policy.retained_runs_per_visitor,
3816 DEMO_RETAINED_RUNS_PER_VISITOR
3817 );
3818 assert_eq!(
3819 policy.max_tune_run_rows_global,
3820 DEMO_MAX_TUNE_RUN_ROWS_GLOBAL
3821 );
3822 assert_eq!(policy.max_json_body_bytes, DEMO_MAX_JSON_BODY_BYTES);
3823 assert_eq!(policy.max_sse_per_visitor, DEMO_MAX_SSE_PER_VISITOR);
3824 assert_eq!(policy.max_sse_global, DEMO_MAX_SSE_GLOBAL);
3825 assert_eq!(policy.sse_lifetime_secs, DEMO_SSE_LIFETIME_SECS);
3826 assert_eq!(
3827 policy.ordinary_request_concurrency,
3828 DEMO_ORDINARY_REQUEST_CONCURRENCY
3829 );
3830 assert_eq!(
3831 policy.ordinary_request_timeout_secs,
3832 DEMO_ORDINARY_REQUEST_TIMEOUT_SECS
3833 );
3834 assert_eq!(policy.cleanup_interval_secs, DEMO_CLEANUP_INTERVAL_SECS);
3835 }
3836
3837 #[test]
3838 fn demo_policy_accepts_explicit_contract_values() {
3839 let explicit = DemoPolicyConfig {
3840 session_ttl_secs: Some(DEMO_SESSION_TTL_SECS),
3841 poll_interval_ms: Some(DEMO_POLL_INTERVAL_MS),
3842 run_timeout_secs: Some(DEMO_RUN_TIMEOUT_SECS),
3843 max_active_runs_global: Some(DEMO_MAX_ACTIVE_RUNS_GLOBAL),
3844 max_active_runs_per_visitor: Some(DEMO_MAX_ACTIVE_RUNS_PER_VISITOR),
3845 accepted_starts_per_token: Some(DEMO_ACCEPTED_STARTS_PER_TOKEN),
3846 accepted_starts_per_client_ip: Some(DEMO_ACCEPTED_STARTS_PER_CLIENT_IP),
3847 accepted_start_window_secs: Some(DEMO_ACCEPTED_START_WINDOW_SECS),
3848 retained_runs_per_visitor: Some(DEMO_RETAINED_RUNS_PER_VISITOR),
3849 max_runs_per_session: Some(DEMO_MAX_RUNS_PER_SESSION),
3850 max_tune_run_rows_global: Some(DEMO_MAX_TUNE_RUN_ROWS_GLOBAL),
3851 max_json_body_bytes: Some(DEMO_MAX_JSON_BODY_BYTES),
3852 max_sse_per_visitor: Some(DEMO_MAX_SSE_PER_VISITOR),
3853 max_sse_global: Some(DEMO_MAX_SSE_GLOBAL),
3854 sse_lifetime_secs: Some(DEMO_SSE_LIFETIME_SECS),
3855 ordinary_request_concurrency: Some(DEMO_ORDINARY_REQUEST_CONCURRENCY),
3856 ordinary_request_timeout_secs: Some(DEMO_ORDINARY_REQUEST_TIMEOUT_SECS),
3857 cleanup_interval_secs: Some(DEMO_CLEANUP_INTERVAL_SECS),
3858 };
3859 assert_eq!(
3860 resolve_demo_policy(&explicit).unwrap(),
3861 DemoPolicy::default()
3862 );
3863 }
3864
3865 #[test]
3866 fn demo_policy_rejects_every_contract_override() {
3867 macro_rules! assert_invalid {
3868 ($field:ident, $value:expr) => {
3869 assert!(
3870 DemoPolicy {
3871 $field: $value,
3872 ..DemoPolicy::default()
3873 }
3874 .validate()
3875 .unwrap_err()
3876 .contains(concat!("demo.", stringify!($field))),
3877 "{} unexpectedly accepted an override",
3878 stringify!($field)
3879 );
3880 };
3881 }
3882
3883 assert_invalid!(session_ttl_secs, DEMO_SESSION_TTL_SECS - 1);
3884 assert_invalid!(poll_interval_ms, DEMO_POLL_INTERVAL_MS + 1);
3885 assert_invalid!(run_timeout_secs, DEMO_RUN_TIMEOUT_SECS + 1);
3886 assert_invalid!(max_active_runs_global, DEMO_MAX_ACTIVE_RUNS_GLOBAL + 1);
3887 assert_invalid!(
3888 max_active_runs_per_visitor,
3889 DEMO_MAX_ACTIVE_RUNS_PER_VISITOR + 1
3890 );
3891 assert_invalid!(
3892 accepted_starts_per_token,
3893 DEMO_ACCEPTED_STARTS_PER_TOKEN + 1
3894 );
3895 assert_invalid!(
3896 accepted_starts_per_client_ip,
3897 DEMO_ACCEPTED_STARTS_PER_CLIENT_IP + 1
3898 );
3899 assert_invalid!(
3900 accepted_start_window_secs,
3901 DEMO_ACCEPTED_START_WINDOW_SECS + 1
3902 );
3903 assert_invalid!(
3904 retained_runs_per_visitor,
3905 DEMO_RETAINED_RUNS_PER_VISITOR + 1
3906 );
3907 assert_invalid!(max_runs_per_session, DEMO_MAX_RUNS_PER_SESSION + 1);
3908 assert_invalid!(max_tune_run_rows_global, DEMO_MAX_TUNE_RUN_ROWS_GLOBAL + 1);
3909 assert_invalid!(max_json_body_bytes, DEMO_MAX_JSON_BODY_BYTES + 1);
3910 assert_invalid!(max_sse_per_visitor, DEMO_MAX_SSE_PER_VISITOR + 1);
3911 assert_invalid!(max_sse_global, DEMO_MAX_SSE_GLOBAL + 1);
3912 assert_invalid!(sse_lifetime_secs, DEMO_SSE_LIFETIME_SECS + 1);
3913 assert_invalid!(
3914 ordinary_request_concurrency,
3915 DEMO_ORDINARY_REQUEST_CONCURRENCY + 1
3916 );
3917 assert_invalid!(
3918 ordinary_request_timeout_secs,
3919 DEMO_ORDINARY_REQUEST_TIMEOUT_SECS + 1
3920 );
3921 assert_invalid!(cleanup_interval_secs, DEMO_CLEANUP_INTERVAL_SECS + 1);
3922 }
3923
3924 #[test]
3925 fn demo_policy_config_rejects_an_override_during_resolution() {
3926 let error = resolve_demo_policy(&DemoPolicyConfig {
3927 accepted_starts_per_token: Some(DEMO_ACCEPTED_STARTS_PER_TOKEN + 1),
3928 ..Default::default()
3929 })
3930 .unwrap_err();
3931 assert_eq!(
3932 error,
3933 format!(
3934 "demo.accepted_starts_per_token must be exactly \
3935 {DEMO_ACCEPTED_STARTS_PER_TOKEN}"
3936 )
3937 );
3938 }
3939
3940 #[test]
3941 fn demo_policy_config_rejects_legacy_conflated_rate_keys() {
3942 let error = toml::from_str::<BhtuneConfig>(
3943 r#"
3944[demo]
3945token_requests_per_window = 64
3946ip_requests_per_window = 10
3947rate_window_secs = 10
3948"#,
3949 )
3950 .unwrap_err();
3951 assert!(error.to_string().contains("unknown field"));
3952 }
3953
3954 #[test]
3955 fn example_config_declares_the_approved_demo_contract() {
3956 let config: BhtuneConfig = toml::from_str(include_str!("../bhtune.example.toml")).unwrap();
3957 assert_eq!(config.server_mode, Some(ServerMode::Full));
3958 assert_eq!(
3959 resolve_demo_policy_from_config(&config).unwrap(),
3960 DemoPolicy::default()
3961 );
3962 }
3963
3964 #[cfg(feature = "schemars")]
3965 #[test]
3966 fn demo_policy_schema_documents_exact_values_and_distinct_limits() {
3967 let schema = serde_json::to_value(schemars::schema_for!(DemoPolicyConfig)).unwrap();
3968 let properties = schema["properties"].as_object().unwrap();
3969 let expected = [
3970 ("session_ttl_secs", 86_400_u64),
3971 ("poll_interval_ms", 200),
3972 ("run_timeout_secs", 30),
3973 ("max_active_runs_global", 8),
3974 ("max_active_runs_per_visitor", 1),
3975 ("accepted_starts_per_token", 6),
3976 ("accepted_starts_per_client_ip", 6),
3977 ("accepted_start_window_secs", 600),
3978 ("retained_runs_per_visitor", 10),
3979 ("max_runs_per_session", 10),
3980 ("max_tune_run_rows_global", 5_000),
3981 ("max_json_body_bytes", 32_768),
3982 ("max_sse_per_visitor", 2),
3983 ("max_sse_global", 32),
3984 ("sse_lifetime_secs", 45),
3985 ("ordinary_request_concurrency", 64),
3986 ("ordinary_request_timeout_secs", 10),
3987 ("cleanup_interval_secs", 300),
3988 ];
3989 assert_eq!(properties.len(), expected.len());
3990 for (name, value) in expected {
3991 let property = &properties[name];
3992 assert_eq!(property["minimum"], value);
3993 assert_eq!(property["maximum"], value);
3994 assert!(
3995 property["description"]
3996 .as_str()
3997 .is_some_and(|description| !description.is_empty())
3998 );
3999 }
4000 assert_eq!(schema["additionalProperties"], false);
4001 assert!(!properties.contains_key("token_requests_per_window"));
4002 assert!(!properties.contains_key("request_timeout_secs"));
4003 }
4004
4005 #[test]
4006 fn server_mode_resolution_prefers_environment_then_config() {
4007 let config = BhtuneConfig {
4008 server_mode: Some(ServerMode::Demo),
4009 ..Default::default()
4010 };
4011 assert_eq!(
4012 resolve_server_mode(Some("full"), &config).unwrap(),
4013 ServerMode::Full
4014 );
4015 assert_eq!(
4016 resolve_server_mode(None, &config).unwrap(),
4017 ServerMode::Demo
4018 );
4019 let full_config = BhtuneConfig {
4020 server_mode: Some(ServerMode::Full),
4021 ..Default::default()
4022 };
4023 assert_eq!(
4024 resolve_server_mode(None, &full_config).unwrap(),
4025 ServerMode::Full
4026 );
4027 assert!(resolve_server_mode(Some("invalid"), &config).is_err());
4028 }
4029
4030 #[test]
4031 fn obsolete_mode_key_no_longer_selects_demo_mode() {
4032 let config: BhtuneConfig = toml::from_str("mode = \"demo\"").unwrap();
4033 assert_eq!(config.server_mode, None);
4034 assert_eq!(
4035 resolve_server_mode(None, &config).unwrap(),
4036 ServerMode::Full
4037 );
4038 }
4039
4040 #[test]
4041 fn demo_origin_requires_https_except_for_explicit_loopback_http() {
4042 for valid in [
4043 "https://demo.example",
4044 "https://demo.example:8443",
4045 "http://localhost:8787",
4046 "http://127.0.0.1:8787",
4047 "http://127.0.0.1:0",
4048 "http://[::1]:8787",
4049 ] {
4050 assert!(validate_demo_origin(valid).is_ok(), "{valid}");
4051 }
4052 for invalid in [
4053 "http://demo.example",
4054 "http://0.0.0.0:8787",
4055 "https://demo.example/",
4056 "https://user@demo.example",
4057 "https://demo.example/path",
4058 "https://demo.example?query",
4059 "https://demo.example#fragment",
4060 "ftp://demo.example",
4061 "demo.example",
4062 " https://demo.example",
4063 "https://demo.example:65536",
4064 "https://demo.example:not-a-port",
4065 "https://[::1",
4066 "https://::1",
4067 ] {
4068 assert!(validate_demo_origin(invalid).is_err(), "{invalid}");
4069 }
4070 }
4071
4072 #[test]
4073 fn origin_resolution_preserves_full_defaults_and_validates_demo() {
4074 let config = BhtuneConfig {
4075 origin: Some("https://config.example".to_owned()),
4076 ..Default::default()
4077 };
4078 assert_eq!(
4079 resolve_origin(
4080 Some("https://environment.example".to_owned()),
4081 &config,
4082 "127.0.0.1:8787",
4083 ServerMode::Demo,
4084 )
4085 .unwrap(),
4086 "https://environment.example"
4087 );
4088 assert_eq!(
4089 resolve_origin(None, &config, "127.0.0.1:8787", ServerMode::Demo).unwrap(),
4090 "https://config.example"
4091 );
4092 assert_eq!(
4093 resolve_origin(
4094 None,
4095 &BhtuneConfig::default(),
4096 "127.0.0.1:8787",
4097 ServerMode::Demo,
4098 )
4099 .unwrap(),
4100 "http://127.0.0.1:8787"
4101 );
4102 assert_eq!(
4103 resolve_origin(
4104 None,
4105 &BhtuneConfig::default(),
4106 "0.0.0.0:8787",
4107 ServerMode::Full,
4108 )
4109 .unwrap(),
4110 "http://0.0.0.0:8787"
4111 );
4112 assert!(
4113 resolve_origin(
4114 None,
4115 &BhtuneConfig::default(),
4116 "0.0.0.0:8787",
4117 ServerMode::Demo,
4118 )
4119 .is_err()
4120 );
4121 }
4122
4123 #[test]
4124 fn demo_trusted_proxy_accepts_only_supported_exact_addresses_and_networks() {
4125 for valid in [
4126 None,
4127 Some("127.0.0.1"),
4128 Some("::1"),
4129 Some("10.0.0.0/24"),
4130 Some("10.0.0.1/24"),
4131 Some("0.0.0.0/0"),
4132 Some("192.0.2.4/32"),
4133 Some("2001:db8::/32"),
4134 ] {
4135 assert!(validate_demo_trusted_proxy(valid).is_ok(), "{valid:?}");
4136 }
4137 for invalid in [
4138 Some(""),
4139 Some(" 10.0.0.1"),
4140 Some("proxy.example"),
4141 Some("10.0.0.0/33"),
4142 Some("::1/129"),
4143 ] {
4144 assert!(validate_demo_trusted_proxy(invalid).is_err(), "{invalid:?}");
4145 }
4146 }
4147}