1use std::{
10 collections::HashMap,
11 ffi::OsStr,
12 fs,
13 path::Component,
14 path::{Path, PathBuf},
15 sync::Arc,
16};
17
18use confique::{
19 Config, FileFormat, Layer,
20 meta::{Expr, FieldKind, LeafKind, MapKey, Meta},
21};
22use figment::{
23 Figment, Metadata, Profile, Provider, Source,
24 providers::{Env, Format, Json, Toml, Yaml},
25 value::{Dict, Map, Uncased},
26};
27use schemars::{JsonSchema, generate::SchemaSettings};
28use tracing::trace;
29
30use crate::{
31 ConfigError, ConfigSource, ConfigTree, ConfigTreeOptions, IncludeOrder, absolutize_lexical,
32 collect_template_targets, normalize_lexical, select_template_source,
33};
34
35pub type ConfigResult<T> = std::result::Result<T, ConfigError>;
39
40pub trait ConfigSchema: Config + Sized {
47 fn include_paths(layer: &<Self as Config>::Layer) -> Vec<PathBuf>;
60
61 fn template_path_for_section(section_path: &[&str]) -> Option<PathBuf> {
77 let _ = section_path;
78 None
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum ConfigFormat {
85 Yaml,
88 Toml,
90 Json,
92}
93
94impl ConfigFormat {
95 pub fn from_path(path: impl AsRef<Path>) -> Self {
107 match path.as_ref().extension().and_then(OsStr::to_str) {
108 Some("toml") => Self::Toml,
109 Some("json" | "json5") => Self::Json,
110 Some("yaml" | "yml") | Some(_) | None => Self::Yaml,
111 }
112 }
113
114 pub fn as_file_format(self) -> FileFormat {
120 match self {
121 Self::Yaml => FileFormat::Yaml,
122 Self::Toml => FileFormat::Toml,
123 Self::Json => FileFormat::Json5,
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ConfigTemplateTarget {
131 pub path: PathBuf,
133 pub content: String,
135}
136
137#[derive(Clone)]
146pub struct ConfiqueEnvProvider {
147 env: Env,
148 path_to_env: Arc<HashMap<String, String>>,
149}
150
151impl ConfiqueEnvProvider {
152 pub fn new<S>() -> Self
162 where
163 S: Config,
164 {
165 let mut env_to_path = HashMap::<String, String>::new();
166 let mut path_to_env = HashMap::<String, String>::new();
167
168 collect_env_mapping(&S::META, "", &mut env_to_path, &mut path_to_env);
169
170 let env_to_path = Arc::new(env_to_path);
171 let path_to_env = Arc::new(path_to_env);
172 let map_for_filter = Arc::clone(&env_to_path);
173
174 let env = Env::raw().filter_map(move |env_key| {
175 let lookup_key = env_key.as_str().to_ascii_uppercase();
176
177 map_for_filter
178 .get(&lookup_key)
179 .cloned()
180 .map(Uncased::from_owned)
181 });
182
183 Self { env, path_to_env }
184 }
185}
186
187impl Provider for ConfiqueEnvProvider {
188 fn metadata(&self) -> Metadata {
189 let path_to_env = Arc::clone(&self.path_to_env);
190
191 Metadata::named("environment variable").interpolater(move |_profile, keys| {
192 let path = keys.join(".");
193
194 path_to_env.get(&path).cloned().unwrap_or(path)
195 })
196 }
197
198 fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
199 self.env.data()
200 }
201}
202
203pub fn load_config<S>(path: impl AsRef<Path>) -> ConfigResult<S>
227where
228 S: ConfigSchema,
229{
230 let (config, _) = load_config_with_figment::<S>(path)?;
231 Ok(config)
232}
233
234pub fn load_config_with_figment<S>(path: impl AsRef<Path>) -> ConfigResult<(S, Figment)>
252where
253 S: ConfigSchema,
254{
255 let figment = build_config_figment::<S>(path)?;
256 let config = load_config_from_figment::<S>(&figment)?;
257
258 Ok((config, figment))
259}
260
261pub fn build_config_figment<S>(path: impl AsRef<Path>) -> ConfigResult<Figment>
278where
279 S: ConfigSchema,
280{
281 let path = path.as_ref();
282 load_dotenv_for_path(path)?;
283
284 let tree = load_layer_tree::<S>(path)?;
285 let mut figment = Figment::new();
286
287 for node in tree.nodes().iter().rev() {
288 figment = merge_file_provider(figment, node.path());
289 }
290
291 Ok(figment.merge(ConfiqueEnvProvider::new::<S>()))
292}
293
294pub fn load_config_from_figment<S>(figment: &Figment) -> ConfigResult<S>
311where
312 S: ConfigSchema,
313{
314 let runtime_layer: <S as Config>::Layer = figment.extract()?;
315 let config = S::from_layer(runtime_layer.with_fallback(S::Layer::default_values()))?;
316
317 trace_config_sources::<S>(figment);
318
319 Ok(config)
320}
321
322pub fn load_layer<S>(path: &Path) -> ConfigResult<<S as Config>::Layer>
337where
338 S: ConfigSchema,
339{
340 Ok(figment_for_file(path).extract()?)
341}
342
343fn load_layer_tree<S>(path: &Path) -> ConfigResult<ConfigTree<<S as Config>::Layer>>
344where
345 S: ConfigSchema,
346{
347 Ok(ConfigTreeOptions::default()
348 .include_order(IncludeOrder::Reverse)
349 .load(
350 path,
351 |path| -> ConfigResult<ConfigSource<<S as Config>::Layer>> {
352 let layer = load_layer::<S>(path)?;
353 let include_paths = S::include_paths(&layer);
354 Ok(ConfigSource::new(layer, include_paths))
355 },
356 )?)
357}
358
359fn merge_file_provider(figment: Figment, path: &Path) -> Figment {
360 match ConfigFormat::from_path(path) {
361 ConfigFormat::Yaml => figment.merge(Yaml::file_exact(path)),
362 ConfigFormat::Toml => figment.merge(Toml::file_exact(path)),
363 ConfigFormat::Json => figment.merge(Json::file_exact(path)),
364 }
365}
366
367fn figment_for_file(path: &Path) -> Figment {
368 merge_file_provider(Figment::new(), path)
369}
370
371pub fn write_config_schema<S>(output_path: impl AsRef<Path>) -> ConfigResult<()>
390where
391 S: JsonSchema,
392{
393 let generator = SchemaSettings::draft07().into_generator();
394 let schema = generator.into_root_schema_for::<S>();
395 let mut json = serde_json::to_string_pretty(&schema)?;
396 ensure_single_trailing_newline(&mut json);
397
398 write_template(output_path.as_ref(), &json)
399}
400
401pub fn template_for_path<S>(path: impl AsRef<Path>) -> ConfigResult<String>
417where
418 S: ConfigSchema,
419{
420 let template = match ConfigFormat::from_path(path.as_ref()) {
421 ConfigFormat::Yaml => confique::yaml::template::<S>(yaml_options()),
422 ConfigFormat::Toml => confique::toml::template::<S>(toml_options()),
423 ConfigFormat::Json => confique::json5::template::<S>(json5_options()),
424 };
425
426 Ok(template)
427}
428
429pub fn template_targets_for_paths<S>(
451 config_path: impl AsRef<Path>,
452 output_path: impl AsRef<Path>,
453) -> ConfigResult<Vec<ConfigTemplateTarget>>
454where
455 S: ConfigSchema,
456{
457 let output_path = output_path.as_ref();
458 let source_path = select_template_source(config_path, output_path);
459 let root_source_path = absolutize_lexical(source_path)?;
460 let output_base_dir = output_path.parent().unwrap_or_else(|| Path::new("."));
461
462 let template_targets = collect_template_targets(
463 &root_source_path,
464 output_path,
465 |node_source_path| -> ConfigResult<Vec<PathBuf>> {
466 let mut include_paths = template_source_include_paths::<S>(node_source_path)?;
467
468 if include_paths.is_empty() {
469 include_paths =
470 default_child_include_paths::<S>(&root_source_path, node_source_path);
471 }
472
473 Ok(include_paths)
474 },
475 )?;
476
477 let split_paths = template_targets
478 .iter()
479 .filter_map(|target| {
480 section_path_for_target::<S>(output_base_dir, target.target_path())
481 .filter(|section_path| !section_path.is_empty())
482 })
483 .collect::<Vec<_>>();
484
485 template_targets
486 .into_iter()
487 .map(|target| {
488 let (_, target_path, include_paths) = target.into_parts();
489 let section_path =
490 section_path_for_target::<S>(output_base_dir, &target_path).unwrap_or_default();
491 Ok(ConfigTemplateTarget {
492 content: template_for_target::<S>(
493 &target_path,
494 &include_paths,
495 §ion_path,
496 &split_paths,
497 )?,
498 path: target_path,
499 })
500 })
501 .collect()
502}
503
504pub fn template_targets_for_paths_with_schema<S>(
525 config_path: impl AsRef<Path>,
526 output_path: impl AsRef<Path>,
527 schema_path: impl AsRef<Path>,
528) -> ConfigResult<Vec<ConfigTemplateTarget>>
529where
530 S: ConfigSchema,
531{
532 template_targets_for_paths::<S>(config_path, output_path)?
533 .into_iter()
534 .map(|mut target| {
535 target.content = template_with_schema_directive(
536 &target.path,
537 schema_path.as_ref(),
538 &target.content,
539 )?;
540 Ok(target)
541 })
542 .collect()
543}
544
545pub fn write_config_templates<S>(
563 config_path: impl AsRef<Path>,
564 output_path: impl AsRef<Path>,
565) -> ConfigResult<()>
566where
567 S: ConfigSchema,
568{
569 for target in template_targets_for_paths::<S>(config_path, output_path)? {
570 write_template(&target.path, &target.content)?;
571 }
572
573 Ok(())
574}
575
576pub fn write_config_templates_with_schema<S>(
597 config_path: impl AsRef<Path>,
598 output_path: impl AsRef<Path>,
599 schema_path: impl AsRef<Path>,
600) -> ConfigResult<()>
601where
602 S: ConfigSchema,
603{
604 for target in
605 template_targets_for_paths_with_schema::<S>(config_path, output_path, schema_path)?
606 {
607 write_template(&target.path, &target.content)?;
608 }
609
610 Ok(())
611}
612
613pub(crate) fn write_template(path: &Path, content: &str) -> ConfigResult<()> {
624 if let Some(parent) = path
625 .parent()
626 .filter(|parent| !parent.as_os_str().is_empty())
627 {
628 fs::create_dir_all(parent)?;
629 }
630
631 fs::write(path, content)?;
632 Ok(())
633}
634
635pub(crate) fn resolve_config_template_output(output: Option<PathBuf>) -> ConfigResult<PathBuf> {
646 let current_dir = std::env::current_dir()?;
647 let output = output.unwrap_or_else(|| PathBuf::from("config.example.yaml"));
648 let output = if output.is_absolute() {
649 output
650 } else {
651 current_dir.join(output)
652 };
653
654 Ok(normalize_lexical(output))
655}
656
657fn template_source_include_paths<S>(path: &Path) -> ConfigResult<Vec<PathBuf>>
658where
659 S: ConfigSchema,
660{
661 if !path.exists() {
662 return Ok(Vec::new());
663 }
664
665 match load_layer::<S>(path) {
666 Ok(layer) => Ok(S::include_paths(&layer)),
667 Err(_) => load_include_paths_only(path),
668 }
669}
670
671fn load_include_paths_only(path: &Path) -> ConfigResult<Vec<PathBuf>> {
672 match figment_for_file(path).extract_inner::<Vec<PathBuf>>("include") {
673 Ok(paths) => Ok(paths),
674 Err(error) if error.missing() => Ok(Vec::new()),
675 Err(error) => Err(error.into()),
676 }
677}
678
679fn template_with_schema_directive(
680 template_path: &Path,
681 schema_path: &Path,
682 content: &str,
683) -> ConfigResult<String> {
684 let schema_ref = schema_reference_for_path(template_path, schema_path)?;
685 let directive = match ConfigFormat::from_path(template_path) {
686 ConfigFormat::Yaml => Some(format!("# yaml-language-server: $schema={schema_ref}")),
687 ConfigFormat::Toml => Some(format!("#:schema {schema_ref}")),
688 ConfigFormat::Json => None,
689 };
690
691 let Some(directive) = directive else {
692 return Ok(content.to_owned());
693 };
694
695 Ok(format!("{directive}\n\n{content}"))
696}
697
698fn schema_reference_for_path(template_path: &Path, schema_path: &Path) -> ConfigResult<String> {
699 let template_path = absolutize_lexical(template_path)?;
700 let schema_path = absolutize_lexical(schema_path)?;
701 let template_dir = template_path.parent().unwrap_or_else(|| Path::new("."));
702 let relative_path = relative_path_from(&schema_path, template_dir);
703 Ok(render_schema_reference(&relative_path))
704}
705
706fn relative_path_from(path: &Path, base: &Path) -> PathBuf {
707 let path_components = path.components().collect::<Vec<_>>();
708 let base_components = base.components().collect::<Vec<_>>();
709
710 let mut common_len = 0;
711 while common_len < path_components.len()
712 && common_len < base_components.len()
713 && path_components[common_len] == base_components[common_len]
714 {
715 common_len += 1;
716 }
717
718 if common_len == 0 {
719 return path.to_path_buf();
720 }
721
722 let mut relative = PathBuf::new();
723 for component in &base_components[common_len..] {
724 if matches!(component, Component::Normal(_)) {
725 relative.push("..");
726 }
727 }
728
729 for component in &path_components[common_len..] {
730 relative.push(component.as_os_str());
731 }
732
733 if relative.as_os_str().is_empty() {
734 PathBuf::from(".")
735 } else {
736 relative
737 }
738}
739
740fn render_schema_reference(path: &Path) -> String {
741 let value = path.to_string_lossy().replace('\\', "/");
742 if path.is_absolute() || value.starts_with("../") || value.starts_with("./") {
743 value
744 } else {
745 format!("./{value}")
746 }
747}
748
749fn template_for_target<S>(
750 path: &Path,
751 include_paths: &[PathBuf],
752 section_path: &[&'static str],
753 split_paths: &[Vec<&'static str>],
754) -> ConfigResult<String>
755where
756 S: ConfigSchema,
757{
758 if ConfigFormat::from_path(path) != ConfigFormat::Yaml || split_paths.is_empty() {
759 return template_for_path_with_includes::<S>(path, include_paths);
760 }
761
762 Ok(render_yaml_template(
763 &S::META,
764 include_paths,
765 section_path,
766 split_paths,
767 ))
768}
769
770fn default_child_include_paths<S>(root_source_path: &Path, node_source_path: &Path) -> Vec<PathBuf>
771where
772 S: ConfigSchema,
773{
774 let root_base_dir = root_source_path.parent().unwrap_or_else(|| Path::new("."));
775 let section_path =
776 section_path_for_target::<S>(root_base_dir, node_source_path).unwrap_or_default();
777 let source_base_dir = node_source_path.parent().unwrap_or_else(|| Path::new("."));
778
779 immediate_child_section_paths(&S::META, §ion_path)
780 .into_iter()
781 .map(|child_section_path| {
782 let child_path =
783 root_base_dir.join(template_path_for_section::<S>(&child_section_path));
784 path_relative_to(&child_path, source_base_dir)
785 })
786 .collect()
787}
788
789fn collect_env_mapping(
790 meta: &'static Meta,
791 prefix: &str,
792 env_to_path: &mut HashMap<String, String>,
793 path_to_env: &mut HashMap<String, String>,
794) {
795 for field in meta.fields {
796 let path = if prefix.is_empty() {
797 field.name.to_owned()
798 } else {
799 format!("{prefix}.{}", field.name)
800 };
801
802 match field.kind {
803 FieldKind::Leaf { env: Some(env), .. } => {
804 env_to_path.insert(env.to_ascii_uppercase(), path.clone());
805 path_to_env.insert(path, env.to_owned());
806 }
807 FieldKind::Leaf { env: None, .. } => {}
808 FieldKind::Nested { meta } => {
809 collect_env_mapping(meta, &path, env_to_path, path_to_env);
810 }
811 }
812 }
813}
814
815fn load_dotenv_for_path(path: &Path) -> ConfigResult<()> {
816 let path = absolutize_lexical(path)?;
817 let mut current_dir = path.parent();
818
819 while let Some(dir) = current_dir {
820 let dotenv_path = dir.join(".env");
821 if dotenv_path.try_exists()? {
822 dotenvy::from_path(&dotenv_path)?;
823 break;
824 }
825 current_dir = dir.parent();
826 }
827
828 Ok(())
829}
830
831fn section_path_for_target<S>(root_base_dir: &Path, target_path: &Path) -> Option<Vec<&'static str>>
832where
833 S: ConfigSchema,
834{
835 let normalized_target = normalize_lexical(target_path);
836
837 for section_path in nested_section_paths(&S::META) {
838 let section_target =
839 normalize_lexical(root_base_dir.join(template_path_for_section::<S>(§ion_path)));
840 if section_target == normalized_target {
841 return Some(section_path);
842 }
843 }
844
845 infer_section_path_from_path::<S>(target_path)
846}
847
848fn template_path_for_section<S>(section_path: &[&str]) -> PathBuf
849where
850 S: ConfigSchema,
851{
852 if let Some(path) = S::template_path_for_section(section_path) {
853 return path;
854 }
855
856 let Some((last, parent_path)) = section_path.split_last() else {
857 return PathBuf::new();
858 };
859
860 if parent_path.is_empty() {
861 return PathBuf::from("config").join(format!("{last}.yaml"));
862 }
863
864 let parent_template_path = template_path_for_section::<S>(parent_path);
865 parent_template_path
866 .with_extension("")
867 .join(format!("{last}.yaml"))
868}
869
870fn path_relative_to(path: &Path, base: &Path) -> PathBuf {
871 match path.strip_prefix(base) {
872 Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
873 _ => path.to_path_buf(),
874 }
875}
876
877fn nested_section_paths(meta: &'static Meta) -> Vec<Vec<&'static str>> {
878 let mut paths = Vec::new();
879 collect_nested_section_paths(meta, &mut Vec::new(), &mut paths);
880 paths
881}
882
883fn collect_nested_section_paths(
884 meta: &'static Meta,
885 prefix: &mut Vec<&'static str>,
886 paths: &mut Vec<Vec<&'static str>>,
887) {
888 for field in meta.fields {
889 if let FieldKind::Nested { meta } = field.kind {
890 prefix.push(field.name);
891 paths.push(prefix.clone());
892 collect_nested_section_paths(meta, prefix, paths);
893 prefix.pop();
894 }
895 }
896}
897
898fn immediate_child_section_paths(
899 meta: &'static Meta,
900 section_path: &[&'static str],
901) -> Vec<Vec<&'static str>> {
902 let Some(section_meta) = meta_at_path(meta, section_path) else {
903 return Vec::new();
904 };
905
906 section_meta
907 .fields
908 .iter()
909 .filter_map(|field| match field.kind {
910 FieldKind::Nested { .. } => {
911 let mut path = section_path.to_vec();
912 path.push(field.name);
913 Some(path)
914 }
915 FieldKind::Leaf { .. } => None,
916 })
917 .collect()
918}
919
920pub fn trace_config_sources<S>(figment: &Figment)
938where
939 S: ConfigSchema,
940{
941 if !tracing::enabled!(tracing::Level::TRACE) {
942 return;
943 }
944
945 for path in leaf_config_paths(&S::META) {
946 let source = config_source_for_path(figment, &path);
947 trace!(target: "rust_config_tree::config", config_key = %path, source = %source, "config source");
948 }
949}
950
951fn config_source_for_path(figment: &Figment, path: &str) -> String {
952 match figment.find_metadata(path) {
953 Some(metadata) => render_metadata(metadata, path),
954 None => "confique default or unset optional field".to_owned(),
955 }
956}
957
958fn render_metadata(metadata: &Metadata, path: &str) -> String {
959 match &metadata.source {
960 Some(Source::File(path)) => format!("{} `{}`", metadata.name, path.display()),
961 Some(Source::Custom(value)) => format!("{} `{value}`", metadata.name),
962 Some(Source::Code(location)) => {
963 format!("{} {}:{}", metadata.name, location.file(), location.line())
964 }
965 Some(_) => metadata.name.to_string(),
966 None => {
967 let parts = path.split('.').collect::<Vec<_>>();
968 let native = metadata.interpolate(&Profile::Default, &parts);
969
970 format!("{} `{native}`", metadata.name)
971 }
972 }
973}
974
975fn leaf_config_paths(meta: &'static Meta) -> Vec<String> {
976 let mut paths = Vec::new();
977 collect_leaf_config_paths(meta, "", &mut paths);
978 paths
979}
980
981fn collect_leaf_config_paths(meta: &'static Meta, prefix: &str, paths: &mut Vec<String>) {
982 for field in meta.fields {
983 let path = if prefix.is_empty() {
984 field.name.to_owned()
985 } else {
986 format!("{prefix}.{}", field.name)
987 };
988
989 match field.kind {
990 FieldKind::Leaf { .. } => paths.push(path),
991 FieldKind::Nested { meta } => collect_leaf_config_paths(meta, &path, paths),
992 }
993 }
994}
995
996fn infer_section_path_from_path<S>(path: &Path) -> Option<Vec<&'static str>>
997where
998 S: ConfigSchema,
999{
1000 let path_tokens = normalized_path_tokens(path);
1001 let file_token = path
1002 .file_stem()
1003 .and_then(OsStr::to_str)
1004 .map(normalize_token)
1005 .unwrap_or_default();
1006
1007 nested_section_paths(&S::META)
1008 .into_iter()
1009 .filter_map(|section_path| {
1010 let score = section_path_score(§ion_path, &path_tokens, &file_token);
1011 (score > 0).then_some((score, section_path))
1012 })
1013 .max_by_key(|(score, section_path)| (*score, section_path.len()))
1014 .map(|(_, section_path)| section_path)
1015}
1016
1017fn normalized_path_tokens(path: &Path) -> Vec<String> {
1018 path.components()
1019 .filter_map(|component| component.as_os_str().to_str())
1020 .map(|component| {
1021 Path::new(component)
1022 .file_stem()
1023 .and_then(OsStr::to_str)
1024 .unwrap_or(component)
1025 })
1026 .map(normalize_token)
1027 .filter(|component| !component.is_empty())
1028 .collect()
1029}
1030
1031fn normalize_token(token: &str) -> String {
1032 token
1033 .chars()
1034 .filter_map(|character| match character {
1035 '-' | ' ' => Some('_'),
1036 '_' => Some('_'),
1037 character if character.is_ascii_alphanumeric() => Some(character.to_ascii_lowercase()),
1038 _ => None,
1039 })
1040 .collect()
1041}
1042
1043fn section_path_score(section_path: &[&str], path_tokens: &[String], file_token: &str) -> usize {
1044 let section_tokens = section_path
1045 .iter()
1046 .map(|segment| normalize_token(segment))
1047 .collect::<Vec<_>>();
1048
1049 if path_tokens.ends_with(§ion_tokens) {
1050 return 1_000 + section_tokens.len();
1051 }
1052
1053 let Some(last_section_token) = section_tokens.last() else {
1054 return 0;
1055 };
1056
1057 if file_token == last_section_token {
1058 return 500 + section_tokens.len();
1059 }
1060
1061 if file_token.starts_with(last_section_token) || last_section_token.starts_with(file_token) {
1062 return 100 + last_section_token.len().min(file_token.len());
1063 }
1064
1065 0
1066}
1067
1068fn meta_at_path(meta: &'static Meta, section_path: &[&str]) -> Option<&'static Meta> {
1069 let mut current_meta = meta;
1070 for section in section_path {
1071 current_meta = current_meta.fields.iter().find_map(|field| {
1072 if field.name != *section {
1073 return None;
1074 }
1075
1076 match field.kind {
1077 FieldKind::Nested { meta } => Some(meta),
1078 FieldKind::Leaf { .. } => None,
1079 }
1080 })?;
1081 }
1082
1083 Some(current_meta)
1084}
1085
1086fn render_yaml_template(
1087 meta: &'static Meta,
1088 include_paths: &[PathBuf],
1089 section_path: &[&'static str],
1090 split_paths: &[Vec<&'static str>],
1091) -> String {
1092 let mut output = String::new();
1093 if !include_paths.is_empty() {
1094 output.push_str(&render_yaml_include(include_paths));
1095 output.push('\n');
1096 }
1097
1098 if section_path.is_empty() {
1099 render_yaml_fields(
1100 meta,
1101 &mut Vec::new(),
1102 split_paths,
1103 0,
1104 !include_paths.is_empty(),
1105 &mut output,
1106 );
1107 } else {
1108 render_yaml_section(meta, section_path, split_paths, &mut output);
1109 }
1110
1111 ensure_single_trailing_newline(&mut output);
1112 output
1113}
1114
1115fn render_yaml_section(
1116 meta: &'static Meta,
1117 section_path: &[&'static str],
1118 split_paths: &[Vec<&'static str>],
1119 output: &mut String,
1120) {
1121 let mut current_meta = meta;
1122 let mut current_path = Vec::new();
1123
1124 for (depth, section) in section_path.iter().enumerate() {
1125 write_yaml_indent(output, depth);
1126 output.push('#');
1127 output.push_str(section);
1128 output.push_str(":\n");
1129 current_path.push(*section);
1130
1131 let Some(next_meta) = meta_at_path(current_meta, &[*section]) else {
1132 return;
1133 };
1134 current_meta = next_meta;
1135 }
1136
1137 render_yaml_fields(
1138 current_meta,
1139 &mut current_path,
1140 split_paths,
1141 section_path.len(),
1142 false,
1143 output,
1144 );
1145}
1146
1147fn render_yaml_fields(
1148 meta: &'static Meta,
1149 current_path: &mut Vec<&'static str>,
1150 split_paths: &[Vec<&'static str>],
1151 depth: usize,
1152 skip_include_field: bool,
1153 output: &mut String,
1154) {
1155 let mut emitted_anything = false;
1156
1157 for field in meta.fields {
1158 let FieldKind::Leaf { env, kind } = field.kind else {
1159 continue;
1160 };
1161
1162 if skip_include_field && current_path.is_empty() && field.name == "include" {
1163 continue;
1164 }
1165
1166 if emitted_anything {
1167 output.push('\n');
1168 }
1169 emitted_anything = true;
1170 render_yaml_leaf(field.name, field.doc, env, kind, depth, output);
1171 }
1172
1173 for field in meta.fields {
1174 let FieldKind::Nested { meta } = field.kind else {
1175 continue;
1176 };
1177
1178 current_path.push(field.name);
1179 let split_exact = split_paths.iter().any(|path| path == current_path);
1180 let split_descendant = split_paths
1181 .iter()
1182 .any(|path| path.starts_with(current_path) && path.len() > current_path.len());
1183
1184 if split_exact {
1185 current_path.pop();
1186 continue;
1187 }
1188
1189 if emitted_anything {
1190 output.push('\n');
1191 }
1192 emitted_anything = true;
1193
1194 for doc in field.doc {
1195 write_yaml_indent(output, depth);
1196 output.push('#');
1197 output.push_str(doc);
1198 output.push('\n');
1199 }
1200 write_yaml_indent(output, depth);
1201 output.push_str(field.name);
1202 output.push_str(":\n");
1203
1204 let child_split_paths = if split_descendant { split_paths } else { &[] };
1205 render_yaml_fields(
1206 meta,
1207 current_path,
1208 child_split_paths,
1209 depth + 1,
1210 false,
1211 output,
1212 );
1213 current_path.pop();
1214 }
1215}
1216
1217fn render_yaml_leaf(
1218 name: &str,
1219 doc: &[&str],
1220 env: Option<&str>,
1221 kind: LeafKind,
1222 depth: usize,
1223 output: &mut String,
1224) {
1225 let mut emitted_doc_comment = false;
1226 for doc in doc {
1227 write_yaml_indent(output, depth);
1228 output.push('#');
1229 output.push_str(doc);
1230 output.push('\n');
1231 emitted_doc_comment = true;
1232 }
1233
1234 if let Some(env) = env {
1235 if emitted_doc_comment {
1236 write_yaml_indent(output, depth);
1237 output.push_str("#\n");
1238 }
1239 write_yaml_indent(output, depth);
1240 output.push_str("# Can also be specified via environment variable `");
1241 output.push_str(env);
1242 output.push_str("`.\n");
1243 }
1244
1245 match kind {
1246 LeafKind::Optional => {
1247 write_yaml_indent(output, depth);
1248 output.push('#');
1249 output.push_str(name);
1250 output.push_str(":\n");
1251 }
1252 LeafKind::Required { default } => {
1253 write_yaml_indent(output, depth);
1254 match default {
1255 Some(default) => {
1256 output.push_str("# Default value: ");
1257 output.push_str(&render_yaml_expr(&default));
1258 output.push('\n');
1259 write_yaml_indent(output, depth);
1260 output.push('#');
1261 output.push_str(name);
1262 output.push_str(": ");
1263 output.push_str(&render_yaml_expr(&default));
1264 output.push('\n');
1265 }
1266 None => {
1267 output.push_str("# Required! This value must be specified.\n");
1268 write_yaml_indent(output, depth);
1269 output.push('#');
1270 output.push_str(name);
1271 output.push_str(":\n");
1272 }
1273 }
1274 }
1275 }
1276}
1277
1278fn render_yaml_expr(expr: &Expr) -> String {
1279 match expr {
1280 Expr::Str(value) => render_plain_or_quoted_string(value),
1281 Expr::Float(value) => value.to_string(),
1282 Expr::Integer(value) => value.to_string(),
1283 Expr::Bool(value) => value.to_string(),
1284 Expr::Array(items) => {
1285 let items = items
1286 .iter()
1287 .map(render_yaml_expr)
1288 .collect::<Vec<_>>()
1289 .join(", ");
1290 format!("[{items}]")
1291 }
1292 Expr::Map(entries) => {
1293 let entries = entries
1294 .iter()
1295 .map(|entry| {
1296 format!(
1297 "{}: {}",
1298 render_yaml_map_key(&entry.key),
1299 render_yaml_expr(&entry.value)
1300 )
1301 })
1302 .collect::<Vec<_>>()
1303 .join(", ");
1304 format!("{{ {entries} }}")
1305 }
1306 _ => String::new(),
1307 }
1308}
1309
1310fn render_yaml_map_key(key: &MapKey) -> String {
1311 match key {
1312 MapKey::Str(value) => render_plain_or_quoted_string(value),
1313 MapKey::Float(value) => value.to_string(),
1314 MapKey::Integer(value) => value.to_string(),
1315 MapKey::Bool(value) => value.to_string(),
1316 _ => String::new(),
1317 }
1318}
1319
1320fn render_plain_or_quoted_string(value: &str) -> String {
1321 let needs_quotes = value.is_empty()
1322 || value.starts_with([
1323 ' ', '#', '{', '}', '[', ']', ',', '&', '*', '!', '|', '>', '\'', '"',
1324 ])
1325 || value.contains([':', '\n', '\r', '\t']);
1326
1327 if needs_quotes {
1328 quote_path(Path::new(value))
1329 } else {
1330 value.to_owned()
1331 }
1332}
1333
1334fn write_yaml_indent(output: &mut String, depth: usize) {
1335 for _ in 0..depth {
1336 output.push_str(" ");
1337 }
1338}
1339
1340fn ensure_single_trailing_newline(output: &mut String) {
1341 if output.ends_with('\n') {
1342 while output.ends_with("\n\n") {
1343 output.pop();
1344 }
1345 } else {
1346 output.push('\n');
1347 }
1348}
1349
1350fn template_for_path_with_includes<S>(
1351 path: &Path,
1352 include_paths: &[PathBuf],
1353) -> ConfigResult<String>
1354where
1355 S: ConfigSchema,
1356{
1357 let template = template_for_path::<S>(path)?;
1358 if include_paths.is_empty() {
1359 return Ok(template);
1360 }
1361
1362 let template = match ConfigFormat::from_path(path) {
1363 ConfigFormat::Yaml => {
1364 let template = strip_prefix_once(&template, "# Default value: []\n#include: []\n\n");
1365 format!("{}\n{template}", render_yaml_include(include_paths))
1366 }
1367 ConfigFormat::Toml => {
1368 let template = strip_prefix_once(&template, "# Default value: []\n#include = []\n\n");
1369 format!("{}\n{template}", render_toml_include(include_paths))
1370 }
1371 ConfigFormat::Json => {
1372 let body = template.strip_prefix("{\n").unwrap_or(&template);
1373 let body = strip_prefix_once(body, " // Default value: []\n //include: [],\n\n");
1374 format!("{{\n{}\n{body}", render_json5_include(include_paths))
1375 }
1376 };
1377
1378 Ok(template)
1379}
1380
1381fn render_yaml_include(paths: &[PathBuf]) -> String {
1382 let mut out = String::from("include:\n");
1383 for path in paths {
1384 out.push_str(" - ");
1385 out.push_str("e_path(path));
1386 out.push('\n');
1387 }
1388 out
1389}
1390
1391fn render_toml_include(paths: &[PathBuf]) -> String {
1392 let entries = paths
1393 .iter()
1394 .map(|path| quote_path(path))
1395 .collect::<Vec<_>>()
1396 .join(", ");
1397 format!("include = [{entries}]\n")
1398}
1399
1400fn render_json5_include(paths: &[PathBuf]) -> String {
1401 let mut out = String::from(" include: [\n");
1402 for path in paths {
1403 out.push_str(" ");
1404 out.push_str("e_path(path));
1405 out.push_str(",\n");
1406 }
1407 out.push_str(" ],\n");
1408 out
1409}
1410
1411fn quote_path(path: &Path) -> String {
1412 serde_json::to_string(&path.to_string_lossy()).expect("path string serialization cannot fail")
1413}
1414
1415fn strip_prefix_once<'a>(value: &'a str, prefix: &str) -> &'a str {
1416 value.strip_prefix(prefix).unwrap_or(value)
1417}
1418
1419fn yaml_options() -> confique::yaml::FormatOptions {
1420 let mut options = confique::yaml::FormatOptions::default();
1421 options.indent = 2;
1422 options.general.comments = true;
1423 options.general.env_keys = true;
1424 options.general.nested_field_gap = 1;
1425 options
1426}
1427
1428fn toml_options() -> confique::toml::FormatOptions {
1429 let mut options = confique::toml::FormatOptions::default();
1430 options.general.comments = true;
1431 options.general.env_keys = true;
1432 options.general.nested_field_gap = 1;
1433 options
1434}
1435
1436fn json5_options() -> confique::json5::FormatOptions {
1437 let mut options = confique::json5::FormatOptions::default();
1438 options.indent = 2;
1439 options.general.comments = true;
1440 options.general.env_keys = true;
1441 options.general.nested_field_gap = 1;
1442 options
1443}
1444
1445#[cfg(test)]
1446#[path = "unit_tests/config.rs"]
1447mod unit_tests;