1use config::{ConfigError, Environment, Map, Source, Value, ValueKind};
42use serde_json::Value as JsonValue;
43
44use crate::error::{
45 Error, Result, TaggedSeqInvalidDefaultSnafu, TaggedSeqMissingFieldSnafu,
46 TaggedSeqMissingTagSnafu, TaggedSeqNotATableSnafu, TaggedSeqNumericTagSnafu,
47 TaggedSeqOverridesConflictSnafu,
48};
49
50#[derive(Debug)]
54pub(crate) struct TaggedSeqEnvSource {
55 inner: Environment,
58 targets: Vec<Target>,
60}
61
62#[derive(Debug, Clone)]
64struct Target {
65 key: String,
67 default: Option<JsonValue>,
69}
70
71impl TaggedSeqEnvSource {
72 pub(crate) fn new(
76 inner: Environment,
77 keys: &'static [&'static str],
78 defaults: &JsonValue,
79 ) -> Self {
80 let targets = keys
81 .iter()
82 .map(|key| Target {
83 key: (*key).to_string(),
84 default: defaults.get(key).cloned(),
85 })
86 .collect();
87
88 Self { inner, targets }
89 }
90}
91
92impl Source for TaggedSeqEnvSource {
93 fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
94 Box::new(Self {
95 inner: self.inner.clone(),
96 targets: self.targets.clone(),
97 })
98 }
99
100 fn collect(&self) -> std::result::Result<Map<String, Value>, ConfigError> {
101 let mut collected = self.inner.collect()?;
102
103 for target in &self.targets {
104 let entries = take_entries(&mut collected, &target.key);
108 let merged = merge(target, entries).map_err(|e| ConfigError::Message(e.to_string()))?;
109 collected.insert(target.key.clone(), merged);
110 }
111
112 Ok(collected)
113 }
114}
115
116fn take_entries(collected: &mut Map<String, Value>, key: &str) -> Vec<(String, Value)> {
118 let child_prefix = format!("{key}.");
119 let mut keys = collected
120 .keys()
121 .filter(|existing| *existing == key || existing.starts_with(&child_prefix))
122 .cloned()
123 .collect::<Vec<_>>();
124 keys.sort();
126
127 keys.into_iter()
128 .filter_map(|key| collected.remove(&key).map(|value| (key, value)))
129 .collect()
130}
131
132fn merge(target: &Target, entries: Vec<(String, Value)>) -> Result<Value> {
134 let mut overrides = group_entries(target, entries)?;
135
136 let mut merged = Vec::with_capacity(overrides.len() + 1);
137 for entry in default_entries(target)? {
138 let mut entry = json_to_value(entry);
139 let tag = single_tag(target, &entry)?;
140
141 if let Some(value) = overrides.remove(&tag) {
142 apply_override(target, &tag, &mut entry, value)?;
143 }
144
145 merged.push(entry);
146 }
147
148 let mut appended = overrides.into_iter().collect::<Vec<_>>();
150 appended.sort_by(|a, b| a.0.cmp(&b.0));
151 for (tag, value) in appended {
152 let mut table = Map::new();
153 table.insert(tag, value);
154 merged.push(Value::new(None, ValueKind::Table(table)));
155 }
156
157 Ok(Value::new(None, ValueKind::Array(merged)))
158}
159
160fn apply_override(target: &Target, tag: &str, entry: &mut Value, value: Value) -> Result<()> {
162 let ValueKind::Table(body) = &mut entry.kind else {
163 return invalid_default(target, "expected an array of tables");
164 };
165
166 let Some(field) = body.get_mut(tag) else {
167 return invalid_default(target, "expected each entry to hold exactly one tag");
168 };
169
170 deep_merge(field, value).map_err(|path| not_a_table(&entry_path(target, tag, &path)))
173}
174
175fn default_entries(target: &Target) -> Result<&[JsonValue]> {
177 match &target.default {
178 None | Some(JsonValue::Null) => Ok(&[]),
179 Some(JsonValue::Array(entries)) => Ok(entries),
180 Some(_) => invalid_default(target, "expected an array of tables"),
181 }
182}
183
184fn single_tag(target: &Target, entry: &Value) -> Result<String> {
186 let ValueKind::Table(table) = &entry.kind else {
187 return invalid_default(target, "expected an array of tables");
188 };
189
190 let mut keys = table.keys();
191 let (Some(tag), None) = (keys.next(), keys.next()) else {
192 return invalid_default(target, "expected each entry to hold exactly one tag");
193 };
194 if !matches!(&table[tag].kind, ValueKind::Table(_)) {
195 return invalid_default(target, "expected each entry to map its tag to a table");
196 }
197
198 Ok(tag.clone())
199}
200
201fn group_entries(target: &Target, entries: Vec<(String, Value)>) -> Result<Map<String, Value>> {
203 let offset = target.key.len() + 1;
205 let mut groups: Map<String, Value> = Map::new();
206
207 for (key, value) in entries {
208 if key == target.key {
209 return TaggedSeqMissingTagSnafu {
210 key,
211 target: target.key.clone(),
212 }
213 .fail();
214 }
215
216 let rest = key[offset..].to_string();
217 let Some((tag, field)) = rest.split_once('.') else {
218 return TaggedSeqMissingFieldSnafu {
219 key,
220 tag: rest,
221 target: target.key.clone(),
222 }
223 .fail();
224 };
225
226 if is_index_like(tag) {
227 return TaggedSeqNumericTagSnafu {
228 key,
229 tag: tag.to_string(),
230 target: target.key.clone(),
231 }
232 .fail();
233 }
234
235 let field = nest(field, value);
236 match groups.get_mut(tag) {
237 Some(group) => deep_merge(group, field)
238 .map_err(|path| overrides_conflict(&key, &entry_path(target, tag, &path)))?,
239 None => {
240 groups.insert(tag.to_string(), field);
241 }
242 }
243 }
244
245 Ok(groups)
246}
247
248fn is_index_like(tag: &str) -> bool {
255 let mut chars = tag.chars();
256 match chars.next() {
257 Some(first) if first.is_ascii_digit() => true,
258 Some('-') | Some('+') => {
259 matches!(chars.next(), Some(next) if next.is_ascii_digit() || next == '.')
260 }
261 _ => false,
262 }
263}
264
265fn nest(path: &str, value: Value) -> Value {
268 let mut table = Map::new();
269 match path.split_once('.') {
270 Some((head, tail)) => {
271 table.insert(head.to_string(), nest(tail, value));
272 }
273 None => {
274 table.insert(path.to_string(), value);
275 }
276 }
277
278 Value::new(None, ValueKind::Table(table))
279}
280
281fn deep_merge(base: &mut Value, incoming: Value) -> std::result::Result<(), String> {
285 merge_at(base, incoming, "")
286}
287
288fn merge_at(base: &mut Value, incoming: Value, path: &str) -> std::result::Result<(), String> {
289 match incoming.kind {
290 ValueKind::Table(incoming) => {
291 let ValueKind::Table(base) = &mut base.kind else {
292 return Err(path.to_string());
293 };
294 for (field, value) in incoming {
295 let field_path = if path.is_empty() {
296 field.clone()
297 } else {
298 format!("{path}.{field}")
299 };
300 if matches!(value.kind, ValueKind::Table(_)) {
303 match base.get_mut(&field) {
304 Some(existing) => merge_at(existing, value, &field_path)?,
305 None => {
306 base.insert(field, value);
307 }
308 }
309 } else {
310 base.insert(field, value);
311 }
312 }
313 Ok(())
314 }
315 kind => {
316 base.kind = kind;
318 Ok(())
319 }
320 }
321}
322
323fn invalid_default<T>(target: &Target, reason: &str) -> Result<T> {
324 TaggedSeqInvalidDefaultSnafu {
325 target: target.key.clone(),
326 reason: reason.to_string(),
327 }
328 .fail()
329}
330
331fn entry_path(target: &Target, tag: &str, path: &str) -> String {
333 if path.is_empty() {
334 format!("{}.{}", target.key, tag)
335 } else {
336 format!("{}.{}.{}", target.key, tag, path)
337 }
338}
339
340fn not_a_table(path: &str) -> Error {
342 TaggedSeqNotATableSnafu {
343 path: path.to_string(),
344 }
345 .build()
346}
347
348fn overrides_conflict(key: &str, path: &str) -> Error {
350 TaggedSeqOverridesConflictSnafu {
351 key: key.to_string(),
352 path: path.to_string(),
353 }
354 .build()
355}
356
357fn json_to_value(json: &JsonValue) -> Value {
359 let kind = match json {
360 JsonValue::Null => ValueKind::Nil,
361 JsonValue::Bool(value) => ValueKind::Boolean(*value),
362 JsonValue::Number(value) => match (value.as_i64(), value.as_u64()) {
363 (Some(value), _) => ValueKind::I64(value),
364 (None, Some(value)) => ValueKind::U64(value),
365 (None, None) => ValueKind::Float(value.as_f64().unwrap_or_default()),
366 },
367 JsonValue::String(value) => ValueKind::String(value.clone()),
368 JsonValue::Array(values) => ValueKind::Array(values.iter().map(json_to_value).collect()),
369 JsonValue::Object(values) => ValueKind::Table(
370 values
371 .iter()
372 .map(|(key, value)| (key.clone(), json_to_value(value)))
373 .collect(),
374 ),
375 };
376
377 Value::new(None, kind)
378}
379
380#[cfg(test)]
381mod tests {
382 use serde_json::json;
383
384 use super::*;
385
386 const KEY: &str = "region_engine";
387 const KEYS: &[&str] = &[KEY];
388
389 fn defaults() -> JsonValue {
392 json!({
393 "region_engine": [
394 {
395 "mito": {
396 "global_write_buffer_size": "64MB",
397 "index": {
398 "aux_path": "/default/aux",
399 "cache_percent": 30
400 }
401 }
402 },
403 {"file": {"data_dir": "/default/data"}}
404 ],
405 "data_home": "/default/data_home"
406 })
407 }
408
409 fn env(vars: &[(&str, &str)]) -> Environment {
412 let mut vars_map = Map::new();
413 for (key, value) in vars {
414 vars_map.insert((*key).to_string(), (*value).to_string());
415 }
416
417 Environment::default()
418 .separator("__")
419 .try_parsing(true)
420 .source(Some(vars_map))
421 }
422
423 fn collect_with(vars: &[(&str, &str)], defaults: &JsonValue) -> Map<String, Value> {
424 TaggedSeqEnvSource::new(env(vars), KEYS, defaults)
425 .collect()
426 .unwrap()
427 }
428
429 fn collect(vars: &[(&str, &str)]) -> Map<String, Value> {
430 collect_with(vars, &defaults())
431 }
432
433 fn to_json(collected: Map<String, Value>) -> JsonValue {
434 let mut map = serde_json::Map::new();
435 for (key, value) in collected {
436 map.insert(key, value.try_deserialize().unwrap());
437 }
438
439 JsonValue::Object(map)
440 }
441
442 fn collect_json(vars: &[(&str, &str)]) -> JsonValue {
443 to_json(collect(vars))
444 }
445
446 fn collect_json_with(vars: &[(&str, &str)], defaults: &JsonValue) -> JsonValue {
447 to_json(collect_with(vars, defaults))
448 }
449
450 fn collect_error_with(vars: &[(&str, &str)], defaults: &JsonValue) -> String {
451 match TaggedSeqEnvSource::new(env(vars), KEYS, defaults).collect() {
452 Ok(_) => panic!("expected an error, got {vars:?}"),
453 Err(ConfigError::Message(message)) => message,
454 Err(other) => panic!("expected a message error, got: {other}"),
455 }
456 }
457
458 fn collect_error(vars: &[(&str, &str)]) -> String {
459 collect_error_with(vars, &defaults())
460 }
461
462 #[test]
463 fn test_collect_overrides_a_scalar_field() {
464 let collected = collect_json(&[("REGION_ENGINE__MITO__GLOBAL_WRITE_BUFFER_SIZE", "128MB")]);
465
466 let engines = collected["region_engine"].as_array().unwrap();
467 assert_eq!(engines.len(), 2);
468 assert_eq!(engines[0]["mito"]["global_write_buffer_size"], "128MB");
469 assert_eq!(engines[0]["mito"]["index"]["aux_path"], "/default/aux");
471 assert_eq!(engines[1]["file"]["data_dir"], "/default/data");
472 }
473
474 #[test]
475 fn test_collect_overrides_a_nested_field() {
476 let collected = collect_json(&[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")]);
477
478 assert_eq!(
479 collected["region_engine"][0]["mito"]["index"]["aux_path"],
480 "/tmp/aux"
481 );
482 assert_eq!(
483 collected["region_engine"][0]["mito"]["index"]["cache_percent"],
484 30
485 );
486 assert_eq!(
487 collected["region_engine"][0]["mito"]["global_write_buffer_size"],
488 "64MB"
489 );
490 }
491
492 #[test]
493 fn test_collect_merges_several_variables_of_one_entry() {
494 let collected = collect_json(&[
495 ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
496 ("REGION_ENGINE__MITO__INDEX__CACHE_PERCENT", "50"),
497 ]);
498
499 assert_eq!(
500 collected["region_engine"][0]["mito"]["index"]["aux_path"],
501 "/tmp/aux"
502 );
503 assert_eq!(
504 collected["region_engine"][0]["mito"]["index"]["cache_percent"],
505 50
506 );
507 }
508
509 #[test]
510 fn test_collect_appends_unknown_tags_in_tag_order() {
511 let collected = collect_json(&[
512 (
513 "REGION_ENGINE__METRIC__FLUSH_METADATA_REGION_INTERVAL",
514 "1m",
515 ),
516 ("REGION_ENGINE__AAA__DATA_DIR", "/tmp/aaa"),
517 ]);
518
519 let engines = collected["region_engine"].as_array().unwrap();
520 assert_eq!(engines.len(), 4);
521 assert_eq!(engines[0]["mito"]["global_write_buffer_size"], "64MB");
523 assert_eq!(engines[1]["file"]["data_dir"], "/default/data");
524 assert_eq!(engines[2], json!({"aaa": {"data_dir": "/tmp/aaa"}}));
526 assert_eq!(
527 engines[3],
528 json!({"metric": {"flush_metadata_region_interval": "1m"}})
529 );
530 }
531
532 #[test]
533 fn test_collect_keeps_the_default_sequence_without_overrides() {
534 let collected = collect_json(&[("DATA_HOME", "/srv/data")]);
535
536 assert_eq!(collected["region_engine"], defaults()["region_engine"]);
537 assert_eq!(collected["data_home"], "/srv/data");
538 }
539
540 #[test]
541 fn test_collect_removes_the_flat_keys_of_the_sequence() {
542 let collected = collect(&[
543 ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
544 ("REGION_ENGINE_EXTRA__MITO__AUX_PATH", "/tmp/other"),
545 ]);
546
547 assert!(
550 !collected
551 .keys()
552 .any(|key| key.starts_with("region_engine.")),
553 "flat keys left in the collected map: {:?}",
554 collected.keys().collect::<Vec<_>>()
555 );
556 assert!(collected.contains_key("region_engine_extra.mito.aux_path"));
558 }
559
560 #[test]
561 fn test_collect_without_default_entries() {
562 let collected = collect_json_with(
563 &[
564 (
565 "REGION_ENGINE__METRIC__FLUSH_METADATA_REGION_INTERVAL",
566 "1m",
567 ),
568 ("REGION_ENGINE__FILE__DATA_DIR", "/tmp/data"),
569 ],
570 &json!({}),
571 );
572
573 assert_eq!(
574 collected["region_engine"],
575 json!([
576 {"file": {"data_dir": "/tmp/data"}},
577 {"metric": {"flush_metadata_region_interval": "1m"}}
578 ])
579 );
580 }
581
582 #[test]
583 fn test_collect_with_null_default_entries() {
584 let collected = collect_json_with(
585 &[("REGION_ENGINE__FILE__DATA_DIR", "/tmp/data")],
586 &json!({"region_engine": null}),
587 );
588
589 assert_eq!(
590 collected["region_engine"],
591 json!([{"file": {"data_dir": "/tmp/data"}}])
592 );
593 }
594
595 #[test]
596 fn test_collect_rejects_setting_the_whole_sequence() {
597 let message = collect_error(&[("REGION_ENGINE", "mito")]);
598
599 assert!(
600 message.contains("cannot be overridden as a whole"),
601 "{message}"
602 );
603 assert!(
604 message.contains("region_engine__<TAG>__<FIELD>"),
605 "{message}"
606 );
607 }
608
609 #[test]
610 fn test_collect_rejects_a_numeric_index() {
611 let message = collect_error(&[("REGION_ENGINE__0__MITO__INDEX__AUX_PATH", "/tmp/aux")]);
612
613 assert!(message.contains("numeric index"), "{message}");
614 assert!(message.contains("addressed by tag name"), "{message}");
615 }
616
617 #[test]
618 fn test_collect_rejects_a_tag_without_field() {
619 let message = collect_error(&[("REGION_ENGINE__MITO", "mito")]);
620
621 assert!(message.contains("needs a field"), "{message}");
622 }
623
624 #[test]
625 fn test_collect_rejects_conflicting_overrides() {
626 let message = collect_error(&[
627 ("REGION_ENGINE__MITO__INDEX", "not-a-table"),
628 ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
629 ]);
630
631 assert!(
632 message.contains("another environment variable sets 'region_engine.mito.index'"),
633 "{message}"
634 );
635 }
636
637 #[test]
638 fn test_collect_rejects_a_field_below_a_scalar_default() {
639 let message = collect_error(&[("REGION_ENGINE__FILE__DATA_DIR__SUB", "/tmp/sub")]);
640
641 assert!(message.contains("region_engine.file.data_dir"), "{message}");
642 assert!(
643 message.contains("default value is not a table"),
644 "{message}"
645 );
646 }
647
648 #[test]
649 fn test_collect_rejects_a_scalar_default_sequence() {
650 let message = collect_error_with(
651 &[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")],
652 &json!({"region_engine": "not-an-array"}),
653 );
654
655 assert!(
656 message.contains("not a sequence of single-key tables"),
657 "{message}"
658 );
659 }
660
661 #[test]
662 fn test_collect_rejects_a_default_entry_with_two_tags() {
663 let message = collect_error_with(
664 &[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")],
665 &json!({"region_engine": [{"mito": {}, "file": {}}]}),
666 );
667
668 assert!(message.contains("exactly one tag"), "{message}");
669 }
670
671 #[test]
672 fn test_take_entries_keeps_unrelated_keys() {
673 let mut collected = Map::new();
674 for key in [
675 "region_engine",
676 "region_engine.mito.index",
677 "region_engine_extra.mito",
678 "other",
679 ] {
680 collected.insert(
681 (*key).to_string(),
682 Value::new(None, ValueKind::String("v".to_string())),
683 );
684 }
685
686 let entries = take_entries(&mut collected, "region_engine");
687
688 assert_eq!(
689 entries
690 .iter()
691 .map(|(key, _)| key.as_str())
692 .collect::<Vec<_>>(),
693 vec!["region_engine", "region_engine.mito.index"]
694 );
695 assert!(!collected.contains_key("region_engine"));
696 assert!(!collected.contains_key("region_engine.mito.index"));
697 assert!(collected.contains_key("region_engine_extra.mito"));
698 assert!(collected.contains_key("other"));
699 }
700
701 #[test]
702 fn test_is_index_like() {
703 for tag in ["0", "12", "-1", "+1", "1e3", "-1.5"] {
704 assert!(is_index_like(tag), "{tag} should look like an index");
705 }
706
707 for tag in ["mito", "file", "metric", "", "m1", "-", "+"] {
708 assert!(!is_index_like(tag), "{tag} should not look like an index");
709 }
710 }
711}