Skip to main content

cairn_mod/
config.rs

1//! Layered config loading.
2//!
3//! Precedence (lowest → highest): compiled-in defaults, then TOML file,
4//! then environment variables prefixed `CAIRN_`. CLI-flag overrides land
5//! once the clap surface grows subcommands.
6//!
7//! Per §5.1, signing-key private material is NEVER sourced through this
8//! path. `signing_key_path` points at a file; the bytes are read only by
9//! [`crate::signing_key::SigningKey::load_from_file`] with explicit
10//! permission, ownership, and env-var-reject checks.
11
12use figment::{
13    Figment,
14    providers::{Env, Format, Toml},
15};
16use serde::Deserialize;
17use std::collections::BTreeMap;
18use std::net::SocketAddr;
19use std::path::PathBuf;
20
21use crate::error::Result;
22
23/// Default bind address when `bind_addr` is absent from config.
24/// §F13: "expects reverse proxy for TLS." Loopback-only by default;
25/// operators knowingly opt into `0.0.0.0:3000` when they run without
26/// a reverse proxy on the same host.
27pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
28
29/// Top-level Cairn configuration.
30///
31/// Fields grow with features; the struct is `non_exhaustive` so additions
32/// are not a breaking change for downstream crates.
33#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub struct Config {
36    /// The service DID Cairn runs as (§5.1).
37    pub service_did: String,
38    /// Publicly-reachable base URL where this Cairn instance serves HTTP
39    /// and WebSocket endpoints (e.g., `https://labeler.example`). Emitted
40    /// as the `serviceEndpoint` value in the `AtprotoLabeler` entry of
41    /// `/.well-known/did.json` so consumers can discover where to call.
42    ///
43    /// This is distinct from [`Self::bind_addr`] — typical production
44    /// deployments bind `127.0.0.1:3000` behind a reverse proxy but
45    /// advertise the public `https://labeler.example` URL here.
46    /// Validated at load time as a URL.
47    pub service_endpoint: String,
48    /// Where `cairn serve` binds its HTTP listener. Defaults to
49    /// [`DEFAULT_BIND_ADDR`] (`127.0.0.1:3000`) if omitted.
50    #[serde(default = "default_bind_addr")]
51    pub bind_addr: SocketAddr,
52    /// SQLite database file. Parent directory must exist; the file
53    /// itself is created on first run by
54    /// [`crate::storage::open`] alongside embedded migrations.
55    pub db_path: PathBuf,
56    /// Signing key file (§5.1). Mode 0600, owned by the running user,
57    /// hex-encoded 32-byte secp256k1 private key. Env-var delivery of
58    /// the key material is explicitly rejected — see
59    /// [`crate::signing_key::SIGNING_KEY_ENV_REJECTED`].
60    pub signing_key_path: PathBuf,
61    /// Admin-endpoint policy (§F12). Defaults to an empty table,
62    /// meaning `admin.applyLabel` accepts any label value ≤128 bytes
63    /// (matches the existing [`crate::AdminConfig`] default).
64    #[serde(default)]
65    pub admin: AdminConfigToml,
66    /// Labeler policy (§F1) — the `app.bsky.labeler.service` record
67    /// content. Required for `cairn publish-service-record`; other
68    /// subcommands don't consume it, so it's optional at load time.
69    /// When absent, `publish-service-record` surfaces a clear error.
70    #[serde(default)]
71    pub labeler: Option<LabelerConfigToml>,
72    /// Operator PDS auth surface (§F1 service record publishing).
73    /// The operator's identity is the DID that OWNS the labeler
74    /// account — distinct from moderators who authenticate to Cairn
75    /// (§5.2) and distinct from Cairn's own signing key (§5.1).
76    #[serde(default)]
77    pub operator: Option<OperatorConfigToml>,
78    /// Retention sweep execution policy (§F4 sweep task). Holds
79    /// schedule + batching knobs only — the cutoff itself
80    /// (`retention_days`) is owned by [`crate::SubscribeConfig`] so
81    /// the read-side floor and the sweep cutoff stay tied to a
82    /// single source of truth. Defaults match §F4 prose: enabled,
83    /// 04:00 UTC, 1000-row batches.
84    #[serde(default)]
85    pub retention: RetentionConfigToml,
86    /// Reason vocabulary for the v1.4 graduated-action moderation
87    /// model (§F20, #47). TOML projection of the operator's
88    /// `[moderation_reasons.<identifier>]` blocks; resolve to a
89    /// runtime [`crate::moderation::reasons::ReasonVocabulary`] via
90    /// `ReasonVocabulary::from_config`.
91    ///
92    /// Three states:
93    /// - `None` — operator declared no blocks; the resolver loads
94    ///   shipped defaults (eight reasons covering common categories).
95    /// - `Some(empty)` — operator wrote a bare `[moderation_reasons]`
96    ///   header with no sub-blocks; rejected at validate time as a
97    ///   probable typo.
98    /// - `Some(non_empty)` — operator's vocabulary is the complete
99    ///   set; defaults are NOT merged in.
100    #[serde(default)]
101    pub moderation_reasons: Option<BTreeMap<String, ReasonDefToml>>,
102    /// Strike policy for the v1.4 graduated-action moderation
103    /// model (§F20, #48). TOML projection of the operator's
104    /// `[strike_policy]` block; resolve to a runtime
105    /// [`crate::moderation::policy::StrikePolicy`] via
106    /// `StrikePolicy::from_config`.
107    ///
108    /// Two states:
109    /// - `None` — operator declared no block; the resolver returns
110    ///   shipped defaults (threshold 3, curve [1, 2], linear decay
111    ///   over 90 days, suspensions freeze decay).
112    /// - `Some(_)` — partial or full operator declaration. Per-field
113    ///   serde defaults fill any unspecified sub-fields; the
114    ///   resolved values are validated together (curve length
115    ///   convention, strict-ascending curve, positive decay window).
116    #[serde(default)]
117    pub strike_policy: Option<StrikePolicyToml>,
118    /// Label-emission policy for the v1.5 graduated-action moderation
119    /// model (§F21, #58). TOML projection of the operator's
120    /// `[label_emission]` block; resolve to a runtime
121    /// [`crate::labels::policy::LabelEmissionPolicy`] via
122    /// `LabelEmissionPolicy::from_config`.
123    ///
124    /// Two states:
125    /// - `None` — operator declared no block; the resolver returns
126    ///   shipped defaults (emission enabled, reason labels emitted,
127    ///   warnings not emitted, default per-action mappings —
128    ///   `!takedown`, `!hide`, `!warn`).
129    /// - `Some(_)` — partial or full operator declaration. Per-field
130    ///   serde defaults fill any unspecified sub-fields; the
131    ///   resolved values are validated together (label-value naming
132    ///   conventions, no-collision across action_type overrides,
133    ///   valid action_type keys in override maps).
134    #[serde(default)]
135    pub label_emission: Option<LabelEmissionPolicyToml>,
136}
137
138/// TOML projection of one entry in
139/// [`Config::moderation_reasons`]. The runtime equivalent
140/// (with validated identifier) is
141/// [`crate::moderation::reasons::ReasonDef`].
142#[derive(Debug, Clone, Deserialize)]
143pub struct ReasonDefToml {
144    /// Strike weight applied at action time, before dampening.
145    /// Validated at config-load time as `>= 1`.
146    pub base_weight: u32,
147    /// When `true`, this reason bypasses the dampening curve
148    /// regardless of the subject's standing. Defaults to `false`
149    /// when omitted from the TOML.
150    #[serde(default)]
151    pub severe: bool,
152    /// Operator-facing label describing what the reason means.
153    /// Required and non-empty; validated at config-load time.
154    pub description: String,
155}
156
157/// TOML projection of [`crate::moderation::policy::StrikePolicy`].
158/// Each sub-field carries a `#[serde(default = "...")]` so a partial
159/// `[strike_policy]` declaration fills the rest from the shipped
160/// defaults. The fully-defaulted struct matches
161/// [`crate::moderation::policy::StrikePolicy::defaults`].
162#[derive(Debug, Clone, Deserialize)]
163pub struct StrikePolicyToml {
164    /// Strike count at or below which a subject is in good standing.
165    /// Default 3. Validated together with `dampening_curve` —
166    /// curve length must equal `max(0, threshold - 1)`. See
167    /// [`crate::moderation::policy`] module docs for the worked
168    /// example.
169    #[serde(default = "default_good_standing_threshold")]
170    pub good_standing_threshold: u32,
171    /// Per-position dampening weights for in-good-standing offenses.
172    /// Default `[1, 2]`. Validated as strictly ascending with each
173    /// entry `>= 1`; length tied to `good_standing_threshold`.
174    #[serde(default = "default_dampening_curve")]
175    pub dampening_curve: Vec<u32>,
176    /// Decay shape applied to each action's strike contribution as
177    /// time passes. Default [`crate::moderation::policy::DecayFunction::Linear`].
178    /// Unknown variants fail at deserialize time.
179    #[serde(default = "default_decay_function")]
180    pub decay_function: crate::moderation::policy::DecayFunction,
181    /// Window over which `decay_function` operates, in days. Default
182    /// 90. Validated as `>= 1`; no upper bound.
183    #[serde(default = "default_decay_window_days")]
184    pub decay_window_days: u32,
185    /// When `true`, decay halts while the subject has an active
186    /// `indef_suspension` action. Default `true`.
187    #[serde(default = "default_suspension_freezes_decay")]
188    pub suspension_freezes_decay: bool,
189    /// How long the `subject_strike_state` cache row remains
190    /// "fresh" before a reader should recompute via the decay
191    /// calculator (#55). Default 3600 (1 hour). Validated `>= 1`.
192    /// Has no effect on v1.4 read endpoints, which always
193    /// recompute from source-of-truth regardless; ships ahead of
194    /// v1.5+ consumers that may read the cache directly.
195    #[serde(default = "default_cache_freshness_window_seconds")]
196    pub cache_freshness_window_seconds: u32,
197}
198
199impl Default for StrikePolicyToml {
200    fn default() -> Self {
201        Self {
202            good_standing_threshold: default_good_standing_threshold(),
203            dampening_curve: default_dampening_curve(),
204            decay_function: default_decay_function(),
205            decay_window_days: default_decay_window_days(),
206            suspension_freezes_decay: default_suspension_freezes_decay(),
207            cache_freshness_window_seconds: default_cache_freshness_window_seconds(),
208        }
209    }
210}
211
212fn default_good_standing_threshold() -> u32 {
213    3
214}
215fn default_dampening_curve() -> Vec<u32> {
216    vec![1, 2]
217}
218fn default_decay_function() -> crate::moderation::policy::DecayFunction {
219    crate::moderation::policy::DecayFunction::Linear
220}
221fn default_decay_window_days() -> u32 {
222    90
223}
224fn default_suspension_freezes_decay() -> bool {
225    true
226}
227fn default_cache_freshness_window_seconds() -> u32 {
228    3600
229}
230
231/// TOML projection of [`crate::labels::policy::LabelEmissionPolicy`]
232/// (§F21, #58). Each sub-field carries a `#[serde(default = "...")]`
233/// so a partial `[label_emission]` declaration fills the rest from
234/// shipped defaults. The fully-defaulted struct matches
235/// [`crate::labels::policy::LabelEmissionPolicy::defaults`].
236///
237/// `action_label_overrides` keys are action-type strings
238/// (`takedown`, `temp_suspension`, etc.) parsed at projection time
239/// against [`crate::moderation::types::ActionType::from_db_str`];
240/// invalid keys fail config load with a clear error rather than
241/// silently mapping to nothing.
242#[derive(Debug, Clone, Default, Deserialize)]
243pub struct LabelEmissionPolicyToml {
244    /// Master toggle. When `false`, no labels are emitted regardless
245    /// of other policy fields. Default `true`.
246    #[serde(default = "default_label_emission_enabled")]
247    pub enabled: bool,
248    /// Whether to emit `reason-<code>` labels alongside the action
249    /// label. Default `true`.
250    #[serde(default = "default_emit_reason_labels")]
251    pub emit_reason_labels: bool,
252    /// Whether `warning` actions emit a label. Default `false` —
253    /// warnings are typically advisory and don't surface to AppViews.
254    /// Operators that want to surface warnings (e.g., a "first
255    /// strike" community signal) flip this to `true`.
256    #[serde(default = "default_warning_emits_label")]
257    pub warning_emits_label: bool,
258    /// Prefix prepended to each `reason_code` to form the reason
259    /// label's `val`. Default `"reason-"` so e.g. `hate-speech`
260    /// becomes `reason-hate-speech`. Empty string permitted but
261    /// surfaces a startup warning.
262    #[serde(default = "default_reason_label_prefix")]
263    pub reason_label_prefix: String,
264    /// Per-action-type label override. Keys are action-type strings
265    /// (`takedown`, `temp_suspension`, etc.); values are full
266    /// [`LabelSpecToml`] declarations that replace the shipped
267    /// defaults for that action type.
268    #[serde(default)]
269    pub action_label_overrides: BTreeMap<String, LabelSpecToml>,
270    /// Per-action-type severity override. Keys are action-type
271    /// strings; values are severities. Use this when only the
272    /// severity needs adjustment without changing the label `val`.
273    /// Ignored for action types that have a full
274    /// [`LabelSpecToml`] in `action_label_overrides` — the explicit
275    /// override wins.
276    #[serde(default)]
277    pub severity_overrides: BTreeMap<String, SeverityToml>,
278}
279
280/// TOML projection of one entry in
281/// [`LabelEmissionPolicyToml::action_label_overrides`] (§F21, #58).
282/// Same field shape as [`LabelValueDefinitionToml`] minus the
283/// `identifier` (which is the action-type key in the parent map).
284#[derive(Debug, Clone, Deserialize)]
285pub struct LabelSpecToml {
286    /// The label `val` emitted for this action type. Validated as
287    /// 1..=128 bytes (§6.4 schema CHECK), lowercase ASCII letters +
288    /// digits + `-`, optionally prefixed with `!` for ATProto global
289    /// label values.
290    pub val: String,
291    /// Severity hint for consumer AppViews. Default
292    /// [`SeverityToml::Alert`].
293    #[serde(default = "default_label_spec_severity")]
294    pub severity: SeverityToml,
295    /// Optional blur policy. `None` means no blur signal — the
296    /// label semantics are entirely consumer-determined. Most
297    /// graduated-action labels do not specify blurs since they
298    /// represent account-level state, not content-level visual
299    /// treatment.
300    #[serde(default)]
301    pub blurs: Option<BlursToml>,
302    /// Optional localized display strings. Mirrors §6.4 `locales`
303    /// on label-value definitions; an emission-time policy can
304    /// declare them so a future #56-style trust-chain consumer
305    /// can present labels with operator-supplied display strings.
306    /// Empty by default.
307    #[serde(default)]
308    pub locales: Vec<LocaleToml>,
309}
310
311fn default_label_emission_enabled() -> bool {
312    true
313}
314fn default_emit_reason_labels() -> bool {
315    true
316}
317fn default_warning_emits_label() -> bool {
318    false
319}
320fn default_reason_label_prefix() -> String {
321    "reason-".to_string()
322}
323fn default_label_spec_severity() -> SeverityToml {
324    SeverityToml::Alert
325}
326
327/// TOML projection of [`crate::AdminConfig`]. Separate from the runtime
328/// type because (a) `AdminConfig` is constructed from a vector of owned
329/// strings and doesn't itself derive `Deserialize`, (b) keeping the
330/// wire shape here avoids coupling the server module to figment/serde.
331#[derive(Debug, Clone, Default, Deserialize)]
332pub struct AdminConfigToml {
333    /// Operator-declared label values. When `Some`, `applyLabel` only
334    /// accepts values in this set. When absent, any val ≤128 bytes is
335    /// accepted.
336    #[serde(default)]
337    pub label_values: Option<Vec<String>>,
338}
339
340/// Labeler policy (§F1, §6.4) — the content of the
341/// `app.bsky.labeler.service` record that `cairn publish-service-record`
342/// emits to the operator's PDS. Field-name mapping to the wire shape is
343/// via `#[serde(rename)]` at the runtime-side boundary in
344/// [`crate::service_record`]; TOML stays snake_case for operator ergonomics.
345#[derive(Debug, Clone, serde::Serialize, Deserialize)]
346pub struct LabelerConfigToml {
347    /// Short-name list of labels this instance will emit (§6.4
348    /// `policies.labelValues`). Every value here must also have a
349    /// matching definition in `label_value_definitions` unless it's a
350    /// global well-known value (§6.5). Publishing rejects if a non-
351    /// global identifier has no definition.
352    pub label_values: Vec<String>,
353    /// Per-label metadata entries (§6.4
354    /// `policies.labelValueDefinitions`). Empty vec is legal — means
355    /// only global values, no custom definitions.
356    #[serde(default)]
357    pub label_value_definitions: Vec<LabelValueDefinitionToml>,
358    /// Optional §6.4 `reasonTypes`. Typically the
359    /// `com.atproto.moderation.defs#reason*` set matching createReport
360    /// (§F11).
361    #[serde(default)]
362    pub reason_types: Vec<String>,
363    /// Optional §6.4 `subjectTypes` (e.g. `["account", "record"]`).
364    #[serde(default)]
365    pub subject_types: Vec<String>,
366    /// Optional §6.4 `subjectCollections` (e.g.
367    /// `["app.bsky.feed.post"]`).
368    #[serde(default)]
369    pub subject_collections: Vec<String>,
370}
371
372/// Single entry in `labelValueDefinitions`. §6.4 constraints:
373/// `severity` + `blurs` + `locales` required; `locales` must be
374/// non-empty; `default_setting` + `adult_only` optional.
375#[derive(Debug, Clone, serde::Serialize, Deserialize)]
376pub struct LabelValueDefinitionToml {
377    /// The label value this definition describes (matches an entry
378    /// in [`LabelerConfigToml::label_values`]).
379    pub identifier: String,
380    /// §6.4 severity — how consumers should weight this label.
381    pub severity: SeverityToml,
382    /// §6.4 blur policy — whether consumer UIs should obscure
383    /// content or media on a match.
384    pub blurs: BlursToml,
385    /// Optional §6.4 default consumer-side setting. Omit to let
386    /// consumers pick their own default.
387    #[serde(default)]
388    pub default_setting: Option<DefaultSettingToml>,
389    /// Optional §6.4 flag marking the label as 18+ only.
390    #[serde(default)]
391    pub adult_only: Option<bool>,
392    /// Non-empty list of localized display strings (§6.4 requires
393    /// ≥1 locale per definition).
394    pub locales: Vec<LocaleToml>,
395}
396
397/// §6.4 severity enum.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
399#[serde(rename_all = "lowercase")]
400pub enum SeverityToml {
401    /// Informational; consumers typically don't gate on this.
402    Inform,
403    /// Alert the viewer; consumers generally surface a warning.
404    Alert,
405    /// No severity signal.
406    None,
407}
408
409/// §6.4 blur policy — what consumer UIs should obscure on a match.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
411#[serde(rename_all = "lowercase")]
412pub enum BlursToml {
413    /// Blur the post / record body.
414    Content,
415    /// Blur embedded media only.
416    Media,
417    /// No blurring.
418    None,
419}
420
421/// §6.4 default consumer-side setting.
422#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
423#[serde(rename_all = "lowercase")]
424pub enum DefaultSettingToml {
425    /// Consumers default to showing the content with no treatment.
426    Ignore,
427    /// Consumers default to surfacing a warning.
428    Warn,
429    /// Consumers default to hiding the content.
430    Hide,
431}
432
433/// Localized display strings for a label value definition (§6.4).
434#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Deserialize)]
435pub struct LocaleToml {
436    /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
437    pub lang: String,
438    /// Short display name shown in consumer UIs.
439    pub name: String,
440    /// Longer explanation, typically shown on tooltip / expand.
441    pub description: String,
442}
443
444/// TOML projection of [`crate::RetentionConfig`]. Field names match
445/// the runtime struct one-for-one; serde defaults mirror
446/// [`crate::RetentionConfig::default()`] so an absent `[retention]`
447/// block produces the §F4 default policy without operator opt-in.
448///
449/// `retention_days` is *not* on this struct — it lives under
450/// [`crate::SubscribeConfig`] and is consumed by both the read-side
451/// floor (`query_oldest_retained`) and the sweep cutoff. Splitting
452/// it would risk drift between the floor and the sweep window.
453#[derive(Debug, Clone, Deserialize)]
454pub struct RetentionConfigToml {
455    /// Master toggle for the scheduled sweep. Default `true`.
456    #[serde(default = "default_sweep_enabled")]
457    pub sweep_enabled: bool,
458    /// UTC hour-of-day (0..=23) for the scheduled sweep. Default 4.
459    /// Validated by [`Config::validate`].
460    #[serde(default = "default_sweep_run_at_utc_hour")]
461    pub sweep_run_at_utc_hour: u8,
462    /// Rows per DELETE transaction. Default 1000.
463    #[serde(default = "default_sweep_batch_size")]
464    pub sweep_batch_size: i64,
465}
466
467impl Default for RetentionConfigToml {
468    fn default() -> Self {
469        Self {
470            sweep_enabled: default_sweep_enabled(),
471            sweep_run_at_utc_hour: default_sweep_run_at_utc_hour(),
472            sweep_batch_size: default_sweep_batch_size(),
473        }
474    }
475}
476
477fn default_sweep_enabled() -> bool {
478    true
479}
480fn default_sweep_run_at_utc_hour() -> u8 {
481    4
482}
483fn default_sweep_batch_size() -> i64 {
484    1000
485}
486
487/// Operator-side PDS auth config (§F1). Scope is narrow — just the
488/// PDS URL + session file path. Named `[operator]` today; if future
489/// operator-identity fields land (contact, alerts, etc.) the table
490/// stays small enough to nest those in, or split to `[operator.pds]`
491/// then.
492#[derive(Debug, Clone, Deserialize)]
493pub struct OperatorConfigToml {
494    /// PDS base URL (e.g. `https://bsky.social`). No default — the
495    /// labeler owner's PDS varies per deployment.
496    pub pds_url: String,
497    /// On-disk path for the operator session file. Written by
498    /// `cairn operator-login`, read by `cairn publish-service-record`.
499    /// Same §5.3 invariants as the moderator session file (mode 0600,
500    /// owned by running user) via the shared
501    /// `crate::credential_file` helper.
502    pub session_path: std::path::PathBuf,
503}
504
505fn default_bind_addr() -> SocketAddr {
506    DEFAULT_BIND_ADDR
507        .parse()
508        .expect("DEFAULT_BIND_ADDR is a valid socket address")
509}
510
511impl Config {
512    /// Post-load validation run by [`Config::load`]. Exposed so call
513    /// sites that construct a `Config` directly (e.g., tests) can
514    /// share the same rule set.
515    pub fn validate(&self) -> Result<()> {
516        url::Url::parse(&self.service_endpoint).map_err(|e| {
517            crate::error::Error::Signing(format!("config.service_endpoint is not a valid URL: {e}"))
518        })?;
519        if self.retention.sweep_run_at_utc_hour >= 24 {
520            return Err(crate::error::Error::Signing(format!(
521                "config.retention.sweep_run_at_utc_hour={} is out of range (0..=23)",
522                self.retention.sweep_run_at_utc_hour
523            )));
524        }
525        if self.retention.sweep_batch_size <= 0 {
526            return Err(crate::error::Error::Signing(format!(
527                "config.retention.sweep_batch_size={} must be > 0",
528                self.retention.sweep_batch_size
529            )));
530        }
531        // Reason vocabulary (§F20, #47): build via the canonical
532        // resolver and discard. If the operator declared an empty
533        // [moderation_reasons] section, an invalid identifier, a
534        // zero base_weight, or an empty description, the resolver
535        // returns an Error::Signing here that surfaces as a
536        // config-load failure at startup.
537        let _ = crate::moderation::reasons::ReasonVocabulary::from_config(self)?;
538        // Strike policy (§F20, #48): same single-source-of-truth
539        // pattern. The resolver applies the curve-length convention
540        // (`max(0, threshold - 1)`), strict-ascending check, and
541        // decay-window-days >= 1 check. See
542        // [`crate::moderation::policy`] for the full validation
543        // rules.
544        let _ = crate::moderation::policy::StrikePolicy::from_config(self)?;
545        // Label emission policy (§F21, #58): same single-source-of-
546        // truth pattern. The resolver applies the label-value naming
547        // conventions, no-collision check across action_label_overrides
548        // entries, and valid-action_type-key check on the override
549        // maps. See [`crate::labels::policy`] for the full validation
550        // rules.
551        let _ = crate::labels::policy::LabelEmissionPolicy::from_config(self)?;
552        // Path existence of db_path / signing_key_path is checked at
553        // use time by storage::open and SigningKey::load_from_file —
554        // duplicating here would just double-fail and lose the
555        // specific cause.
556        Ok(())
557    }
558
559    /// Load configuration from the default TOML location + env
560    /// overrides (see [`Self::load_from`] for the full precedence
561    /// rules). The default location is `CAIRN_CONFIG` env var, or
562    /// `/etc/cairn/cairn.toml` if unset.
563    pub fn load() -> Result<Self> {
564        let toml_path: PathBuf = std::env::var_os("CAIRN_CONFIG")
565            .map(PathBuf::from)
566            .unwrap_or_else(|| PathBuf::from("/etc/cairn/cairn.toml"));
567        Self::load_from(Some(&toml_path))
568    }
569
570    /// Load configuration with an explicit TOML path (or `None` to
571    /// skip the file layer entirely and rely on env overrides).
572    ///
573    /// Sources, low to high precedence:
574    /// 1. Compiled-in defaults (`bind_addr` if unset, empty admin
575    ///    table).
576    /// 2. `toml_path` if `Some` and the file exists.
577    /// 3. Environment variables prefixed `CAIRN_`
578    ///    (e.g. `CAIRN_SERVICE_DID`).
579    ///
580    /// `cairn serve --config <path>` routes through this without
581    /// mutating process env (which is `unsafe` under Rust 2024 and
582    /// blocked by the crate's `#![forbid(unsafe_code)]`).
583    pub fn load_from(toml_path: Option<&std::path::Path>) -> Result<Self> {
584        let mut fig = Figment::new();
585        if let Some(p) = toml_path
586            && p.is_file()
587        {
588            fig = fig.merge(Toml::file(p));
589        }
590        fig = fig.merge(Env::prefixed("CAIRN_"));
591
592        let cfg: Config = fig.extract()?;
593        cfg.validate()?;
594        Ok(cfg)
595    }
596}
597
598impl From<AdminConfigToml> for crate::AdminConfig {
599    fn from(t: AdminConfigToml) -> Self {
600        // service_did / service_endpoint / declared_label_values are
601        // populated separately at admin_router-construction time
602        // (see `serve::run`) — they live elsewhere in `Config` and
603        // would needlessly couple [admin] to those fields if pulled
604        // through here.
605        crate::AdminConfig {
606            label_values: t.label_values,
607            ..Default::default()
608        }
609    }
610}
611
612impl From<RetentionConfigToml> for crate::RetentionConfig {
613    fn from(t: RetentionConfigToml) -> Self {
614        crate::RetentionConfig {
615            sweep_enabled: t.sweep_enabled,
616            sweep_run_at_utc_hour: t.sweep_run_at_utc_hour,
617            sweep_batch_size: t.sweep_batch_size,
618        }
619    }
620}