1use std::{
2 cmp::Ordering,
3 fmt::Write as _,
4 iter::once,
5 path::{Path, PathBuf},
6 sync::LazyLock,
7};
8
9mod overlay;
10#[cfg(test)]
11mod tests;
12mod tree;
13mod write;
14
15use figment::{
16 Figment,
17 providers::{Data, Format as _, Toml},
18 value::{Dict, Value},
19};
20use itertools::Itertools as _;
21use link_section::TypedSection;
22use serde::Serialize as _;
23use smallvec::SmallVec;
24use toml::{Value as TomlValue, ser::ValueSerializer};
25use toml_writer::TomlWrite as _;
26
27use self::{
28 overlay::{filter_nonconfig_environment, is_file_value, validate_file_overlay},
29 tree::{
30 ConfigPath, Instance, PathPart, find_path, normalize_aliases, remove_path,
31 resolve_instances,
32 },
33 write::write_atomic,
34};
35use super::{Config, DEPRECATED_KEYS, Sources};
36use crate::{Err, Error, Result, err, implement, utils::BoolExt};
37
38const NEVER_EMIT: [&str; 2] = ["database_restore_backup", "force_migration"];
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub(super) enum FieldClass {
42 Documented,
43 Structural,
44 Hidden,
45 Forbidden,
46}
47
48#[derive(Clone, Copy, Debug)]
49pub(super) struct FieldSpec {
50 pub(super) name: &'static str,
51 pub(super) aliases: &'static [&'static str],
52 pub(super) example: &'static str,
53 pub(super) class: FieldClass,
54}
55
56#[derive(Clone, Copy, Debug)]
57pub(super) struct SectionSpec {
58 pub(super) section: &'static str,
59 pub(super) aliases: &'static [&'static str],
60 pub(super) example: &'static str,
61 pub(super) fields: &'static [FieldSpec],
62 pub(super) position: SourcePosition,
63}
64
65#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
66pub(super) struct SourcePosition {
67 pub(super) file: &'static str,
68 pub(super) line: u32,
69 pub(super) column: u32,
70}
71
72#[derive(Clone, Copy, Debug, Default)]
77pub struct RegenerateOptions<'a> {
78 pub output: Option<&'a Path>,
83
84 pub force: bool,
89
90 pub include_env: bool,
95
96 pub strip_unknown: bool,
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub enum Overwrite {
109 Deny,
113
114 Allow,
118}
119
120#[derive(Debug)]
126pub struct RegenerationSummary {
127 output: PathBuf,
128 input_count: usize,
129 configured: usize,
130 residue: usize,
131 dropped: [bool; NEVER_EMIT.len()],
132}
133
134#[derive(Default)]
135struct RenderStats {
136 configured: usize,
137 residue: usize,
138 dropped: [bool; NEVER_EMIT.len()],
139}
140
141struct RenderContext<'a> {
142 environment: Option<(&'a Figment, &'a Dict)>,
143 strip_unknown: bool,
144 expected: &'a mut Dict,
145 stats: &'a mut RenderStats,
146}
147
148#[derive(Clone, Copy)]
149enum ResidueKind {
150 Deprecated,
151 Hidden,
152 Unknown,
153}
154
155#[derive(Clone, Copy)]
156enum ResidueDisposition {
157 Active,
158 Commented,
159}
160
161#[link_section::section(typed)]
162pub(super) static REGISTERED_SECTIONS: TypedSection<SectionSpec>;
163
164type OrderedSections = SmallVec<[&'static SectionSpec; 16]>;
165
166static ORDERED_SECTIONS: LazyLock<OrderedSections> = LazyLock::new(ordered_sections);
167
168#[inline]
169fn schema() -> &'static [&'static SectionSpec] { ORDERED_SECTIONS.as_slice() }
170
171fn ordered_sections() -> OrderedSections {
172 let mut sections = REGISTERED_SECTIONS
173 .as_slice()
174 .iter()
175 .collect::<OrderedSections>();
176
177 sections.sort_unstable_by_key(|section| section.position);
179 validate_schema(§ions);
180
181 sections
182}
183
184fn validate_schema(sections: &[&SectionSpec]) {
185 let first = sections
186 .first()
187 .expect("configuration schema must contain the global section");
188
189 assert!(
190 sections
191 .iter()
192 .all(|section| section.position.file == first.position.file),
193 "configuration sections must be declared in one source file",
194 );
195
196 assert_eq!(first.section, "global", "global must be the first configuration section");
197
198 for pair in sections.windows(2) {
199 assert_ne!(
200 pair[0].position, pair[1].position,
201 "configuration sections share a source location"
202 );
203 }
204
205 for (index, section) in sections.iter().enumerate() {
206 assert!(
207 sections[..index]
208 .iter()
209 .all(|previous| previous.section != section.section),
210 "configuration section {:?} is registered more than once",
211 section.section,
212 );
213 }
214}
215
216pub fn example_config() -> Result<String> {
221 let values = Dict::new();
222 let mut expected = Dict::new();
223 let mut stats = RenderStats::default();
224 let rendered = {
225 let mut context = RenderContext {
226 environment: None,
227 strip_unknown: false,
228 expected: &mut expected,
229 stats: &mut stats,
230 };
231
232 render_schema(&values, &mut context)?
233 };
234
235 Ok(rendered)
236}
237
238pub fn write_example_config(path: &Path, overwrite: Overwrite) -> Result {
243 let rendered = example_config()?;
244
245 write_atomic(path, rendered.as_bytes(), matches!(overwrite, Overwrite::Allow))
246}
247
248pub fn regenerate_config(
253 sources: &Sources,
254 options: RegenerateOptions<'_>,
255) -> Result<RegenerationSummary> {
256 let paths = sources.file_paths().unique().collect_vec();
257 let input_count = paths.len();
258
259 if paths.is_empty() {
260 return Err!("Configuration regeneration requires at least one input file.");
261 }
262
263 if paths.len() > 1 && options.output.is_none() {
264 return Err!(
265 "Layered configuration files require an explicit output path because they are \
266 collapsed into one document."
267 );
268 }
269
270 let output = options
271 .output
272 .map(Path::to_path_buf)
273 .unwrap_or_else(|| adjacent_new_path(&paths[0]));
274
275 let files = Config::load_files(paths.iter().map(PathBuf::as_path))?;
276 validate_file_overlay(&files)?;
277
278 let file_values = options
279 .include_env
280 .is_false()
281 .then(|| files.extract::<Dict>())
282 .transpose()?;
283
284 let effective = Config::merge_environment(files);
285 Config::new(&effective)?;
286
287 let mut values = match file_values {
288 | Some(values) => values,
289 | None => {
290 let mut values = effective.extract::<Dict>()?;
291
292 filter_nonconfig_environment(&effective, &mut values)?;
293 values
294 },
295 };
296
297 normalize_aliases(&mut values, schema());
298
299 let effective_values = options
300 .include_env
301 .is_false()
302 .then(|| effective.extract::<Dict>())
303 .transpose()?
304 .map(|mut values| {
305 normalize_aliases(&mut values, schema());
306
307 values
308 });
309
310 let mut expected = values.clone();
311 let mut stats = RenderStats::default();
312 let rendered = {
313 let mut context = RenderContext {
314 environment: effective_values
315 .as_ref()
316 .map(|values| (&effective, values)),
317 strip_unknown: options.strip_unknown,
318 expected: &mut expected,
319 stats: &mut stats,
320 };
321
322 render_schema(&values, &mut context)?
323 };
324
325 verify_rendered(&rendered, &expected)?;
326 write_atomic(&output, rendered.as_bytes(), options.force)?;
327 verify_file(&output, &expected).map_err(|error| {
328 err!(
329 "Configuration was written to {} but post-write verification failed. Inspect the \
330 written file and restore its adjacent `.bak` backup if present before retrying: \
331 {error}",
332 output.display(),
333 )
334 })?;
335
336 let summary = RegenerationSummary {
337 output,
338 input_count,
339 configured: stats.configured,
340 residue: stats.residue,
341 dropped: stats.dropped,
342 };
343
344 Ok(summary)
345}
346
347#[implement(RegenerationSummary)]
352#[inline]
353#[must_use]
354pub fn output(&self) -> &Path { &self.output }
355
356#[implement(RegenerationSummary)]
361#[inline]
362#[must_use]
363pub fn input_count(&self) -> usize { self.input_count }
364
365#[implement(RegenerationSummary)]
369#[inline]
370#[must_use]
371pub fn configured(&self) -> usize { self.configured }
372
373#[implement(RegenerationSummary)]
377#[inline]
378#[must_use]
379pub fn residue(&self) -> usize { self.residue }
380
381#[implement(RegenerationSummary)]
386pub fn dropped_keys(&self) -> impl Iterator<Item = &'static str> + '_ {
387 NEVER_EMIT
388 .into_iter()
389 .zip(self.dropped)
390 .filter_map(|(key, dropped)| dropped.then_some(key))
391}
392
393fn render_schema(values: &Dict, context: &mut RenderContext<'_>) -> Result<String> {
394 let schema = schema();
395 let mut output = String::with_capacity(schema.iter().map(|spec| spec.example.len()).sum());
396
397 for &spec in schema {
398 let instances = resolve_instances(spec, values);
399 let dynamic = is_dynamic(spec.section);
400
401 for instance in &instances {
402 render_instance(&mut output, spec, instance, context)?;
403 }
404
405 let rendered_instance = instances.is_empty().is_false();
406
407 let rendered_instance = match context.environment {
408 | None => rendered_instance,
409 | Some(environment @ (_, environment_values)) => {
410 let environment_instances = resolve_instances(spec, environment_values);
411
412 environment_instances
413 .iter()
414 .filter(|candidate| {
415 instances
416 .iter()
417 .all(|instance| instance.path != candidate.path)
418 })
419 .try_fold(rendered_instance, |_, candidate| {
420 render_environment_instance(&mut output, spec, candidate, environment)?;
421
422 Ok::<_, Error>(true)
423 })?
424 },
425 };
426
427 if dynamic || !rendered_instance {
428 output.push_str(spec.example);
429 }
430 }
431
432 Ok(output)
433}
434
435fn render_instance(
436 output: &mut String,
437 spec: &SectionSpec,
438 instance: &Instance<'_>,
439 context: &mut RenderContext<'_>,
440) -> Result {
441 for line in spec.example.split_inclusive('\n') {
442 let body = line.strip_suffix('\n').unwrap_or(line);
443
444 if is_template_header(body, spec.section) {
445 write_active_header(output, spec.section, &instance.section);
446 if line.ends_with('\n') {
447 output.push('\n');
448 }
449
450 continue;
451 }
452
453 let Some(name) = assignment_name(body) else {
454 output.push_str(line);
455 continue;
456 };
457
458 let Some(field) = spec
459 .fields
460 .iter()
461 .find(|field| field.class == FieldClass::Documented && field.name == name)
462 else {
463 output.push_str(line);
464 continue;
465 };
466
467 let path = field_path(&instance.path, field.name);
468 annotate_environment(
469 output,
470 context.environment,
471 &path,
472 instance.values.get(field.name),
473 )?;
474
475 match instance.values.get(field.name) {
476 | None => output.push_str(line),
477 | Some(value) => {
478 write_assignment(output, field.name, value)?;
479 context.stats.configured = context.stats.configured.saturating_add(1);
480 },
481 }
482 }
483
484 render_unlisted_fields(output, spec, instance, context)
485}
486
487fn render_environment_instance(
488 output: &mut String,
489 spec: &SectionSpec,
490 instance: &Instance<'_>,
491 environment: (&Figment, &Dict),
492) -> Result {
493 for line in spec.example.split_inclusive('\n') {
494 let body = line.strip_suffix('\n').unwrap_or(line);
495
496 if is_template_header(body, spec.section) {
497 output.push('#');
498 write_active_header(output, spec.section, &instance.section);
499 if line.ends_with('\n') {
500 output.push('\n');
501 }
502
503 continue;
504 }
505
506 if let Some(field) = assignment_name(body).and_then(|name| {
507 spec.fields
508 .iter()
509 .find(|field| field.class == FieldClass::Documented && field.name == name)
510 }) {
511 let path = field_path(&instance.path, field.name);
512
513 annotate_environment(output, Some(environment), &path, None)?;
514 }
515
516 output.push_str(line);
517 }
518
519 for field in spec
520 .fields
521 .iter()
522 .filter(|field| field.class == FieldClass::Hidden)
523 {
524 let path = field_path(&instance.path, field.name);
525
526 render_hidden_environment(output, field, Some(environment), &path)?;
527 }
528
529 Ok(())
530}
531
532fn render_unlisted_fields(
533 output: &mut String,
534 spec: &SectionSpec,
535 instance: &Instance<'_>,
536 context: &mut RenderContext<'_>,
537) -> Result {
538 for field in spec.fields {
539 let Some(value) = instance.values.get(field.name) else {
540 if field.class == FieldClass::Hidden {
541 let path = field_path(&instance.path, field.name);
542
543 render_hidden_environment(output, field, context.environment, &path)?;
544 }
545
546 continue;
547 };
548
549 let path = field_path(&instance.path, field.name);
550
551 match field.class {
552 | FieldClass::Documented | FieldClass::Structural => {},
553 | FieldClass::Hidden => {
554 annotate_environment(output, context.environment, &path, Some(value))?;
555 render_residue(
556 output,
557 field.name,
558 value,
559 ResidueKind::Hidden,
560 ResidueDisposition::Active,
561 )?;
562
563 context.stats.residue = context.stats.residue.saturating_add(1);
564 },
565 | FieldClass::Forbidden => {
566 remove_path(context.expected, &path);
567 mark_dropped(context.stats, field.name);
568 },
569 }
570 }
571
572 for (name, value) in instance.values {
573 if spec.fields.iter().any(|field| field.name == name) {
574 continue;
575 }
576
577 let path = field_path(&instance.path, name);
578 let kind = is_deprecated(&path)
579 .then_some(ResidueKind::Deprecated)
580 .unwrap_or(ResidueKind::Unknown);
581
582 let disposition = if context.strip_unknown {
583 ResidueDisposition::Commented
584 } else {
585 ResidueDisposition::Active
586 };
587
588 annotate_environment(output, context.environment, &path, Some(value))?;
589 render_residue(output, name, value, kind, disposition)?;
590 context.stats.residue = context.stats.residue.saturating_add(1);
591
592 if context.strip_unknown {
593 remove_path(context.expected, &path);
594 }
595 }
596
597 Ok(())
598}
599
600fn render_hidden_environment(
601 output: &mut String,
602 field: &FieldSpec,
603 environment: Option<(&Figment, &Dict)>,
604 path: &[PathPart<'_>],
605) -> Result {
606 let Some((figment, value)) = environment_value(environment, path) else {
607 return Ok(());
608 };
609
610 writeln!(
611 output,
612 "\n# UNDOCUMENTED: `{}` is valid but omitted from the example configuration.",
613 field.name
614 )
615 .expect("written to configuration buffer");
616
617 output.push_str("# currently set by ");
618 write_environment_name(output, figment, value, path);
619 output.push_str(".\n");
620
621 match field.example {
622 | "" => writeln!(output, "#{} =", field.name),
623 | example => writeln!(output, "#{} = {example}", field.name),
624 }
625 .expect("written to configuration buffer");
626
627 Ok(())
628}
629
630fn render_residue(
631 output: &mut String,
632 name: &str,
633 value: &Value,
634 kind: ResidueKind,
635 disposition: ResidueDisposition,
636) -> Result {
637 match kind {
638 | ResidueKind::Hidden => writeln!(
639 output,
640 "\n# UNDOCUMENTED: `{name}` is valid but omitted from the example configuration."
641 )
642 .expect("written to configuration buffer"),
643 | ResidueKind::Deprecated => {
644 writeln!(
645 output,
646 "\n# DEPRECATED: `{name}` is no longer used by tuwunel and is ignored."
647 )
648 .expect("written to configuration buffer");
649
650 writeln!(output, "# Preserved from the previous configuration; it can be deleted.")
651 .expect("written to configuration buffer");
652 },
653 | ResidueKind::Unknown => {
654 writeln!(output, "\n# UNKNOWN: `{name}` is not a tuwunel configuration option.")
655 .expect("written to configuration buffer");
656
657 writeln!(output, "# Preserved from the previous configuration.")
658 .expect("written to configuration buffer");
659 },
660 }
661
662 if matches!(disposition, ResidueDisposition::Commented) {
663 output.push('#');
664 }
665
666 write_assignment(output, name, value)?;
667
668 Ok(())
669}
670
671fn annotate_environment(
672 output: &mut String,
673 environment: Option<(&Figment, &Dict)>,
674 path: &[PathPart<'_>],
675 file_value: Option<&Value>,
676) -> Result {
677 let Some((figment, value)) = environment_value(environment, path) else {
678 return Ok(());
679 };
680
681 let action = file_value
682 .is_some()
683 .then_some("currently overridden by")
684 .unwrap_or("currently set by");
685
686 write!(output, "# {action} ").expect("written to configuration buffer");
687 write_environment_name(output, figment, value, path);
688 output.push_str(".\n");
689
690 Ok(())
691}
692
693fn environment_value<'a>(
694 environment: Option<(&'a Figment, &'a Dict)>,
695 path: &[PathPart<'_>],
696) -> Option<(&'a Figment, &'a Value)> {
697 let (figment, values) = environment?;
698
699 find_path(values, path)
700 .filter(|value| !is_file_value(figment, value))
701 .map(|value| (figment, value))
702}
703
704fn write_assignment(output: &mut String, name: &str, value: &Value) -> Result {
705 output
706 .key(name)
707 .expect("written to configuration buffer");
708
709 output.push_str(" = ");
710 write_value(output, value)?;
711 output.push('\n');
712
713 Ok(())
714}
715
716fn write_value(output: &mut String, value: &Value) -> Result {
717 value
718 .serialize(ValueSerializer::new(output))
719 .map_err(|error| err!("Failed to serialize a configuration value: {error}"))?;
720
721 Ok(())
722}
723
724fn assignment_name(line: &str) -> Option<&str> {
725 let (name, _) = line.strip_prefix('#')?.split_once(" =")?;
726
727 name.chars()
728 .all(|character| {
729 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
730 })
731 .then_some(name)
732}
733
734fn is_template_header(line: &str, section: &str) -> bool {
735 match section == "global" {
736 | true => line == "[global]",
737 | false => line
738 .strip_prefix("#[")
739 .and_then(|line| line.strip_suffix(']'))
740 .is_some_and(|line| line == section),
741 }
742}
743
744fn write_active_header(output: &mut String, section: &str, resolved: &str) {
745 if section.starts_with('[') {
746 write!(output, "[[{resolved}]]").expect("written to configuration buffer");
747 } else {
748 write!(output, "[{resolved}]").expect("written to configuration buffer");
749 }
750}
751
752fn is_dynamic(section: &str) -> bool { section.contains('<') || section.starts_with('[') }
753
754fn field_path<'a>(base: &ConfigPath<'a>, field: &'a str) -> ConfigPath<'a> {
755 base.iter()
756 .copied()
757 .chain(once(PathPart::Key(field)))
758 .collect()
759}
760
761fn is_deprecated(path: &[PathPart<'_>]) -> bool {
762 DEPRECATED_KEYS.iter().any(|deprecated| {
763 deprecated
764 .split('.')
765 .eq(path.iter().filter_map(|part| match part {
766 | PathPart::Key(key) => Some(*key),
767 | PathPart::Index(_) => None,
768 }))
769 })
770}
771
772fn mark_dropped(stats: &mut RenderStats, name: &str) {
773 if let Some(index) = NEVER_EMIT.iter().position(|key| *key == name) {
774 stats.dropped[index] = true;
775 }
776}
777
778fn write_environment_name(
779 output: &mut String,
780 figment: &Figment,
781 value: &Value,
782 path: &[PathPart<'_>],
783) {
784 let prefix = figment
785 .get_metadata(value.tag())
786 .and_then(|metadata| metadata.name.strip_prefix('`'))
787 .and_then(|name| name.split_once('`'))
788 .map(|(prefix, _)| prefix)
789 .unwrap_or("TUWUNEL_");
790
791 output.push_str(prefix);
792
793 for (index, key) in path
794 .iter()
795 .take_while(|part| matches!(part, PathPart::Key(_)))
796 .filter_map(|part| match part {
797 | PathPart::Key(key) => Some(*key),
798 | PathPart::Index(_) => None,
799 })
800 .enumerate()
801 {
802 if index > 0 {
803 output.push_str("__");
804 }
805
806 output.extend(
807 key.chars()
808 .map(|character| character.to_ascii_uppercase()),
809 );
810 }
811}
812
813fn verify_rendered(rendered: &str, expected: &Dict) -> Result {
814 let output = Figment::new().merge(Data::nested(Toml::string(rendered)));
815 let actual = output.extract::<Dict>()?;
816
817 if !toml_equivalent(&actual, expected)? {
818 return Err!("Regenerated configuration failed its semantic round-trip check.");
819 }
820
821 Ok(())
822}
823
824fn verify_file(path: &Path, expected: &Dict) -> Result {
825 let output = Config::load_files([path].into_iter())?;
826 let actual = output.extract::<Dict>()?;
827
828 if !toml_equivalent(&actual, expected)? {
829 return Err!("Written configuration failed its semantic round-trip check.");
830 }
831
832 Ok(())
833}
834
835fn toml_equivalent(left: &Dict, right: &Dict) -> Result<bool> {
836 let left = TomlValue::try_from(left)
837 .map_err(|error| err!("Failed to normalize regenerated values: {error}"))?;
838
839 let right = TomlValue::try_from(right)
840 .map_err(|error| err!("Failed to normalize expected values: {error}"))?;
841
842 Ok(toml_values_equivalent(&left, &right))
843}
844
845fn toml_values_equivalent(left: &TomlValue, right: &TomlValue) -> bool {
846 match (left, right) {
847 | (TomlValue::Float(left), TomlValue::Float(right)) =>
848 left.is_nan() && right.is_nan()
849 || left
850 .partial_cmp(right)
851 .is_some_and(Ordering::is_eq),
852 | (TomlValue::Array(left), TomlValue::Array(right)) =>
853 left.len() == right.len()
854 && left
855 .iter()
856 .zip(right)
857 .all(|(left, right)| toml_values_equivalent(left, right)),
858 | (TomlValue::Table(left), TomlValue::Table(right)) =>
859 left.len() == right.len()
860 && left.iter().all(|(key, left)| {
861 right
862 .get(key)
863 .is_some_and(|right| toml_values_equivalent(left, right))
864 }),
865 | _ => left == right,
866 }
867}
868
869fn adjacent_new_path(input: &Path) -> PathBuf {
870 let mut output = input.as_os_str().to_owned();
871
872 output.push(".new");
873
874 output.into()
875}