Skip to main content

kellnr_settings/
settings.rs

1use std::path::{Path, PathBuf};
2
3/// Error returned by settings construction. Aliases [`provcfg::Error`],
4/// every failure surfaces from there (source deserialization or
5/// config-file I/O), so kellnr-side code keeps a domain-flavoured name
6/// without inventing its own opaque box.
7pub use provcfg::Error as SettingsError;
8use provcfg::{ClapArgs, Configurable, Provenance};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12use crate::config_source::SourceMap;
13use crate::docs::{Docs, DocsArgs, DocsPartial, DocsProv};
14use crate::filter::{
15    CratesioFilter, CratesioFilterArgs, CratesioFilterPartial, CratesioFilterProv, RegistryFilter,
16    RegistryFilterArgs, RegistryFilterPartial, RegistryFilterProv,
17};
18use crate::gcs::{Gcs, GcsArgs, GcsPartial, GcsProv};
19use crate::local::{Local, LocalArgs, LocalPartial, LocalProv};
20use crate::log::{Log, LogArgs, LogPartial, LogProv};
21use crate::oauth2::{OAuth2, OAuth2Args, OAuth2Partial, OAuth2Prov};
22use crate::origin::{Origin, OriginArgs, OriginPartial, OriginProv};
23use crate::postgresql::{Postgresql, PostgresqlArgs, PostgresqlPartial, PostgresqlProv};
24use crate::proxy::{Proxy, ProxyArgs, ProxyPartial, ProxyProv};
25use crate::registry::{Registry, RegistryArgs, RegistryPartial, RegistryProv};
26use crate::s3::{S3, S3Args, S3Partial, S3Prov};
27use crate::setup::{Setup, SetupArgs, SetupPartial, SetupProv};
28use crate::toolchain::{Toolchain, ToolchainArgs, ToolchainPartial, ToolchainProv};
29use crate::webhooks::{Webhooks, WebhooksArgs, WebhooksPartial, WebhooksProv};
30
31/// Pure configuration values, every leaf carries the active value, no
32/// provenance attached. Provenance lives on the companion `SettingsProv`,
33/// which is built by [`build_prov_with_cli`] and stored in the app state for
34/// the one handler (`/settings`) that needs it.
35#[derive(
36    Debug, Default, Deserialize, Serialize, Eq, PartialEq, Clone, Configurable, ClapArgs, ToSchema,
37)]
38#[serde(default)]
39pub struct Settings {
40    #[configurable(nested)]
41    pub setup: Setup,
42    #[configurable(nested)]
43    pub registry: Registry,
44    #[configurable(nested)]
45    pub docs: Docs,
46    #[configurable(nested)]
47    pub proxy: Proxy,
48    #[configurable(nested)]
49    pub log: Log,
50    #[configurable(nested)]
51    pub local: Local,
52    #[configurable(nested)]
53    pub origin: Origin,
54    #[configurable(nested)]
55    pub postgresql: Postgresql,
56    #[configurable(nested)]
57    pub s3: S3,
58    #[configurable(nested)]
59    pub gcs: Gcs,
60    #[configurable(nested)]
61    pub oauth2: OAuth2,
62    #[configurable(nested)]
63    pub toolchain: Toolchain,
64    #[configurable(nested)]
65    pub webhooks: Webhooks,
66    #[configurable(nested)]
67    pub cratesio_filter: CratesioFilter,
68    #[configurable(nested)]
69    pub registry_filter: RegistryFilter,
70}
71
72/// Build a `SettingsProv` from the configured sources: optional TOML file,
73/// `KELLNR_*` environment variables, and optionally a CLI partial.
74///
75/// Source order matters, later sources override earlier ones (TOML < env < CLI).
76///
77/// Exposed publicly (in addition to being called by [`crate::cli::parse_cli`])
78/// so consumers like the `kellnr config show` printer can render per-leaf
79/// provenance directly without duplicating the source-stack wiring.
80pub fn build_prov_with_cli(
81    config_file: Option<&Path>,
82    cli_partial: Option<SettingsPartial>,
83) -> Result<SettingsProv, SettingsError> {
84    let mut cfg = provcfg::Config::new();
85    if let Some(path) = config_file {
86        cfg = cfg.add_toml_file(path)?;
87    }
88    cfg = cfg.add_env_with_list_keys("KELLNR", env_list_keys().iter().copied());
89    if let Some(partial) = cli_partial {
90        cfg = cfg.add_cli(partial);
91    }
92    cfg.build::<SettingsProv>()
93}
94
95/// The active settings of `prov`, normalized so that the rest of Kellnr can
96/// rely on the form of values like `origin.path`.
97pub fn settings_from_prov(prov: &SettingsProv) -> Settings {
98    let mut settings = Settings::from(prov);
99    settings.normalize();
100    settings
101}
102
103/// Dotted paths that the env source should treat as comma-separated lists.
104/// Matches the kellnr 6.x behaviour driven by the `config` crate's
105/// `with_list_parse_key`.
106pub(crate) fn env_list_keys() -> &'static [&'static str] {
107    &[
108        "registry.required_crate_fields",
109        "oauth2.scopes",
110        "oauth2.additional_audiences",
111        "docs.build_wrapper",
112        "cratesio_filter.license_allowed",
113        "cratesio_filter.license_exempt_crates",
114        "cratesio_filter.min_release_age_exempt_crates",
115        "registry_filter.license_allowed",
116        "registry_filter.license_exempt_crates",
117    ]
118}
119
120/// Convert provcfg's per-leaf provenance map into kellnr's `SourceMap`.
121/// Called by the `/settings` web-ui handler to build the API response.
122pub fn sources_from_prov(prov: &SettingsProv) -> SourceMap {
123    prov.sources_map()
124        .into_iter()
125        .map(|(key, category)| (key, category.into()))
126        .collect()
127}
128
129impl Settings {
130    /// Normalize values that can be written in several equivalent ways.
131    pub fn normalize(&mut self) {
132        self.origin.normalize();
133        self.proxy.normalize();
134    }
135
136    pub fn bin_path(&self) -> PathBuf {
137        PathBuf::from(&self.registry.data_dir).join("crates")
138    }
139
140    pub fn doc_queue_path(&self) -> PathBuf {
141        PathBuf::from(&self.registry.data_dir).join("doc_queue")
142    }
143
144    pub fn sqlite_path(&self) -> PathBuf {
145        PathBuf::from(&self.registry.data_dir).join("db.sqlite")
146    }
147
148    pub fn docs_path(&self) -> String {
149        format!("{}/docs", self.registry.data_dir)
150    }
151
152    pub fn base_path(&self) -> PathBuf {
153        PathBuf::from(&self.registry.data_dir).join("git")
154    }
155
156    pub fn crates_io_bin_path(&self) -> PathBuf {
157        PathBuf::from(&self.registry.data_dir).join("cratesio")
158    }
159
160    pub fn crates_io_path(&self) -> String {
161        format!("{}/cratesio", self.registry.data_dir)
162    }
163
164    pub fn crates_path(&self) -> String {
165        format!("{}/crates", self.registry.data_dir)
166    }
167
168    pub fn crates_path_or_bucket(&self) -> String {
169        if self.s3.enabled {
170            self.s3.crates_bucket.clone()
171        } else if self.gcs.enabled {
172            self.gcs.crates_bucket.clone()
173        } else {
174            self.crates_path()
175        }
176    }
177
178    pub fn crates_io_path_or_bucket(&self) -> String {
179        if self.s3.enabled {
180            self.s3.cratesio_bucket.clone()
181        } else if self.gcs.enabled {
182            self.gcs.cratesio_bucket.clone()
183        } else {
184            self.crates_io_path()
185        }
186    }
187
188    pub fn toolchain_path(&self) -> String {
189        format!("{}/toolchains", self.registry.data_dir)
190    }
191
192    pub fn toolchain_path_or_bucket(&self) -> String {
193        if self.s3.enabled {
194            self.s3.toolchain_bucket.clone()
195        } else if self.gcs.enabled {
196            self.gcs.toolchain_bucket.clone()
197        } else {
198            self.toolchain_path()
199        }
200    }
201
202    /// `false` once an operator has opted into `s3.docs_bucket`/`gcs.docs_bucket`
203    /// (see `init_docs_storage`) — unlike the other `*_path_or_bucket` methods,
204    /// docs stay on local disk by default even when `s3.enabled`/`gcs.enabled`
205    /// is set, so this can't be derived from `enabled` alone.
206    pub fn docs_backend_is_local(&self) -> bool {
207        !(self.s3.enabled && self.s3.docs_bucket.is_some()
208            || self.gcs.enabled && self.gcs.docs_bucket.is_some())
209    }
210}
211
212/// Default settings for tests. The data directory is a path that cannot be
213/// created, so a test that writes files fails instead of sharing a directory
214/// with other tests. Such tests use [`test_settings_in`] with a temporary
215/// directory.
216#[cfg(any(test, feature = "test-support"))]
217pub fn test_settings() -> Settings {
218    test_settings_in(Path::new("/dev/null/kellnr-test"))
219}
220
221/// Default settings for tests with `data_dir` as the data directory.
222#[cfg(any(test, feature = "test-support"))]
223pub fn test_settings_in(data_dir: &Path) -> Settings {
224    Settings {
225        registry: Registry {
226            data_dir: data_dir.to_string_lossy().into_owned(),
227            ..Registry::default()
228        },
229        ..Settings::default()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use provcfg::Config;
236
237    use super::*;
238    use crate::config_source::ConfigSource;
239
240    #[test]
241    fn sources_from_prov_with_no_sources_marks_every_leaf_default() {
242        let prov = Config::new().build::<SettingsProv>().unwrap();
243        let map = sources_from_prov(&prov);
244
245        // Spot-check a flat leaf and a nested one, every entry must be Default.
246        assert_eq!(map.get("registry.data_dir"), Some(&ConfigSource::Default));
247        assert_eq!(map.get("docs.enabled"), Some(&ConfigSource::Default));
248        assert_eq!(map.get("s3.enabled"), Some(&ConfigSource::Default));
249        assert!(
250            map.values().all(|s| *s == ConfigSource::Default),
251            "every entry must report Default when no sources were added"
252        );
253    }
254
255    #[test]
256    fn sources_from_prov_attributes_toml_leaves_to_toml_source() {
257        let prov = Config::new()
258            .add_toml_str(
259                "test.toml",
260                "[registry]\ndata_dir = \"/tmp/x\"\n[docs]\nenabled = true\n",
261            )
262            .build::<SettingsProv>()
263            .unwrap();
264        let map = sources_from_prov(&prov);
265
266        assert_eq!(map.get("registry.data_dir"), Some(&ConfigSource::Toml));
267        assert_eq!(map.get("docs.enabled"), Some(&ConfigSource::Toml));
268        // Untouched leaves keep Default, File → Toml mapping must not bleed
269        // across leaves that the TOML source didn't supply.
270        assert_eq!(map.get("registry.cache_size"), Some(&ConfigSource::Default));
271        assert_eq!(map.get("s3.enabled"), Some(&ConfigSource::Default));
272    }
273
274    #[test]
275    fn settings_from_prov_normalizes_origin_path() {
276        let prov = Config::new()
277            .add_toml_str("test.toml", "[origin]\npath = \"registry/\"\n")
278            .build::<SettingsProv>()
279            .unwrap();
280
281        assert_eq!(settings_from_prov(&prov).origin.path, "/registry");
282    }
283
284    #[test]
285    fn settings_from_prov_normalizes_proxy_urls() {
286        let prov = Config::new()
287            .add_toml_str(
288                "test.toml",
289                "[proxy]\nurl = \"https://mirror.example.com/crates\"\n",
290            )
291            .build::<SettingsProv>()
292            .unwrap();
293
294        assert_eq!(
295            settings_from_prov(&prov).proxy.url.as_str(),
296            "https://mirror.example.com/crates/"
297        );
298    }
299}