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