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        "registry_filter.license_allowed",
115        "registry_filter.license_exempt_crates",
116    ]
117}
118
119/// Convert provcfg's per-leaf provenance map into kellnr's `SourceMap`.
120/// Called by the `/settings` web-ui handler to build the API response.
121pub fn sources_from_prov(prov: &SettingsProv) -> SourceMap {
122    prov.sources_map()
123        .into_iter()
124        .map(|(key, category)| (key, category.into()))
125        .collect()
126}
127
128impl Settings {
129    /// Normalize values that can be written in several equivalent ways.
130    pub fn normalize(&mut self) {
131        self.origin.normalize();
132        self.proxy.normalize();
133    }
134
135    pub fn bin_path(&self) -> PathBuf {
136        PathBuf::from(&self.registry.data_dir).join("crates")
137    }
138
139    pub fn doc_queue_path(&self) -> PathBuf {
140        PathBuf::from(&self.registry.data_dir).join("doc_queue")
141    }
142
143    pub fn sqlite_path(&self) -> PathBuf {
144        PathBuf::from(&self.registry.data_dir).join("db.sqlite")
145    }
146
147    pub fn docs_path(&self) -> String {
148        format!("{}/docs", self.registry.data_dir)
149    }
150
151    pub fn base_path(&self) -> PathBuf {
152        PathBuf::from(&self.registry.data_dir).join("git")
153    }
154
155    pub fn crates_io_bin_path(&self) -> PathBuf {
156        PathBuf::from(&self.registry.data_dir).join("cratesio")
157    }
158
159    pub fn crates_io_path(&self) -> String {
160        format!("{}/cratesio", self.registry.data_dir)
161    }
162
163    pub fn crates_path(&self) -> String {
164        format!("{}/crates", self.registry.data_dir)
165    }
166
167    pub fn crates_path_or_bucket(&self) -> String {
168        if self.s3.enabled {
169            self.s3.crates_bucket.clone()
170        } else if self.gcs.enabled {
171            self.gcs.crates_bucket.clone()
172        } else {
173            self.crates_path()
174        }
175    }
176
177    pub fn crates_io_path_or_bucket(&self) -> String {
178        if self.s3.enabled {
179            self.s3.cratesio_bucket.clone()
180        } else if self.gcs.enabled {
181            self.gcs.cratesio_bucket.clone()
182        } else {
183            self.crates_io_path()
184        }
185    }
186
187    pub fn toolchain_path(&self) -> String {
188        format!("{}/toolchains", self.registry.data_dir)
189    }
190
191    pub fn toolchain_path_or_bucket(&self) -> String {
192        if self.s3.enabled {
193            self.s3.toolchain_bucket.clone()
194        } else if self.gcs.enabled {
195            self.gcs.toolchain_bucket.clone()
196        } else {
197            self.toolchain_path()
198        }
199    }
200
201    /// `false` once an operator has opted into `s3.docs_bucket`/`gcs.docs_bucket`
202    /// (see `init_docs_storage`) — unlike the other `*_path_or_bucket` methods,
203    /// docs stay on local disk by default even when `s3.enabled`/`gcs.enabled`
204    /// is set, so this can't be derived from `enabled` alone.
205    pub fn docs_backend_is_local(&self) -> bool {
206        !(self.s3.enabled && self.s3.docs_bucket.is_some()
207            || self.gcs.enabled && self.gcs.docs_bucket.is_some())
208    }
209}
210
211/// Default settings for tests. The data directory is a path that cannot be
212/// created, so a test that writes files fails instead of sharing a directory
213/// with other tests. Such tests use [`test_settings_in`] with a temporary
214/// directory.
215#[cfg(any(test, feature = "test-support"))]
216pub fn test_settings() -> Settings {
217    test_settings_in(Path::new("/dev/null/kellnr-test"))
218}
219
220/// Default settings for tests with `data_dir` as the data directory.
221#[cfg(any(test, feature = "test-support"))]
222pub fn test_settings_in(data_dir: &Path) -> Settings {
223    Settings {
224        registry: Registry {
225            data_dir: data_dir.to_string_lossy().into_owned(),
226            ..Registry::default()
227        },
228        ..Settings::default()
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use provcfg::Config;
235
236    use super::*;
237    use crate::config_source::ConfigSource;
238
239    #[test]
240    fn sources_from_prov_with_no_sources_marks_every_leaf_default() {
241        let prov = Config::new().build::<SettingsProv>().unwrap();
242        let map = sources_from_prov(&prov);
243
244        // Spot-check a flat leaf and a nested one, every entry must be Default.
245        assert_eq!(map.get("registry.data_dir"), Some(&ConfigSource::Default));
246        assert_eq!(map.get("docs.enabled"), Some(&ConfigSource::Default));
247        assert_eq!(map.get("s3.enabled"), Some(&ConfigSource::Default));
248        assert!(
249            map.values().all(|s| *s == ConfigSource::Default),
250            "every entry must report Default when no sources were added"
251        );
252    }
253
254    #[test]
255    fn sources_from_prov_attributes_toml_leaves_to_toml_source() {
256        let prov = Config::new()
257            .add_toml_str(
258                "test.toml",
259                "[registry]\ndata_dir = \"/tmp/x\"\n[docs]\nenabled = true\n",
260            )
261            .build::<SettingsProv>()
262            .unwrap();
263        let map = sources_from_prov(&prov);
264
265        assert_eq!(map.get("registry.data_dir"), Some(&ConfigSource::Toml));
266        assert_eq!(map.get("docs.enabled"), Some(&ConfigSource::Toml));
267        // Untouched leaves keep Default, File → Toml mapping must not bleed
268        // across leaves that the TOML source didn't supply.
269        assert_eq!(map.get("registry.cache_size"), Some(&ConfigSource::Default));
270        assert_eq!(map.get("s3.enabled"), Some(&ConfigSource::Default));
271    }
272
273    #[test]
274    fn settings_from_prov_normalizes_origin_path() {
275        let prov = Config::new()
276            .add_toml_str("test.toml", "[origin]\npath = \"registry/\"\n")
277            .build::<SettingsProv>()
278            .unwrap();
279
280        assert_eq!(settings_from_prov(&prov).origin.path, "/registry");
281    }
282
283    #[test]
284    fn settings_from_prov_normalizes_proxy_urls() {
285        let prov = Config::new()
286            .add_toml_str(
287                "test.toml",
288                "[proxy]\nurl = \"https://mirror.example.com/crates\"\n",
289            )
290            .build::<SettingsProv>()
291            .unwrap();
292
293        assert_eq!(
294            settings_from_prov(&prov).proxy.url.as_str(),
295            "https://mirror.example.com/crates/"
296        );
297    }
298}