Skip to main content

bhtune_cli/
config.rs

1//! Global bhtune configuration, including the shared `[tuning]` timing policy and the
2//! `CLI flag > env var > TOML config file > built-in default` precedence used by settings
3//! that expose command-line or environment overrides.
4
5use 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
15/// Default opcda-bridge gateway address bhtune connects to when nothing else specifies one.
16pub const DEFAULT_BRIDGE_HOST: &str = "localhost:7600";
17
18/// Default address `bhtune-server` binds to when nothing else specifies one -- loopback
19/// only, matching the "v1 binds to `127.0.0.1` by default" decision in AGENTS.md. Lives
20/// alongside [`DEFAULT_BRIDGE_HOST`] in this shared config module (rather than in
21/// `bhtune-server` itself) even though only the server binary ever calls
22/// [`resolve_bind_addr`], the same way `templates`/`log` below are settings only some
23/// commands consume -- one `bhtune.toml` file and one precedence chain for every bhtune
24/// setting, CLI or server.
25pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8787";
26
27/// Built-in pre/post-MRFT recording padding when `[tuning].mrft_delay_secs` is absent.
28pub const DEFAULT_TUNING_MRFT_DELAY_SECS: u32 = 0;
29/// Built-in driver polling interval when `[tuning].poll_interval_ms` is absent.
30pub const DEFAULT_TUNING_POLL_INTERVAL_MS: u64 = 800;
31/// Built-in whole-run timeout when `[tuning].timeout_secs` is absent.
32pub const DEFAULT_TUNING_TIMEOUT_SECS: u64 = 3_600;
33/// Built-in per-driver-operation timeout when `[tuning].op_timeout_secs` is absent.
34pub const DEFAULT_TUNING_OP_TIMEOUT_SECS: u64 = 30;
35/// Built-in post-run restoration timeout when `[tuning].restore_timeout_secs` is absent.
36pub const DEFAULT_TUNING_RESTORE_TIMEOUT_SECS: u64 = 30;
37/// Largest supported pre/post-MRFT recording delay.
38pub const MAX_TUNING_MRFT_DELAY_SECS: u32 = 3_600;
39/// Minimum restoration timeout for OPC DA runs.
40pub const MIN_OPC_RESTORE_TIMEOUT_SECS: u64 = 4;
41/// Fixed lifetime of an anonymous public Demo visitor session.
42pub const DEMO_SESSION_TTL_SECS: u64 = 86_400;
43/// Fixed simulator polling interval used by public Demo tunes.
44pub const DEMO_POLL_INTERVAL_MS: u64 = 200;
45/// Fixed whole-run timeout used by public Demo tunes.
46pub const DEMO_RUN_TIMEOUT_SECS: u64 = 30;
47/// Maximum number of Demo tunes that may be active across all visitors.
48pub const DEMO_MAX_ACTIVE_RUNS_GLOBAL: u32 = 8;
49/// Maximum number of Demo tunes that may be active for one visitor.
50pub const DEMO_MAX_ACTIVE_RUNS_PER_VISITOR: u32 = 1;
51/// Accepted Demo tune starts allowed for one session token in a quota window.
52pub const DEMO_ACCEPTED_STARTS_PER_TOKEN: u32 = 6;
53/// Accepted Demo tune starts allowed for one client IP in a quota window.
54pub const DEMO_ACCEPTED_STARTS_PER_CLIENT_IP: u32 = 6;
55/// Fixed window used by the accepted-start quotas.
56pub const DEMO_ACCEPTED_START_WINDOW_SECS: u64 = 600;
57/// Maximum accepted demo runs retained per session, independent of active-run concurrency.
58pub const DEMO_MAX_RUNS_PER_SESSION: u32 = 10;
59/// Maximum number of completed Demo runs retained for one visitor.
60pub const DEMO_RETAINED_RUNS_PER_VISITOR: u32 = 10;
61/// Maximum number of current Demo-owned `tune_runs` rows in the database.
62pub const DEMO_MAX_TUNE_RUN_ROWS_GLOBAL: u32 = 5_000;
63/// Maximum JSON request-body size accepted by the Demo API.
64pub const DEMO_MAX_JSON_BODY_BYTES: u64 = 32_768;
65/// Maximum number of simultaneous SSE streams for one visitor.
66pub const DEMO_MAX_SSE_PER_VISITOR: u32 = 2;
67/// Maximum number of simultaneous Demo SSE streams across all visitors.
68pub const DEMO_MAX_SSE_GLOBAL: u32 = 32;
69/// Absolute lifetime of one Demo SSE stream.
70pub const DEMO_SSE_LIFETIME_SECS: u64 = 45;
71/// Maximum number of ordinary Demo API requests processed concurrently.
72pub const DEMO_ORDINARY_REQUEST_CONCURRENCY: u32 = 64;
73/// Timeout applied to an ordinary, non-streaming Demo API request.
74pub const DEMO_ORDINARY_REQUEST_TIMEOUT_SECS: u64 = 10;
75/// Interval between expired-session and excess-history cleanup passes.
76pub const DEMO_CLEANUP_INTERVAL_SECS: u64 = 300;
77/// Built-in template exposed by the public Demo.
78pub const DEMO_TEMPLATE_NAME: &str = "Yokogawa CentumVP";
79/// Fixed persisted/display label used by public Demo runs.
80///
81/// This is deliberately not an OPC or simulator driver tag. The simulator still uses its
82/// internal `Sim.PV`/`Sim.MV` tags; this value is the stable run identity shown to visitors.
83pub const DEMO_TAG_NAME: &str = "Simulator demo";
84/// Default PV and MV lower bound used by public Demo runs.
85pub const DEMO_RANGE_LOW: f32 = 0.0;
86/// Default PV and MV upper bound used by public Demo runs.
87pub const DEMO_RANGE_HIGH: f32 = 100.0;
88/// Minimum PV/MV range endpoint accepted by the public Demo.
89pub const DEMO_RANGE_ENDPOINT_MIN: f32 = -1_000.0;
90/// Maximum PV/MV range endpoint accepted by the public Demo.
91pub const DEMO_RANGE_ENDPOINT_MAX: f32 = 1_000.0;
92/// Minimum PV/MV span accepted by the public Demo.
93pub const DEMO_RANGE_SPAN_MIN: f32 = 1.0;
94/// Maximum PV/MV span accepted by the public Demo.
95pub const DEMO_RANGE_SPAN_MAX: f32 = 1_000.0;
96/// Minimum non-zero process-gain magnitude accepted by the public Demo.
97pub const DEMO_SIM_GAIN_ABS_MIN: f32 = 0.1;
98/// Maximum process-gain magnitude accepted by the public Demo.
99pub const DEMO_SIM_GAIN_MAX: f32 = 5.0;
100/// Minimum simulator time constant accepted by the public Demo.
101pub const DEMO_SIM_TAU_MIN: f32 = 0.05;
102/// Maximum simulator time constant accepted by the public Demo.
103pub const DEMO_SIM_TAU_MAX: f32 = 5.0;
104/// Minimum simulator dead time accepted by the public Demo.
105pub const DEMO_SIM_DEAD_TIME_MIN: f32 = 0.0;
106/// Maximum simulator dead time accepted by the public Demo.
107pub const DEMO_SIM_DEAD_TIME_MAX: f32 = 2.0;
108/// Minimum simulator noise amplitude accepted by the public Demo.
109pub const DEMO_SIM_NOISE_MIN: f32 = 0.0;
110/// Maximum simulator noise amplitude as a fraction of the configured PV span.
111pub const DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION: f32 = 0.05;
112/// Largest simulator seed accepted by the public Demo.
113pub const DEMO_SIM_SEED_MAX: u64 = i32::MAX as u64;
114/// Minimum relay amplitude accepted by the public Demo.
115pub const DEMO_RELAY_AMP_MIN: f32 = 1.0;
116/// Maximum relay amplitude accepted by the public Demo.
117pub const DEMO_RELAY_AMP_MAX: f32 = 20.0;
118/// Default relay amplitude used by the Demo form.
119pub const DEMO_RELAY_AMP_DEFAULT: f32 = 10.0;
120/// Minimum relay-cycle skip count accepted by the public Demo.
121pub const DEMO_CYCLES_SKIP_MIN: u32 = 0;
122/// Maximum relay-cycle skip count accepted by the public Demo.
123pub const DEMO_CYCLES_SKIP_MAX: u32 = 2;
124/// Default relay-cycle skip count used by the Demo form.
125pub const DEMO_CYCLES_SKIP_DEFAULT: u32 = 1;
126/// Minimum relay-cycle count accepted by the public Demo.
127pub const DEMO_CYCLES_COUNT_MIN: u32 = 1;
128/// Maximum relay-cycle count accepted by the public Demo.
129pub const DEMO_CYCLES_COUNT_MAX: u32 = 3;
130/// Default relay-cycle count used by the Demo form.
131pub const DEMO_CYCLES_COUNT_DEFAULT: u32 = 2;
132/// Minimum switch noise-protection delay accepted by the public Demo.
133pub const DEMO_NOISE_PROTECTION_SECS_MIN: u32 = 0;
134/// Maximum switch noise-protection delay accepted by the public Demo.
135pub const DEMO_NOISE_PROTECTION_SECS_MAX: u32 = 3;
136/// Default switch noise-protection delay used by the Demo form.
137pub const DEMO_NOISE_PROTECTION_SECS_DEFAULT: u32 = 0;
138/// Default simulator process gain used by the Demo form.
139pub const DEMO_SIM_GAIN_DEFAULT: f32 = 1.0;
140/// Default simulator time constant used by the Demo form.
141pub const DEMO_SIM_TAU_DEFAULT: f32 = 0.5;
142/// Default simulator dead time used by the Demo form.
143pub const DEMO_SIM_DEAD_TIME_DEFAULT: f32 = 1.0;
144/// Default simulator noise amplitude used by the Demo form.
145pub const DEMO_SIM_NOISE_DEFAULT: f32 = 0.0;
146/// Default simulator random seed used by the Demo form.
147pub const DEMO_SIM_SEED_DEFAULT: u64 = 0;
148/// Default simulator PV and MV starting value used by the Demo form.
149pub const DEMO_SIM_INITIAL_VALUE_DEFAULT: f32 = 50.0;
150/// Name of the host-only anonymous Demo session cookie.
151pub const DEMO_COOKIE_NAME: &str = "__Host-bhtune_demo_session";
152
153/// Runtime server exposure mode. Full mode preserves the normal live-plant API; Demo mode
154/// is an explicitly restricted, simulator-only surface intended for public demonstrations.
155#[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/// Limits applied to the public simulator-only demo surface.
165#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, utoipa::ToSchema)]
166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
167pub struct DemoPolicy {
168    /// Anonymous visitor-session lifetime.
169    #[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    /// Simulator polling interval.
173    #[schema(minimum = 200, maximum = 200)]
174    #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
175    pub poll_interval_ms: u64,
176    /// Whole-run timeout.
177    #[schema(minimum = 30, maximum = 30)]
178    #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
179    pub run_timeout_secs: u64,
180    /// Active Demo tune limit across all visitors.
181    #[schema(minimum = 8, maximum = 8)]
182    #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
183    pub max_active_runs_global: u32,
184    /// Active Demo tune limit for one visitor.
185    #[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    /// Accepted starts for one session token in the quota window.
189    #[schema(minimum = 6, maximum = 6)]
190    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
191    pub accepted_starts_per_token: u32,
192    /// Accepted starts for one client IP in the quota window.
193    #[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    /// Window shared by both accepted-start quotas.
197    #[schema(minimum = 600, maximum = 600)]
198    #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
199    pub accepted_start_window_secs: u64,
200    /// Completed runs retained for one visitor.
201    #[schema(minimum = 10, maximum = 10)]
202    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
203    pub retained_runs_per_visitor: u32,
204    /// Maximum total demo runs accepted for one visitor.
205    pub max_runs_per_session: u32,
206    /// Current Demo-owned `tune_runs` row limit across all visitors.
207    #[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    /// Maximum JSON request-body size.
211    #[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    /// Simultaneous SSE streams for one visitor.
215    #[schema(minimum = 2, maximum = 2)]
216    #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
217    pub max_sse_per_visitor: u32,
218    /// Simultaneous Demo SSE streams across all visitors.
219    #[schema(minimum = 32, maximum = 32)]
220    #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
221    pub max_sse_global: u32,
222    /// Absolute lifetime of one Demo SSE stream.
223    #[schema(minimum = 45, maximum = 45)]
224    #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
225    pub sse_lifetime_secs: u64,
226    /// Concurrent ordinary, non-streaming Demo API requests.
227    #[schema(minimum = 64, maximum = 64)]
228    #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
229    pub ordinary_request_concurrency: u32,
230    /// Timeout for an ordinary, non-streaming Demo API request.
231    #[schema(minimum = 10, maximum = 10)]
232    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
233    pub ordinary_request_timeout_secs: u64,
234    /// Interval between Demo cleanup passes.
235    #[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/// Optional declarations of the fixed [`DemoPolicy`] contract.
369///
370/// Missing keys receive the approved value. A present key must state that same value; public
371/// Demo deployments cannot weaken or silently diverge from the documented resource policy.
372#[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    /// Anonymous visitor-session lifetime. Fixed at 86,400 seconds.
377    #[cfg_attr(feature = "schemars", schemars(range(min = 86_400, max = 86_400)))]
378    pub session_ttl_secs: Option<u64>,
379    /// Simulator polling interval. Fixed at 200 milliseconds.
380    #[cfg_attr(feature = "schemars", schemars(range(min = 200, max = 200)))]
381    pub poll_interval_ms: Option<u64>,
382    /// Whole-run timeout. Fixed at 30 seconds.
383    #[cfg_attr(feature = "schemars", schemars(range(min = 30, max = 30)))]
384    pub run_timeout_secs: Option<u64>,
385    /// Active Demo tune limit across all visitors. Fixed at 8.
386    #[cfg_attr(feature = "schemars", schemars(range(min = 8, max = 8)))]
387    pub max_active_runs_global: Option<u32>,
388    /// Active Demo tune limit for one visitor. Fixed at 1.
389    #[cfg_attr(feature = "schemars", schemars(range(min = 1, max = 1)))]
390    pub max_active_runs_per_visitor: Option<u32>,
391    /// Accepted starts for one session token in the quota window. Fixed at 6.
392    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
393    pub accepted_starts_per_token: Option<u32>,
394    /// Accepted starts for one client IP in the quota window. Fixed at 6.
395    #[cfg_attr(feature = "schemars", schemars(range(min = 6, max = 6)))]
396    pub accepted_starts_per_client_ip: Option<u32>,
397    /// Window shared by both accepted-start quotas. Fixed at 600 seconds.
398    #[cfg_attr(feature = "schemars", schemars(range(min = 600, max = 600)))]
399    pub accepted_start_window_secs: Option<u64>,
400    /// Completed runs retained for one visitor. Fixed at 10.
401    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
402    pub retained_runs_per_visitor: Option<u32>,
403    /// Runs retained for one session. Fixed at 10.
404    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
405    pub max_runs_per_session: Option<u32>,
406    /// Current Demo-owned `tune_runs` row limit across all visitors. Fixed at 5,000.
407    #[cfg_attr(feature = "schemars", schemars(range(min = 5_000, max = 5_000)))]
408    pub max_tune_run_rows_global: Option<u32>,
409    /// Maximum JSON request-body size. Fixed at 32,768 bytes.
410    #[cfg_attr(feature = "schemars", schemars(range(min = 32_768, max = 32_768)))]
411    pub max_json_body_bytes: Option<u64>,
412    /// Simultaneous SSE streams for one visitor. Fixed at 2.
413    #[cfg_attr(feature = "schemars", schemars(range(min = 2, max = 2)))]
414    pub max_sse_per_visitor: Option<u32>,
415    /// Simultaneous Demo SSE streams across all visitors. Fixed at 32.
416    #[cfg_attr(feature = "schemars", schemars(range(min = 32, max = 32)))]
417    pub max_sse_global: Option<u32>,
418    /// Absolute lifetime of one Demo SSE stream. Fixed at 45 seconds.
419    #[cfg_attr(feature = "schemars", schemars(range(min = 45, max = 45)))]
420    pub sse_lifetime_secs: Option<u64>,
421    /// Concurrent ordinary, non-streaming Demo API requests. Fixed at 64.
422    #[cfg_attr(feature = "schemars", schemars(range(min = 64, max = 64)))]
423    pub ordinary_request_concurrency: Option<u32>,
424    /// Timeout for an ordinary, non-streaming Demo API request. Fixed at 10 seconds.
425    #[cfg_attr(feature = "schemars", schemars(range(min = 10, max = 10)))]
426    pub ordinary_request_timeout_secs: Option<u64>,
427    /// Interval between Demo cleanup passes. Fixed at 300 seconds.
428    #[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/// Optional values authored in the `[tuning]` table.
515///
516/// Missing keys stay `None` so callers can distinguish an explicit TOML value from a
517/// built-in default. Use [`resolve_tuning_config`] to obtain the concrete values used by a
518/// tune and [`validate_tuning_config`] before preparing the run.
519#[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/// Concrete tuning timing policy after absent TOML keys have received built-in defaults.
535#[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/// Origin of one effective tuning value.
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
558pub enum TuningConfigSource {
559    Toml,
560    BuiltInDefault,
561}
562
563/// Per-field provenance for the effective `[tuning]` policy.
564#[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/// Validation error for concrete tuning timing values.
574#[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
609/// Resolve optional `[tuning]` values against the built-in defaults.
610pub 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
628/// Report whether each effective tuning value came from TOML or a built-in default.
629pub 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
647/// Validate concrete tuning timing values.
648///
649/// `require_opc_restore_minimum` raises the restoration minimum from one second to
650/// [`MIN_OPC_RESTORE_TIMEOUT_SECS`], matching the live OPC DA actuation-confirmation window.
651pub 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
689/// Resolve and validate a raw `[tuning]` table in one step.
690pub 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/// bhtune's configuration, loaded from an optional TOML file. Every field is optional; a
700/// value missing from the file (or the file itself missing) falls back to the env var / CLI
701/// flag / built-in default resolution in the `resolve_*` functions below.
702#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
703#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
704pub struct BhtuneConfig {
705    /// Runtime server mode. The environment variable `BHTUNE_SERVER_MODE` overrides this.
706    #[serde(default)]
707    pub server_mode: Option<ServerMode>,
708    /// Safety limits for the simulator-only public demo mode.
709    #[serde(default)]
710    pub demo: DemoPolicyConfig,
711    /// Overrides the default SQLite database path (see [`default_db_path_from`]).
712    pub db: Option<PathBuf>,
713    /// Overrides [`DEFAULT_BRIDGE_HOST`] for every `tune --driver opcda` and `opc`
714    /// subcommand invocation that doesn't pass `--bridge-host` explicitly.
715    pub bridge_host: Option<String>,
716    /// Default OPC DA server ProgID, used when `--server` is omitted. Unlike the other
717    /// fields there is no built-in default -- if this is unset and `--server` is omitted,
718    /// the command errors (see [`resolve_server`]).
719    pub server: Option<String>,
720    /// Overrides the default user-supplied DCS/PLC template catalog path (see
721    /// [`templates_path_from`]). A file here is loaded on every startup in addition to the
722    /// embedded built-in catalog (see `crate::db::open` and [`load_user_templates`]),
723    /// attributed `TemplateOrigin::Catalog`.
724    pub templates: Option<PathBuf>,
725    /// Overrides [`DEFAULT_BIND_ADDR`] -- the `host:port` `bhtune-server` listens on. Only
726    /// meaningful to the server binary; see [`resolve_bind_addr`].
727    pub bind: Option<String>,
728    /// Exact browser origin allowed for state-changing HTTP requests. `BHTUNE_ORIGIN`
729    /// overrides this value. Demo mode requires HTTPS, except for explicit loopback HTTP
730    /// origins used by local tests and development.
731    #[serde(default)]
732    pub origin: Option<String>,
733    /// IP address or matching-family CIDR of a reverse proxy trusted to supply the
734    /// single-address `X-BHTune-Client-IP` header for Demo quota accounting.
735    #[serde(default)]
736    pub trusted_proxy: Option<String>,
737    /// Age-based history retention (`history-retention`): tune runs with `started_at` older
738    /// than this many days are deleted automatically on every startup (both binaries, via
739    /// `crate::db::open`) and, for `bhtune-server`, again on a periodic timer while it keeps
740    /// running -- see `crate::retention`. A present value must be at least 1. `None` (the
741    /// default) means retain forever: there is no built-in number of days, since at this
742    /// project's data volumes (see AGENTS.md's History explorer notes) an unexpected
743    /// auto-delete of someone's baseline tune is a worse failure mode than an ever-growing
744    /// database file. See [`resolve_retention_days`].
745    #[serde(default, deserialize_with = "deserialize_retention_days")]
746    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
747    pub retention_days: Option<u32>,
748    /// Default OPC sample-quality policy for the server config page: `true` accepts
749    /// `Uncertain` quality, while `false` rejects it. A missing key is treated as `true`
750    /// when the config file is parsed, matching the configuration-page default rather than
751    /// `bool`'s ordinary `false`.
752    #[serde(default = "default_allow_uncertain_quality")]
753    pub allow_uncertain_quality: bool,
754    /// Global tune timing defaults. Missing keys remain `None` and resolve through
755    /// [`resolve_tuning_config`] only when a tune is prepared.
756    #[serde(default)]
757    pub tuning: TuningConfig,
758    /// `[log]` sub-table: level/directory/format/rotation for `crate::logging`'s tracing
759    /// setup, mirroring `opcda-bridge-gateway`'s own `log.*` config conventions.
760    #[serde(default)]
761    pub log: LogConfig,
762}
763
764/// Logging configuration keys (a `[log]` table in `bhtune.toml`), consumed by
765/// `crate::logging::resolve_log_settings`. Every field is optional and falls back through
766/// the same `CLI flag > env var > config file > default` precedence as the rest of
767/// [`BhtuneConfig`].
768#[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
777/// The default configuration-page quality policy: absent `allow_uncertain_quality` resolves
778/// to `true` rather than `bool`'s usual `false`.
779pub 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/// Path-resolution result for the TOML config store: either an explicit `--config` path or
817/// the auto-discovered default, plus whether a missing file is acceptable at that tier.
818#[derive(Debug, Clone, PartialEq, Eq)]
819pub struct ConfigPathResolution {
820    pub path: Option<PathBuf>,
821    pub missing_is_allowed: bool,
822}
823
824/// A path-aware TOML config snapshot suitable for server-side read/modify/write flows.
825#[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    /// Raw file value, if the key was present. This distinguishes an explicit `true` from
833    /// the defaulted value when reporting configuration provenance.
834    pub toml_allow_uncertain_quality: Option<bool>,
835    /// Raw optional values from the TOML `[tuning]` table.
836    pub toml_tuning: TuningConfig,
837    /// Per-field TOML/default provenance for [`Self::toml_tuning`].
838    pub tuning_sources: TuningConfigSources,
839}
840
841/// Config-page-owned settings that can be patched in place while preserving every unrelated
842/// key and comment in the source TOML.
843#[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/// Result of safely saving a patched TOML config file.
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct ConfigSaveResult {
883    pub backup_path: Option<PathBuf>,
884    pub state: LoadedConfigStore,
885}
886
887/// Typed errors for the path-aware TOML config store used by the server config page.
888#[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
961/// Derive bhtune's config file location from raw environment values rather than reading
962/// `std::env` directly -- keeps discovery fully unit-testable across every permutation
963/// without mutating real process environment variables.
964///
965/// - Windows (`is_windows = true`): `%APPDATA%\bhtune\bhtune.toml`.
966/// - Elsewhere: `$XDG_CONFIG_HOME/bhtune/bhtune.toml`, falling back to
967///   `$HOME/.config/bhtune/bhtune.toml`.
968pub 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
988/// Resolve which path the TOML config store should use: an explicit `--config` path if one
989/// was provided, otherwise the platform-default `bhtune.toml` location from
990/// [`config_path_from`]. Explicit paths must already exist; an auto-discovered missing file
991/// is acceptable and can be created on first save.
992pub 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
1106/// Derive bhtune's default *user template catalog* location the same way [`config_path_from`]
1107/// derives `bhtune.toml`'s -- deliberately the same directory, since both are per-user
1108/// settings a site admin edits by hand, not persistent application data (contrast
1109/// [`default_db_path_from`]/[`default_log_dir_from`], which live under the platform data
1110/// directory instead). See [`load_user_templates`] for how this default fits into the full
1111/// `template-user-catalog` precedence chain.
1112///
1113/// - Windows (`is_windows = true`): `%APPDATA%\bhtune\templates.toml`.
1114/// - Elsewhere: `$XDG_CONFIG_HOME/bhtune/templates.toml`, falling back to
1115///   `$HOME/.config/bhtune/templates.toml`.
1116pub 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
1136/// Derive bhtune's default *database* location the same way config files are discovered, but
1137/// under the platform's data directory rather than its config directory -- a database is
1138/// persistent user data, not settings, per the XDG base directory specification.
1139///
1140/// - Windows (`is_windows = true`): `%APPDATA%\bhtune\bhtune.db`.
1141/// - Elsewhere: `$XDG_DATA_HOME/bhtune/bhtune.db`, falling back to
1142///   `$HOME/.local/share/bhtune/bhtune.db`.
1143/// - A database location must always resolve to *something* usable (unlike the config
1144///   file, whose absence is fine) -- if none of the above are available, this falls back
1145///   further to `bhtune.db` in the current directory, matching the CLI's original
1146///   hardcoded placeholder default.
1147pub 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
1171/// Derive bhtune's default *log directory* the same way the database path is derived (see
1172/// [`default_db_path_from`]) -- under the platform data directory, not next to the compiled
1173/// binary. Unlike `opcda-bridge-gateway`'s equivalent (`log_dir_from_exe`), a `cargo
1174/// install`ed binary's own directory (e.g. `~/.cargo/bin/`) isn't a sensible place to write
1175/// logs, and bhtune already has this exact precedence machinery for the database, so the log
1176/// directory reuses it rather than inventing a second convention.
1177///
1178/// - Windows (`is_windows = true`): `%APPDATA%\bhtune\logs`.
1179/// - Elsewhere: `$XDG_DATA_HOME/bhtune/logs`, falling back to `$HOME/.local/share/bhtune/logs`.
1180/// - Falls back further to `logs` in the current directory if none of the above are
1181///   available, matching [`default_db_path_from`]'s own final fallback.
1182pub 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
1206/// Load a bhtune config from `path`.
1207///
1208/// A missing file resolves to `Ok(BhtuneConfig::default())` when `missing_is_error` is
1209/// false (the auto-discovered path may legitimately not exist yet); with an explicit
1210/// `--config` path a missing file is a hard error instead. A file that exists but fails to
1211/// parse as TOML is always a hard error -- a config typo should never be silently ignored.
1212pub 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
1221/// Parses the in-memory TOML representation of a config file.
1222///
1223/// Keeping the parser separate from filesystem discovery gives property tests and fuzz
1224/// targets a narrow, side-effect-free boundary to exercise.
1225pub 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
1243/// Patch only `allow_uncertain_quality` while preserving every unrelated key, comment, and
1244/// formatting detail the source document already had.
1245pub 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
1256/// Patch only `retention_days` while preserving every unrelated key, comment, and formatting
1257/// detail the source document already had. `None` removes the key entirely.
1258pub 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
1311/// Patch all five `[tuning]` values while preserving unrelated keys, comments, and formatting.
1312/// A `None` value removes only that key.
1313pub 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
1382/// Load the path-aware TOML config store using real environment-based auto-discovery.
1383pub 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
1395/// Load the path-aware TOML config store using injected path-discovery inputs so every
1396/// resolution branch stays unit-testable without mutating process-global environment
1397/// variables.
1398pub 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
1614/// Safely save the two config-page-managed settings with optimistic-concurrency checks.
1615///
1616/// The caller must provide the revision token it last loaded. The save is rejected when that
1617/// token is stale *or* the on-disk bytes no longer match the bytes this store last loaded or
1618/// wrote, preventing blind overwrites of external edits.
1619pub 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
1690/// Load the config from an auto-discovered path, falling back to defaults when no path
1691/// could be discovered at all (e.g. neither `XDG_CONFIG_HOME` nor `HOME` is set on a
1692/// non-Windows host). Split out from [`load_config`] so the no-path-discovered branch is
1693/// directly unit-testable with a literal `None`, without mutating real process-global
1694/// environment variables in a parallel test binary.
1695fn 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
1704/// Resolve and load the bhtune config: an explicit `--config` path if given, otherwise the
1705/// platform's auto-discovered path (silently falls back to defaults if none of the relevant
1706/// environment variables are set, or if the discovered file doesn't exist).
1707pub 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
1722/// Resolve and load the user-supplied DCS/PLC template catalog (`template-user-catalog`):
1723/// `--templates` / `BHTUNE_TEMPLATES` (already folded into `cli_templates` by clap's `env`
1724/// attribute) / the config file's `templates` key, or else the platform's auto-discovered
1725/// `templates.toml` next to `bhtune.toml` (see [`templates_path_from`]) -- `CLI flag > env
1726/// var > config file > platform default`, the same precedence chain as every other bhtune
1727/// setting.
1728///
1729/// Returns `Ok(None)` when no catalog applies at all: nothing was requested explicitly and
1730/// no file exists at the auto-discovered default path -- the common case, since most
1731/// installs never create this file. A path named *explicitly* (by any of the first three
1732/// tiers) that doesn't exist is a hard error, exactly mirroring `bhtune.toml` itself
1733/// (`load_config_file`'s `missing_is_error`); a file that exists (whichever tier it came
1734/// from) but fails to parse as TOML or fails `DcsTemplate::validate()` is always a hard
1735/// error naming the file and the problem -- a malformed catalog should never be silently
1736/// ignored. Parsing and validating are both handled by
1737/// [`bhtune_core::template::parse_catalog`], so this function does no TOML-shape or
1738/// cross-field checking of its own.
1739pub 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/// Resolve the database path with `CLI flag > env var > config file > platform default`
1775/// precedence. The env var is already folded into `cli_db` by clap's `env` attribute on
1776/// `Cli::db`; the platform-default tier takes its own raw environment values (rather than
1777/// reading `std::env` internally, like [`default_db_path_from`]) so this stays fully
1778/// unit-testable without touching real process environment variables.
1779#[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
1793/// Resolve the opcda-bridge gateway address with `CLI flag > env var > config file >
1794/// default` precedence. The env var is already folded into `cli_host` by clap's `env`
1795/// attribute on `TuneArgs::bridge_host`/`OpcCommand`'s per-variant `bridge_host`.
1796pub 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
1802/// Resolve the history retention policy (`history-retention`) with `CLI flag > env var >
1803/// config file > default` precedence, matching [`resolve_bridge_host`]'s shape. The env var
1804/// is already folded into `cli_days` by clap's `env` attribute on `Cli::retention_days`.
1805/// `None` means retain forever -- there is no built-in default number of days; see
1806/// [`BhtuneConfig::retention_days`] for why.
1807pub fn resolve_retention_days(cli_days: Option<u32>, config: &BhtuneConfig) -> Option<u32> {
1808    cli_days.or(config.retention_days)
1809}
1810
1811/// Resolve `bhtune-server`'s bind address with `CLI flag > env var > config file > default`
1812/// precedence, matching [`resolve_bridge_host`]'s shape exactly. `bhtune-server` has no
1813/// `clap` dependency (see AGENTS.md's "Deferred setup"), so unlike `resolve_bridge_host` the
1814/// env var isn't folded in by a derive attribute upstream -- callers pass
1815/// `std::env::var("BHTUNE_BIND").ok()` (or a real CLI flag, if one is ever added) directly as
1816/// `cli_bind`.
1817pub 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
1838/// Validates the exact browser origin used by a public Demo deployment.
1839///
1840/// HTTPS is mandatory except for an explicit loopback HTTP origin used by local tests and
1841/// development. Origins must contain only a scheme and authority: paths, query strings,
1842/// fragments, credentials, and trailing slashes are rejected.
1843pub 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
1905/// Validates a trusted reverse-proxy address before Demo requests may use forwarded client IPs.
1906///
1907/// The request path supports an exact IPv4/IPv6 address or a matching-family CIDR.
1908pub 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
1939/// Resolve the OPC DA server ProgID with `CLI flag > config file` precedence, erroring if
1940/// neither is set -- there's no sensible default for which OPC server to talk to.
1941pub 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        // Reading a directory as a file fails with an `IsADirectory`-style error, distinct
2435        // from `NotFound` -- exercises the catch-all I/O error branch (e.g. permission
2436        // denied in real usage).
2437        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        // No file will exist next to the test binary's own executable path, so this
2459        // exercises the "missing, not an error" auto-discovery path against whatever the
2460        // real test machine's environment happens to be.
2461        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        // Exercises the "neither XDG_CONFIG_HOME nor HOME is set" case directly, without
2468        // mutating real process environment variables (unsafe/flaky in a parallel test
2469        // binary) to force `config_path_from` itself to return `None`.
2470        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    /// A minimal, `DcsTemplate::validate()`-passing `[[template]]` block: non-empty `name`,
3434    /// PV/MV suffixes, and every other field either populated or left as an intentionally
3435    /// empty (not missing) suffix -- see `bhtune_core::template::DcsTemplate::validate`.
3436    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        // Points the auto-discovery tier at a real, empty directory that (by construction of
3477        // a fresh tempdir) has no `bhtune/templates.toml` inside it -- proves a missing file
3478        // at the *discovered* default is `Ok(None)`, not an error, unlike the explicit-path
3479        // case below.
3480        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        // `manipulated_variable_suffix` left empty fails `DcsTemplate::validate()` --
3556        // proves `parse_catalog`'s validation pass (not just its TOML-shape parsing) is
3557        // surfaced as a hard error too.
3558        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        // Reading a directory as a file fails with an `IsADirectory`-style error, distinct
3582        // from `NotFound` -- exercises the catch-all I/O error branch (e.g. permission
3583        // denied in real usage), mirroring `load_config_file_generic_io_error`.
3584        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        // No CLI flag, env var, or config key at all -- the deliberate "ships disabled by
3757        // default" behavior `history-retention`'s design note calls for, not merely the
3758        // absence of a hardcoded number.
3759        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}