Skip to main content

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;