1use figment::Provider;
2use saluki_config::{ConfigurationLoader, GenericConfiguration};
3use serde::Serialize;
4use serde_json::json;
5
6use crate::config_registry::{SalukiAnnotation, ValueType, SUPPORTED_ANNOTATIONS};
7
8pub const TEST_STRING_VALUE: &str = "http://smoke-proxy.example.com:3128";
10pub const TEST_BOOL_VALUE: bool = true;
12pub const TEST_STRING_LIST_VALUE: &[&str] = &["smoke-host-1.example.com", "smoke-host-2.example.com"];
14
15fn test_json_value(value_type: ValueType) -> serde_json::Value {
16 match value_type {
17 ValueType::String => json!(TEST_STRING_VALUE),
18 ValueType::Bool => json!(TEST_BOOL_VALUE),
19 ValueType::StringList => json!(TEST_STRING_LIST_VALUE),
20 ValueType::Integer => json!(42i64),
21 ValueType::Float => json!(1.5f64),
22 ValueType::Duration => json!("42s"),
23 }
24}
25
26fn effective_test_value(annotation: &SalukiAnnotation) -> serde_json::Value {
27 let v = test_json_value(annotation.value_type());
28 if let Some(default_raw) = annotation.schema.default {
29 if let Ok(default_val) = serde_json::from_str::<serde_json::Value>(default_raw) {
30 if v == default_val {
31 return match annotation.value_type() {
32 ValueType::Bool => json!(!default_val.as_bool().unwrap_or(false)),
33 _ => v,
34 };
35 }
36 }
37 }
38 v
39}
40
41fn json_value_to_env_string(value: &serde_json::Value, value_type: ValueType) -> String {
42 match value_type {
43 ValueType::Bool => value
44 .as_bool()
45 .map(|b| b.to_string())
46 .unwrap_or_else(|| "true".to_string()),
47 ValueType::Integer => value
48 .as_i64()
49 .map(|n| n.to_string())
50 .unwrap_or_else(|| "42".to_string()),
51 ValueType::Float => value
52 .as_f64()
53 .map(|f| f.to_string())
54 .unwrap_or_else(|| "1.5".to_string()),
55 ValueType::String => value.as_str().unwrap_or(TEST_STRING_VALUE).to_string(),
56 ValueType::StringList => value
57 .as_array()
58 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" "))
59 .unwrap_or_else(|| TEST_STRING_LIST_VALUE.join(" ")),
60 ValueType::Duration => value.as_str().unwrap_or("42s").to_string(),
61 }
62}
63
64fn collect_unchanged_leaves(
65 full: &serde_json::Value, default: &serde_json::Value, path: &str, unchanged: &mut Vec<String>,
66) {
67 match (full, default) {
68 (serde_json::Value::Object(f), serde_json::Value::Object(d)) => {
69 for (key, full_val) in f {
70 let child_path = if path.is_empty() {
71 key.clone()
72 } else {
73 format!("{}.{}", path, key)
74 };
75 let def_val = d.get(key).unwrap_or(&serde_json::Value::Null);
76 collect_unchanged_leaves(full_val, def_val, &child_path, unchanged);
77 }
78 }
79 (full_val, def_val) => {
80 if full_val == def_val {
81 unchanged.push(path.to_string());
82 }
83 }
84 }
85}
86
87fn yaml_path_to_json(yaml_path: &str, value: serde_json::Value) -> serde_json::Value {
88 let mut root = json!({});
89 saluki_config::upsert(&mut root, yaml_path, value);
90 root
91}
92
93fn merge_over_base(base: &serde_json::Value, overlay: serde_json::Value) -> serde_json::Value {
94 let mut merged = base.clone();
95 if let (Some(base_obj), Some(overlay_obj)) = (merged.as_object_mut(), overlay.as_object()) {
96 for (k, v) in overlay_obj {
97 base_obj.insert(k.clone(), v.clone());
98 }
99 }
100 merged
101}
102
103fn dd_env_var_to_test_key(env_var: &str) -> &str {
104 env_var.strip_prefix("DD_").unwrap_or(env_var)
105}
106
107async fn make_config_from_file<P, F>(
108 file_values: serde_json::Value, key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
109) -> GenericConfiguration
110where
111 P: Provider + Send + Sync + 'static,
112 F: FnOnce(Vec<(String, String)>) -> P,
113{
114 let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
115 Some(file_values),
116 None,
117 false,
118 key_aliases,
119 provider_factory,
120 )
121 .await;
122 cfg
123}
124
125async fn make_config_from_env<P, F>(
126 base_file_values: &serde_json::Value, env_vars: &[(String, String)],
127 key_aliases: &'static [(&'static str, &'static str)], provider_factory: F,
128) -> GenericConfiguration
129where
130 P: Provider + Send + Sync + 'static,
131 F: FnOnce(Vec<(String, String)>) -> P,
132{
133 let (cfg, _) = ConfigurationLoader::for_tests_with_provider_factory(
134 Some(base_file_values.clone()),
135 Some(env_vars),
136 false,
137 key_aliases,
138 provider_factory,
139 )
140 .await;
141 cfg
142}
143
144pub async fn run_config_smoke_tests<T, Factory, P, PF>(
161 struct_name: &'static str, non_config_fields: &[&str], base_config: serde_json::Value, config_factory: Factory,
162 key_aliases: &'static [(&'static str, &'static str)], provider_factory: PF,
163) where
164 T: PartialEq + Serialize,
165 Factory: Fn(GenericConfiguration) -> T,
166 P: Provider + Send + Sync + 'static,
167 PF: Fn(Vec<(String, String)>) -> P,
168{
169 let keys: Vec<&'static SalukiAnnotation> = SUPPORTED_ANNOTATIONS
170 .iter()
171 .copied()
172 .filter(|a| a.used_by.contains(&struct_name))
173 .collect();
174
175 let default_struct =
176 config_factory(make_config_from_file(base_config.clone(), key_aliases, &provider_factory).await);
177 let mut failures: Vec<String> = Vec::new();
178
179 for annotation in &keys {
180 let canonical_path = annotation.yaml_path();
181 let injected_value = match annotation.test_json {
182 Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
183 None => effective_test_value(annotation),
184 };
185 let reference = config_factory(
186 make_config_from_file(
187 merge_over_base(&base_config, yaml_path_to_json(canonical_path, injected_value.clone())),
188 key_aliases,
189 &provider_factory,
190 )
191 .await,
192 );
193
194 if reference == default_struct {
195 failures.push(format!(
196 "yaml_path '{}': struct did not change from its default—\
197 is the test value the same as the default, or is the key not wired up?",
198 canonical_path,
199 ));
200 continue;
201 }
202
203 for yaml_path in annotation.additional_yaml_paths {
204 let from_path = config_factory(
205 make_config_from_file(
206 merge_over_base(&base_config, yaml_path_to_json(yaml_path, injected_value.clone())),
207 key_aliases,
208 &provider_factory,
209 )
210 .await,
211 );
212 if from_path != reference {
213 failures.push(format!(
214 "yaml_path '{}' produced a different struct than canonical yaml_path '{}'",
215 yaml_path, canonical_path,
216 ));
217 }
218 }
219
220 for env_var in annotation.effective_env_vars() {
221 let env_pairs = [(
222 dd_env_var_to_test_key(env_var).to_string(),
223 json_value_to_env_string(&injected_value, annotation.value_type()),
224 )];
225 let from_env =
226 config_factory(make_config_from_env(&base_config, &env_pairs, key_aliases, &provider_factory).await);
227 if from_env != reference {
228 failures.push(format!(
229 "env var '{}' produced a different struct than yaml_path '{}'",
230 env_var, canonical_path,
231 ));
232 }
233 }
234 }
235
236 for annotation in SUPPORTED_ANNOTATIONS
237 .iter()
238 .filter(|a| !a.used_by.contains(&struct_name))
239 {
240 for yaml_path in annotation.all_yaml_paths() {
241 let with_foreign = config_factory(
242 make_config_from_file(
243 merge_over_base(
244 &base_config,
245 yaml_path_to_json(yaml_path, test_json_value(annotation.value_type())),
246 ),
247 key_aliases,
248 &provider_factory,
249 )
250 .await,
251 );
252 if with_foreign != default_struct {
253 failures.push(format!(
254 "yaml_path '{}' is not registered for '{}' but unexpectedly changed the struct",
255 yaml_path, struct_name,
256 ));
257 }
258 }
259 }
260
261 let mut all_vals = base_config.clone();
262 for annotation in keys {
263 let val = match annotation.test_json {
264 Some(raw) => serde_json::from_str(raw).expect("test_json is not valid JSON"),
265 None => effective_test_value(annotation),
266 };
267 saluki_config::upsert(&mut all_vals, annotation.yaml_path(), val);
268 }
269 let all_keys_struct = config_factory(make_config_from_file(all_vals, key_aliases, &provider_factory).await);
270 let full_map = serde_json::to_value(&all_keys_struct).expect("failed to serialize struct with all keys set");
271 let default_map = serde_json::to_value(&default_struct).expect("failed to serialize default struct");
272 let mut unchanged = Vec::new();
273 collect_unchanged_leaves(&full_map, &default_map, "", &mut unchanged);
274 unchanged.retain(|path| !non_config_fields.contains(&path.as_str()));
275 if !unchanged.is_empty() {
276 failures.push(format!(
277 "{} serialized field(s) are never changed by any registered config key: [{}]\n \
278 Fix: add a SalukiAnnotation for each field and include '{}' in its used_by list.\n \
279 Fix: if a field is intentionally not config-driven (for example, injected at runtime), \
280 add its serialized name to the `non_config_fields` slice in this test call.",
281 unchanged.len(),
282 unchanged.join(", "),
283 struct_name,
284 ));
285 }
286
287 if !failures.is_empty() {
288 panic!(
289 "config smoke tests for '{}' failed with {} error(s):\n\n{}",
290 struct_name,
291 failures.len(),
292 failures
293 .iter()
294 .enumerate()
295 .map(|(i, msg)| format!(" [{}] {}", i + 1, msg))
296 .collect::<Vec<_>>()
297 .join("\n\n"),
298 );
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use saluki_config::GenericConfiguration;
318 use serde_json::json;
319
320 use super::run_config_smoke_tests;
321 use crate::config_registry::structs;
322
323 const UNREGISTERED_STRUCT: &str = "NonExistentConfiguration";
325
326 fn empty_provider(_env_vars: Vec<(String, String)>) -> figment::providers::Serialized<serde_json::Value> {
327 figment::providers::Serialized::defaults(json!({}))
328 }
329
330 fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
331 payload
332 .downcast_ref::<String>()
333 .cloned()
334 .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
335 .unwrap_or_else(|| "<non-string panic payload>".to_string())
336 }
337
338 #[tokio::test]
339 async fn flags_a_supported_key_that_never_changes_the_struct() {
340 let outcome = tokio::spawn(async {
344 run_config_smoke_tests(
345 structs::PROXY_CONFIGURATION,
346 &[],
347 json!({}),
348 |_cfg: GenericConfiguration| json!({}),
349 &[],
350 empty_provider,
351 )
352 .await
353 })
354 .await;
355
356 let panic = outcome.expect_err("harness should panic when a supported key never changes the struct");
357 let message = panic_message(panic.into_panic());
358 assert!(
359 message.contains("did not change from its default"),
360 "expected supported-key failure, got: {message}"
361 );
362 }
363
364 #[tokio::test]
365 async fn flags_a_foreign_key_that_changes_the_struct() {
366 let outcome = tokio::spawn(async {
370 run_config_smoke_tests(
371 UNREGISTERED_STRUCT,
372 &[],
373 json!({}),
374 |cfg: GenericConfiguration| cfg.as_typed::<serde_json::Value>().unwrap_or(serde_json::Value::Null),
375 &[],
376 empty_provider,
377 )
378 .await
379 })
380 .await;
381
382 let panic = outcome.expect_err("harness should panic when a foreign key changes the struct");
383 let message = panic_message(panic.into_panic());
384 assert!(
385 message.contains("unexpectedly changed the struct"),
386 "expected unsupported-key failure, got: {message}"
387 );
388 }
389
390 #[tokio::test]
391 async fn flags_a_serialized_field_no_key_ever_changes() {
392 let outcome = tokio::spawn(async {
395 run_config_smoke_tests(
396 UNREGISTERED_STRUCT,
397 &[],
398 json!({}),
399 |_cfg: GenericConfiguration| json!({ "phantom": "constant" }),
400 &[],
401 empty_provider,
402 )
403 .await
404 })
405 .await;
406
407 let panic = outcome.expect_err("harness should panic about a serialized field no key changes");
408 let message = panic_message(panic.into_panic());
409 assert!(
410 message.contains("never changed by any registered config key"),
411 "expected full-field-coverage failure, got: {message}"
412 );
413 }
414
415 #[tokio::test]
416 async fn passes_when_no_guarantee_is_violated() {
417 run_config_smoke_tests(
421 UNREGISTERED_STRUCT,
422 &[],
423 json!({}),
424 |_cfg: GenericConfiguration| json!({}),
425 &[],
426 empty_provider,
427 )
428 .await;
429 }
430}