sentinel_core/config/mod.rs
1//! Configuration parsing for `.perf-sentinel.toml`.
2//!
3//! Supports both the new sectioned format (`[thresholds]`, `[detection]`, `[green]`, `[daemon]`)
4//! and the legacy flat format for backward compatibility.
5
6use std::borrow::Cow;
7use std::collections::HashMap;
8#[cfg(test)]
9use std::time::Duration;
10
11use crate::detect::Confidence;
12use crate::score::alumet::AlumetConfig;
13use crate::score::carbon::DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2;
14use crate::score::cloud_energy::config::CloudEnergyConfig;
15use crate::score::kepler::KeplerConfig;
16use crate::score::redfish::RedfishConfig;
17#[cfg(test)]
18use crate::score::redfish::RedfishEndpoint;
19use crate::score::scaphandre::ScaphandreConfig;
20
21/// Top-level configuration for perf-sentinel.
22///
23/// Mirrors the four `.perf-sentinel.toml` sections (`[thresholds]`,
24/// `[detection]`, `[green]`, `[daemon]`) into typed sub-structs so a
25/// consumer that touches only thresholds does not pull a daemon-shaped
26/// import surface. The 0.5.x flat layout was unfolded in 0.6.0; see
27/// `docs/CONFIGURATION.md` for the rename matrix.
28#[derive(Debug, Clone, Default)]
29pub struct Config {
30 /// Quality-gate thresholds enforced by `analyze --ci`.
31 pub thresholds: ThresholdsConfig,
32 /// Per-detector knobs that drive `detect::detect`.
33 pub detection: DetectionConfig,
34 /// `GreenOps` / SCI-v1.0 scoring config.
35 pub green: GreenConfig,
36 /// Daemon (`perf-sentinel watch`) runtime config: listeners, ack
37 /// store, TLS, CORS, cross-trace correlation.
38 pub daemon: DaemonConfig,
39 /// Periodic disclosure report config (intent, org-config path, output
40 /// destination). Drives daemon startup validation when
41 /// `intent = "official"` and is consumed by `perf-sentinel disclose`.
42 pub reporting: ReportingConfig,
43}
44
45/// Maps 1:1 to `[reporting]` in TOML. All fields optional: an absent
46/// section means the operator never asked for a periodic disclosure.
47#[derive(Debug, Clone, Default)]
48pub struct ReportingConfig {
49 /// `"internal"`, `"official"`, or `"audited"`. `None` means no
50 /// reporting intent declared.
51 pub intent: Option<String>,
52 /// `"internal"` or `"public"`. Drives G1 vs G2 granularity.
53 pub confidentiality_level: Option<String>,
54 /// Path to the operator's organisation/scope/methodology TOML.
55 /// Required by daemon startup when `intent = "official"`.
56 pub org_config_path: Option<String>,
57 /// Path where `perf-sentinel disclose` writes the produced JSON.
58 /// Hint only, the CLI accepts an explicit `--output`.
59 pub disclose_output_path: Option<String>,
60 /// Period selector hint: `"calendar-quarter"`, `"calendar-month"`,
61 /// `"calendar-year"`, or `"custom"`. Pure hint for scheduled runs.
62 pub disclose_period: Option<String>,
63 /// Sigstore signing target. Empty defaults to the public Sigstore
64 /// instance. perf-sentinel does not sign itself; this value lives
65 /// in the report so `verify-hash` knows which Rekor to query.
66 pub sigstore: SigstoreConfig,
67}
68
69/// Sigstore Rekor + Fulcio endpoints used by `verify-hash` and reported
70/// in `integrity.signature.rekor_url`. Maps to `[reporting.sigstore]`.
71#[derive(Debug, Clone)]
72pub struct SigstoreConfig {
73 pub rekor_url: String,
74 pub fulcio_url: String,
75}
76
77impl Default for SigstoreConfig {
78 fn default() -> Self {
79 Self {
80 rekor_url: DEFAULT_REKOR_URL.to_string(),
81 fulcio_url: DEFAULT_FULCIO_URL.to_string(),
82 }
83 }
84}
85
86/// Public Sigstore Rekor transparency log.
87pub const DEFAULT_REKOR_URL: &str = "https://rekor.sigstore.dev";
88/// Public Sigstore Fulcio certificate authority.
89pub const DEFAULT_FULCIO_URL: &str = "https://fulcio.sigstore.dev";
90
91/// Workspace version that turns `[reporting] disclose_output_path`
92/// into a functional field (daemon-triggered periodic disclosures).
93/// Bump here when the timeline slips. The same value appears as a
94/// TOML comment in `docs/REPORTING.md` and `docs/FR/REPORTING-FR.md`,
95/// kept in sync by grep at release time.
96const RESERVED_DISCLOSE_OUTPUT_PATH_VERSION: &str = "0.8.0";
97
98/// Maps to `[daemon.archive]` in TOML. When `Some`, the daemon writes
99/// each per-window `Report` as one NDJSON line to `path`, with
100/// size-triggered rotation and `max_files` count-based pruning.
101#[derive(Debug, Clone)]
102pub struct DaemonArchiveConfig {
103 pub path: String,
104 pub max_size_mb: u64,
105 pub max_files: u32,
106}
107
108impl Default for DaemonArchiveConfig {
109 fn default() -> Self {
110 Self {
111 path: String::new(),
112 max_size_mb: 100,
113 max_files: 12,
114 }
115 }
116}
117
118/// Quality-gate thresholds. Maps 1:1 to `[thresholds]` in TOML.
119#[derive(Debug, Clone)]
120pub struct ThresholdsConfig {
121 /// Maximum allowed critical N+1 SQL findings before quality gate fails.
122 pub n_plus_one_sql_critical_max: u32,
123 /// Maximum allowed warning+ N+1 HTTP findings before quality gate fails.
124 pub n_plus_one_http_warning_max: u32,
125 /// Maximum allowed I/O waste ratio before quality gate fails.
126 pub io_waste_ratio_max: f64,
127}
128
129/// Per-detector knobs. Maps 1:1 to `[detection]` in TOML.
130#[derive(Debug, Clone)]
131pub struct DetectionConfig {
132 /// N+1 detection threshold: minimum repeated similar queries to flag.
133 pub n_plus_one_threshold: u32,
134 /// Sliding window duration in milliseconds for N+1 detection.
135 pub window_duration_ms: u64,
136 /// Threshold in milliseconds above which an operation is considered slow.
137 pub slow_query_threshold_ms: u64,
138 /// Minimum occurrences of a slow template to flag as a finding.
139 pub slow_query_min_occurrences: u32,
140 /// Maximum child spans per parent before flagging excessive fanout.
141 pub max_fanout: u32,
142 /// Minimum HTTP outbound calls per trace to flag as chatty service.
143 pub chatty_service_min_calls: u32,
144 /// Peak concurrent SQL spans per service to flag pool saturation.
145 pub pool_saturation_concurrent_threshold: u32,
146 /// Minimum sequential independent sibling calls to flag as serialized.
147 pub serialized_min_sequential: u32,
148 /// Sanitizer-aware classification mode for SQL N+1 vs redundant.
149 /// See [`crate::detect::sanitizer_aware::SanitizerAwareMode`].
150 pub sanitizer_aware_classification: crate::detect::sanitizer_aware::SanitizerAwareMode,
151}
152
153/// `GreenOps` / carbon scoring config. Maps to `[green]` in TOML.
154#[derive(Debug, Clone)]
155#[allow(clippy::struct_excessive_bools)] // Config aggregates the [green] toggles from .perf-sentinel.toml
156pub struct GreenConfig {
157 pub enabled: bool,
158 /// Fallback region for CO₂ scoring (e.g. `"eu-west-3"`).
159 pub default_region: Option<String>,
160 /// Per-service region overrides. Keys lowercased at load time.
161 pub service_regions: HashMap<String, String>,
162 /// SCI `M` term: embodied carbon per request (gCO₂eq).
163 pub embodied_carbon_per_request_gco2: f64,
164 /// Use 24-hour carbon intensity profiles when available.
165 pub use_hourly_profiles: bool,
166 /// Scaphandre RAPL scraper config (daemon only).
167 pub scaphandre: Option<ScaphandreConfig>,
168 /// Kepler eBPF energy scraper config (daemon only).
169 pub kepler: Option<KeplerConfig>,
170 /// Alumet energy scraper config (daemon only). Highest
171 /// measured-energy precedence, overrides Scaphandre.
172 pub alumet: Option<AlumetConfig>,
173 /// Redfish BMC wall-plug-power scraper config (daemon only).
174 pub redfish: Option<RedfishConfig>,
175 /// Cloud CPU% + `SPECpower` config (daemon only).
176 pub cloud_energy: Option<CloudEnergyConfig>,
177 /// Whether to use per-operation energy coefficients (SQL verb weighting,
178 /// HTTP payload size tiers) in the proxy model. Default: `true`.
179 pub per_operation_coefficients: bool,
180 /// Whether to compute a network transport energy term for cross-region
181 /// HTTP calls. Default: `false` (opt-in).
182 pub include_network_transport: bool,
183 /// Energy per byte for network transport (kWh/byte).
184 /// Default: 0.04 kWh/GB, a conservative upper bound below recent
185 /// whole-network averages (see `DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH`).
186 pub network_energy_per_byte_kwh: f64,
187 /// Path to user-supplied hourly profiles JSON file. `None` when not
188 /// configured (uses only embedded profiles).
189 pub hourly_profiles_file: Option<String>,
190 /// Pre-parsed custom hourly profiles, loaded at config parse time.
191 /// `None` when `hourly_profiles_file` is not set or failed to load.
192 pub custom_hourly_profiles:
193 Option<std::sync::Arc<HashMap<String, crate::score::carbon::HourlyProfile>>>,
194 /// Path to a calibration TOML file generated by `perf-sentinel calibrate`.
195 pub calibration_file: Option<String>,
196 /// Pre-loaded calibration data, parsed at config load time.
197 /// `None` when `calibration_file` is not set or failed to load.
198 pub calibration: Option<crate::calibrate::CalibrationData>,
199 /// Electricity Maps real-time carbon intensity config (daemon only).
200 pub electricity_maps: Option<crate::score::electricity_maps::ElectricityMapsConfig>,
201}
202
203/// Daemon runtime config. Maps to `[daemon]` plus its `[daemon.tls]`,
204/// `[daemon.ack]`, `[daemon.cors]` and `[daemon.correlation]` sub-tables.
205#[derive(Debug, Clone)]
206pub struct DaemonConfig {
207 pub listen_addr: String,
208 /// Port for OTLP HTTP receiver.
209 pub listen_port: u16,
210 /// Port for OTLP gRPC receiver.
211 pub listen_port_grpc: u16,
212 pub json_socket: String,
213 /// Maximum number of active traces in streaming mode.
214 pub max_active_traces: usize,
215 /// Trace TTL in milliseconds for streaming mode eviction.
216 pub trace_ttl_ms: u64,
217 /// Sampling rate for incoming traces (0.0 - 1.0).
218 pub sampling_rate: f64,
219 /// Maximum events kept per trace (ring buffer size).
220 pub max_events_per_trace: usize,
221 /// Maximum payload size in bytes for JSON deserialization.
222 pub max_payload_size: usize,
223 /// Deployment environment label used to stamp findings with a
224 /// [`Confidence`] value, so downstream consumers (perf-lint) can boost
225 /// severity on production traffic. Ignored in `analyze` batch mode,
226 /// which always emits [`Confidence::CiBatch`].
227 pub environment: DaemonEnvironment,
228 /// Maximum number of findings retained by the daemon query API.
229 pub max_retained_findings: usize,
230 /// Capacity of the ingestion channel: span-event batches buffered
231 /// between the listeners and the event loop. Provides ingestion
232 /// backpressure once full.
233 pub ingest_queue_capacity: usize,
234 /// Capacity of the analysis worker queue: evicted/expired batches
235 /// awaiting detect+score. When full, whole batches are shed (counted
236 /// on `perf_sentinel_analysis_shed_*`).
237 pub analysis_queue_capacity: usize,
238 /// Memory-pressure admission control, as a percentage of the cgroup v2
239 /// memory limit (1-100). When the pod's `memory.current / memory.max`
240 /// crosses this high-water mark, OTLP ingest is rejected with a
241 /// retryable status (counted on `perf_sentinel_otlp_rejected_total`
242 /// `{reason="memory_pressure"}`) until usage falls back below the mark,
243 /// so RSS is bounded independently of queue depth. `0` disables the
244 /// guard (default). Linux/cgroup-v2 only, inert elsewhere.
245 pub memory_high_water_pct: u8,
246 pub api_enabled: bool,
247 /// TLS material for the OTLP listeners. When `cert_path` and
248 /// `key_path` are both `Some`, both gRPC and HTTP listen TLS; when
249 /// both are `None`, plain TCP (default).
250 pub tls: DaemonTlsConfig,
251 /// Daemon-side ack store (JSONL persistence + HTTP API).
252 pub ack: DaemonAckConfig,
253 /// CORS layer for the daemon HTTP API.
254 pub cors: DaemonCorsConfig,
255 /// Cross-trace correlation. `enabled = false` by default; the
256 /// daemon never wires the correlator when off, so the other fields
257 /// only apply when `enabled = true`.
258 pub correlation: crate::detect::correlate_cross::CorrelationConfig,
259 /// Optional per-window `Report` archive writer. `None` (default)
260 /// means no archive is written. Consumed by `perf-sentinel disclose`.
261 pub archive: Option<DaemonArchiveConfig>,
262}
263
264/// TLS material. Both fields must be set together (or both `None`).
265#[derive(Debug, Clone, Default)]
266pub struct DaemonTlsConfig {
267 /// Path to PEM-encoded TLS certificate chain for the OTLP receivers.
268 pub cert_path: Option<String>,
269 /// Path to PEM-encoded TLS private key for the OTLP receivers.
270 pub key_path: Option<String>,
271}
272
273/// Daemon-side ack store config.
274#[derive(Debug, Clone)]
275pub struct DaemonAckConfig {
276 /// Whether the daemon-side ack store (JSONL persistence + HTTP API)
277 /// is enabled. Default `true`. Disabling skips both the TOML acks
278 /// load and the JSONL store init at startup, and the three ack
279 /// routes return 503 Service Unavailable.
280 pub enabled: bool,
281 /// Optional override for the JSONL storage path. Default resolves
282 /// at runtime via `dirs::data_local_dir()` to
283 /// `<data_local>/perf-sentinel/acks.jsonl`.
284 pub storage_path: Option<String>,
285 /// Optional opt-in API key. When set, `POST` and `DELETE` on
286 /// `/api/findings/<sig>/ack` require an `X-API-Key` header
287 /// matching this value (constant-time compared). Default `None`
288 /// means no auth, suitable for the loopback-only deployment.
289 pub api_key: Option<String>,
290 /// Optional override for the CI ack TOML file path read at daemon
291 /// startup. Default `.perf-sentinel-acknowledgments.toml` in CWD.
292 pub toml_path: Option<String>,
293}
294
295/// Daemon HTTP API CORS layer config.
296#[derive(Debug, Clone, Default)]
297pub struct DaemonCorsConfig {
298 /// Allowed origins for the daemon HTTP API CORS layer. Empty (default)
299 /// means no CORS headers are emitted, which preserves the pre-CORS
300 /// behavior. `["*"]` is wildcard mode, intended for development. A
301 /// non-wildcard list is the production posture: each entry must be a
302 /// full origin (scheme + host + optional port), e.g.
303 /// `"https://reports.example.com"`. Configured via
304 /// `[daemon.cors] allowed_origins` in TOML.
305 pub allowed_origins: Vec<String>,
306}
307
308/// Deployment environment for the daemon's `watch` mode.
309///
310/// Maps 1:1 to [`Confidence`] via [`Config::confidence`]:
311/// - [`Self::Staging`] → [`Confidence::DaemonStaging`]
312/// - [`Self::Production`] → [`Confidence::DaemonProduction`]
313///
314/// Parsed from the `[daemon] environment` TOML field as case-insensitive
315/// `"staging"` or `"production"`. Any other value is rejected at load time
316/// with a clear validation error.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
318pub enum DaemonEnvironment {
319 /// Staging traffic, medium confidence. Default.
320 #[default]
321 Staging,
322 /// Production traffic, high confidence.
323 Production,
324}
325
326impl DaemonEnvironment {
327 /// Returns the lowercase string label used in the TOML config.
328 #[must_use]
329 pub const fn as_str(&self) -> &'static str {
330 match self {
331 Self::Staging => "staging",
332 Self::Production => "production",
333 }
334 }
335}
336
337impl Default for ThresholdsConfig {
338 fn default() -> Self {
339 Self {
340 n_plus_one_sql_critical_max: 0,
341 n_plus_one_http_warning_max: 3,
342 io_waste_ratio_max: 0.30,
343 }
344 }
345}
346
347impl Default for DetectionConfig {
348 fn default() -> Self {
349 Self {
350 n_plus_one_threshold: 5,
351 window_duration_ms: 500,
352 slow_query_threshold_ms: 500,
353 slow_query_min_occurrences: 3,
354 max_fanout: 20,
355 chatty_service_min_calls: 15,
356 pool_saturation_concurrent_threshold: 10,
357 serialized_min_sequential: 3,
358 sanitizer_aware_classification:
359 crate::detect::sanitizer_aware::SanitizerAwareMode::default(),
360 }
361 }
362}
363
364impl Default for GreenConfig {
365 fn default() -> Self {
366 Self {
367 enabled: true,
368 default_region: None,
369 service_regions: HashMap::new(),
370 embodied_carbon_per_request_gco2: DEFAULT_EMBODIED_CARBON_PER_REQUEST_GCO2,
371 use_hourly_profiles: true,
372 scaphandre: None,
373 kepler: None,
374 alumet: None,
375 redfish: None,
376 cloud_energy: None,
377 per_operation_coefficients: true,
378 include_network_transport: false,
379 network_energy_per_byte_kwh: crate::score::carbon::DEFAULT_NETWORK_ENERGY_PER_BYTE_KWH,
380 hourly_profiles_file: None,
381 custom_hourly_profiles: None,
382 calibration_file: None,
383 calibration: None,
384 electricity_maps: None,
385 }
386 }
387}
388
389impl Default for DaemonConfig {
390 fn default() -> Self {
391 Self {
392 listen_addr: "127.0.0.1".to_string(),
393 listen_port: 4318,
394 listen_port_grpc: 4317,
395 json_socket: "/tmp/perf-sentinel.sock".to_string(),
396 max_active_traces: 10_000,
397 trace_ttl_ms: 30_000,
398 sampling_rate: 1.0,
399 max_events_per_trace: 1_000,
400 // 16 MiB, comfort-zone ceiling (warn_unusual_daemon_limits)
401 max_payload_size: 16 * 1024 * 1024,
402 environment: DaemonEnvironment::Staging,
403 max_retained_findings: 10_000,
404 ingest_queue_capacity: 1024,
405 analysis_queue_capacity: 1024,
406 memory_high_water_pct: 0,
407 api_enabled: true,
408 tls: DaemonTlsConfig::default(),
409 ack: DaemonAckConfig::default(),
410 cors: DaemonCorsConfig::default(),
411 correlation: crate::detect::correlate_cross::CorrelationConfig::default(),
412 archive: None,
413 }
414 }
415}
416
417impl Default for DaemonAckConfig {
418 fn default() -> Self {
419 Self {
420 enabled: true,
421 storage_path: None,
422 api_key: None,
423 toml_path: None,
424 }
425 }
426}
427
428impl Config {
429 /// Map the daemon environment to a [`Confidence`] value.
430 ///
431 /// Used by `daemon::run` to stamp findings after detection. `analyze`
432 /// batch mode does not call this; it picks `CiBatch` or `LocalBatch`
433 /// from the host CI environment in `pipeline::analyze_with_traces`
434 /// instead (see `pipeline::ci_environment_detected`).
435 #[must_use]
436 pub const fn confidence(&self) -> Confidence {
437 match self.daemon.environment {
438 DaemonEnvironment::Staging => Confidence::DaemonStaging,
439 DaemonEnvironment::Production => Confidence::DaemonProduction,
440 }
441 }
442
443 /// Build a [`CarbonContext`] from the green config fields.
444 ///
445 /// Returns a context with `energy_snapshot: None`. The daemon clones
446 /// this and patches in the measured energy snapshot per tick; the
447 /// batch pipeline uses it as-is (no scrapers in batch mode).
448 #[must_use]
449 pub fn carbon_context(&self) -> crate::score::carbon::CarbonContext {
450 let scoring_config = self
451 .green
452 .electricity_maps
453 .as_ref()
454 .map(crate::score::carbon::ScoringConfig::from_electricity_maps);
455 crate::score::carbon::CarbonContext {
456 default_region: self.green.default_region.clone(),
457 service_regions: self.green.service_regions.clone(),
458 embodied_per_request_gco2: self.green.embodied_carbon_per_request_gco2,
459 use_hourly_profiles: self.green.use_hourly_profiles,
460 energy_snapshot: None,
461 per_operation_coefficients: self.green.per_operation_coefficients,
462 include_network_transport: self.green.include_network_transport,
463 network_energy_per_byte_kwh: self.green.network_energy_per_byte_kwh,
464 custom_hourly_profiles: self.green.custom_hourly_profiles.clone(),
465 calibration: self.green.calibration.clone(),
466 real_time_intensity: None, // set per-tick in daemon via build_tick_ctx
467 scoring_config,
468 // window_kwh stays 0.0 here; the daemon patches it per tick
469 // with the energy accumulated since the previous scored batch.
470 db_energy: self
471 .green
472 .alumet
473 .as_ref()
474 .and_then(|a| a.database.as_ref())
475 .map(|db| crate::score::carbon::DbEnergyContext {
476 window_kwh: 0.0,
477 region: db.region.clone(),
478 }),
479 }
480 }
481}
482
483mod raw;
484mod toml_paths;
485mod validate;
486
487use raw::{RawConfig, parse_daemon_environment, parse_kepler_metric_kind, validate_alumet_raw};
488use toml_paths::normalize_toml_path_strings;
489pub(crate) use validate::has_control_char;
490
491// Re-imports so `use super::*;` in the tests module keeps resolving the
492// names that moved into submodules.
493#[cfg(test)]
494use raw::{
495 AlumetDatabaseSection, AlumetSection, CloudSection, ElectricityMapsSection, KeplerSection,
496 RedfishSection, ScaphandreSection, convert_alumet_section_with_env,
497 convert_cloud_section_with_env, convert_electricity_maps_section_with_env,
498 convert_kepler_section_with_env, convert_redfish_section_with_env,
499 convert_scaphandre_section_with_env,
500};
501#[cfg(test)]
502use toml_paths::{TOML_PATH_STRING_KEYS, find_basic_string_end};
503#[cfg(test)]
504use validate::validate_http_authority;
505
506/// Top-level TOML keys that perf-sentinel accepted in 0.5.x as legacy
507/// flat aliases for sectioned fields. Removed in 0.6.0; loading a config
508/// that still uses any of them returns
509/// [`ConfigError::Validation`] with the new section path so the operator
510/// can migrate without grep-around. Tuple is `(legacy_top_level_key,
511/// new_section_path)`. The list is intentionally exhaustive: a 0.5.x
512/// config that loads on 0.6.x without a clear error is the worst-case
513/// outcome we want to avoid.
514const REMOVED_LEGACY_TOP_LEVEL_KEYS: &[(&str, &str)] = &[
515 (
516 "n_plus_one_threshold",
517 "[detection] n_plus_one_min_occurrences",
518 ),
519 ("window_duration_ms", "[detection] window_duration_ms"),
520 ("listen_addr", "[daemon] listen_address"),
521 ("listen_port", "[daemon] listen_port_http"),
522 ("max_active_traces", "[daemon] max_active_traces"),
523 ("trace_ttl_ms", "[daemon] trace_ttl_ms"),
524 ("max_events_per_trace", "[daemon] max_events_per_trace"),
525 ("max_payload_size", "[daemon] max_payload_size"),
526];
527
528/// Reject 0.5.x legacy top-level keys with a migration hint.
529///
530/// Runs before the typed `RawConfig` parse: a typed parse with no
531/// `deny_unknown_fields` would silently drop these keys (operator never
532/// sees a warning, defaults silently apply). A typed parse WITH
533/// `deny_unknown_fields` would surface a serde error like "unknown field
534/// `listen_port`" without the migration path. The bespoke check below
535/// prints both pieces of information in one error.
536fn reject_legacy_top_level_keys(content: &str) -> Result<(), ConfigError> {
537 let value: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
538 let toml::Value::Table(table) = value else {
539 return Ok(());
540 };
541 for (legacy, replacement) in REMOVED_LEGACY_TOP_LEVEL_KEYS {
542 if table.contains_key(*legacy) {
543 return Err(ConfigError::Validation(format!(
544 "config: top-level '{legacy}' was removed in 0.6.0; \
545 use '{replacement}' instead. \
546 See the 0.6.0 migration notes for the full list of renamed keys."
547 )));
548 }
549 }
550 Ok(())
551}
552
553/// Load configuration from a TOML string.
554///
555/// Validates that all values are within acceptable bounds after parsing.
556///
557/// # Errors
558///
559/// Returns `ConfigError::Parse` if the TOML is malformed, or
560/// `ConfigError::Validation` if a field value is out of bounds, or if a
561/// 0.5.x legacy top-level key is present (see
562/// [`REMOVED_LEGACY_TOP_LEVEL_KEYS`]).
563pub fn load_from_str(content: &str) -> Result<Config, ConfigError> {
564 let normalized = normalize_toml_path_strings(content);
565 reject_legacy_top_level_keys(normalized.as_ref())?;
566 let raw: RawConfig = match toml::from_str(normalized.as_ref()) {
567 Ok(raw) => raw,
568 Err(norm_err) => {
569 if matches!(normalized, Cow::Owned(_)) {
570 // Path normalization fallback. See design doc 07 >
571 // "Windows path normalization" for the rationale.
572 tracing::debug!(
573 normalized_error = %norm_err,
574 "path normalization produced invalid TOML; retrying with original input"
575 );
576 toml::from_str(content).map_err(ConfigError::Parse)?
577 } else {
578 return Err(ConfigError::Parse(norm_err));
579 }
580 }
581 };
582 // Validate before the lossy `Config::from` conversion: a typo like
583 // `envrionment = "prod"` would otherwise silently downgrade to
584 // Staging instead of erroring.
585 if let Some(env_str) = raw.daemon.environment.as_deref()
586 && parse_daemon_environment(env_str).is_none()
587 {
588 return Err(ConfigError::Validation(format!(
589 "[daemon] environment '{env_str}' is invalid; \
590 expected 'staging' or 'production' (case-insensitive)"
591 )));
592 }
593 // Same pattern for `[green.kepler] metric_kind`: the From conversion
594 // would otherwise downgrade an invalid value to a tracing::error log
595 // and silently drop the whole section, which on a v0.7.4 → v0.7.5
596 // upgrade would translate an operator's `metric_kind = "process_package"`
597 // into a silent Kepler disable instead of the documented loud error.
598 parse_kepler_metric_kind(raw.green.kepler.metric_kind.as_deref())
599 .map_err(ConfigError::Validation)?;
600 // Same pattern for `[green.alumet]`: `metric_name` and `label_key`
601 // are mandatory once an endpoint is set and have no safe default,
602 // so a missing one must be a loud error rather than a silently
603 // dropped section.
604 validate_alumet_raw(&raw.green.alumet).map_err(ConfigError::Validation)?;
605 let config = Config::from(raw);
606 config.validate().map_err(ConfigError::Validation)?;
607 config.warn_listen_addr_if_non_loopback();
608 config.warn_reporting_advisory();
609 Ok(config)
610}
611
612/// Errors that can occur during configuration loading.
613///
614/// `#[non_exhaustive]` so that adding future variants (e.g. a new
615/// validation failure when a new config section lands) stays a
616/// SemVer-minor change.
617#[derive(Debug, thiserror::Error)]
618#[non_exhaustive]
619pub enum ConfigError {
620 /// TOML parsing error.
621 #[error("config parse error: {0}")]
622 Parse(#[from] toml::de::Error),
623 /// Validation error (out-of-range values).
624 #[error("config validation error: {0}")]
625 Validation(String),
626}
627
628#[cfg(test)]
629mod tests;