1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::ffi::OsStr;
4use std::fmt::{self, Display, Formatter};
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, OnceLock};
8
9use regex::Regex;
10use serde::Serialize;
11use serde::de::{
12 DeserializeOwned, IntoDeserializer, MapAccess, SeqAccess, Visitor,
13 value::{Error as ValueDeError, MapAccessDeserializer},
14};
15use serde_json::{Map, Value};
16
17#[cfg(any(feature = "json", feature = "toml", feature = "yaml"))]
18use crate::error::LineColumn;
19use crate::error::{ConfigError, UnknownField, ValidationErrors};
20#[cfg(feature = "schema")]
21use crate::export::{json_pretty, json_value};
22use crate::patch::DeferredPatchLayer;
23use crate::report::{
24 AppliedMigration, ConfigReport, ConfigWarning, DeprecatedField, ResolutionStep,
25 canonicalize_path_with_aliases, collect_diff_paths, collect_paths, get_value_at_path,
26 join_path, normalize_path, path_matches_pattern, path_overlaps_pattern,
27 path_starts_with_pattern, redact_value,
28};
29use crate::{ConfigMetadata, EnvDecoder, MergeStrategy, TierMetadata, TierPatch};
30
31mod canonical;
32mod de;
33mod env;
34mod load;
35mod merge;
36mod overrides;
37mod path;
38mod policy;
39mod unknown;
40mod validation;
41
42use self::canonical::*;
43use self::de::deserialize_with_path;
44use self::merge::*;
45use self::overrides::*;
46use self::path::*;
47use self::policy::enforce_source_policies;
48use self::unknown::*;
49use self::validation::{validate_declared_checks, validate_declared_rules};
50
51pub(crate) use self::de::insert_path;
52pub(crate) use self::load::is_secret_path;
53pub(crate) use self::path::record_direct_array_state;
54
55type Normalizer<T> = Box<dyn Fn(&mut T) -> Result<(), String> + Send + Sync>;
56type Validator<T> = Box<dyn Fn(&T) -> Result<(), ValidationErrors> + Send + Sync>;
57type CustomEnvDecoder = Arc<dyn Fn(&str) -> Result<Value, String> + Send + Sync>;
58
59#[derive(
60 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
61)]
62#[serde(rename_all = "snake_case")]
63pub enum SourceKind {
65 Default,
67 File,
69 Environment,
71 Arguments,
73 Normalization,
75 Custom,
77}
78
79impl Display for SourceKind {
80 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::Default => write!(f, "default"),
83 Self::File => write!(f, "file"),
84 Self::Environment => write!(f, "env"),
85 Self::Arguments => write!(f, "cli"),
86 Self::Normalization => write!(f, "normalize"),
87 Self::Custom => write!(f, "custom"),
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum UnknownFieldPolicy {
95 Allow,
97 Warn,
99 #[default]
100 Deny,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
105pub enum ConfigMigrationKind {
107 Rename {
109 from: String,
111 to: String,
113 },
114 Remove {
116 path: String,
118 },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct ConfigMigration {
124 pub since_version: u32,
126 pub kind: ConfigMigrationKind,
128 pub note: Option<String>,
130}
131
132impl ConfigMigration {
133 #[must_use]
135 pub fn rename(from: impl Into<String>, to: impl Into<String>, since_version: u32) -> Self {
136 Self {
137 since_version,
138 kind: ConfigMigrationKind::Rename {
139 from: from.into(),
140 to: to.into(),
141 },
142 note: None,
143 }
144 }
145
146 #[must_use]
148 pub fn remove(path: impl Into<String>, since_version: u32) -> Self {
149 Self {
150 since_version,
151 kind: ConfigMigrationKind::Remove { path: path.into() },
152 note: None,
153 }
154 }
155
156 #[must_use]
158 pub fn with_note(mut self, note: impl Into<String>) -> Self {
159 self.note = Some(note.into());
160 self
161 }
162}
163
164impl Display for UnknownFieldPolicy {
165 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
166 match self {
167 Self::Allow => write!(f, "allow"),
168 Self::Warn => write!(f, "warn"),
169 Self::Deny => write!(f, "deny"),
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
175pub struct SourceTrace {
177 pub kind: SourceKind,
179 pub name: String,
181 pub location: Option<String>,
183}
184
185impl SourceTrace {
186 fn new(kind: SourceKind, name: impl Into<String>) -> Self {
187 Self {
188 kind,
189 name: name.into(),
190 location: None,
191 }
192 }
193}
194
195impl Display for SourceTrace {
196 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
197 match &self.location {
198 Some(location) if self.name.is_empty() => write!(f, "{}({location})", self.kind),
199 Some(location) => write!(f, "{}({}:{location})", self.kind, self.name),
200 None if self.name.is_empty() => write!(f, "{}", self.kind),
201 None => write!(f, "{}({})", self.kind, self.name),
202 }
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum FileFormat {
209 Json,
211 Toml,
213 Yaml,
215}
216
217impl Display for FileFormat {
218 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
219 match self {
220 Self::Json => write!(f, "json"),
221 Self::Toml => write!(f, "toml"),
222 Self::Yaml => write!(f, "yaml"),
223 }
224 }
225}
226
227#[derive(Debug, Clone)]
228pub struct FileSource {
261 candidates: Vec<PathBuf>,
262 required: bool,
263 format: Option<FileFormat>,
264}
265
266impl FileSource {
267 #[must_use]
269 pub fn new(path: impl Into<PathBuf>) -> Self {
270 Self {
271 candidates: vec![path.into()],
272 required: true,
273 format: None,
274 }
275 }
276
277 #[must_use]
279 pub fn optional(path: impl Into<PathBuf>) -> Self {
280 Self {
281 candidates: vec![path.into()],
282 required: false,
283 format: None,
284 }
285 }
286
287 #[must_use]
289 pub fn search<I, P>(paths: I) -> Self
290 where
291 I: IntoIterator<Item = P>,
292 P: Into<PathBuf>,
293 {
294 Self {
295 candidates: paths.into_iter().map(Into::into).collect(),
296 required: true,
297 format: None,
298 }
299 }
300
301 #[must_use]
303 pub fn optional_search<I, P>(paths: I) -> Self
304 where
305 I: IntoIterator<Item = P>,
306 P: Into<PathBuf>,
307 {
308 Self {
309 candidates: paths.into_iter().map(Into::into).collect(),
310 required: false,
311 format: None,
312 }
313 }
314
315 #[must_use]
317 pub fn candidates(&self) -> &[PathBuf] {
318 &self.candidates
319 }
320
321 #[must_use]
323 pub fn format(mut self, format: FileFormat) -> Self {
324 self.format = Some(format);
325 self
326 }
327}
328
329#[derive(Debug, Clone)]
330pub struct EnvSource {
367 vars: BTreeMap<String, String>,
368 prefix: Option<String>,
369 separator: String,
370 lowercase_segments: bool,
371 bindings: BTreeMap<String, EnvBinding>,
372 binding_conflicts: Vec<EnvBindingConflict>,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
376struct EnvBinding {
377 path: String,
378 decoder: Option<EnvDecoder>,
379 fallback: bool,
380}
381
382#[derive(Debug, Clone)]
383struct EnvBindingConflict {
384 name: String,
385 first: EnvBinding,
386 second: EnvBinding,
387}
388
389impl EnvSource {
390 #[must_use]
392 pub fn from_env() -> Self {
393 Self::from_pairs(std::env::vars())
394 }
395
396 #[must_use]
398 pub fn prefixed(prefix: impl Into<String>) -> Self {
399 Self::from_env().prefix(prefix)
400 }
401
402 #[must_use]
404 pub fn from_pairs<I, K, V>(iter: I) -> Self
405 where
406 I: IntoIterator<Item = (K, V)>,
407 K: Into<String>,
408 V: Into<String>,
409 {
410 let vars = iter
411 .into_iter()
412 .map(|(key, value)| (key.into(), value.into()))
413 .collect();
414 Self {
415 vars,
416 prefix: None,
417 separator: "__".to_owned(),
418 lowercase_segments: true,
419 bindings: BTreeMap::new(),
420 binding_conflicts: Vec::new(),
421 }
422 }
423
424 #[must_use]
426 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
427 self.prefix = Some(prefix.into());
428 self
429 }
430
431 #[must_use]
433 pub fn separator(mut self, separator: impl Into<String>) -> Self {
434 let separator = separator.into();
435 if !separator.is_empty() {
436 self.separator = separator;
437 }
438 self
439 }
440
441 #[must_use]
443 pub fn preserve_case(mut self) -> Self {
444 self.lowercase_segments = false;
445 self
446 }
447
448 #[must_use]
453 pub fn with_alias(mut self, name: impl Into<String>, path: impl Into<String>) -> Self {
454 self.insert_binding(
455 name.into(),
456 EnvBinding {
457 path: path.into(),
458 decoder: None,
459 fallback: false,
460 },
461 );
462 self
463 }
464
465 #[must_use]
468 pub fn with_alias_decoder(
469 mut self,
470 name: impl Into<String>,
471 path: impl Into<String>,
472 decoder: EnvDecoder,
473 ) -> Self {
474 self.insert_binding(
475 name.into(),
476 EnvBinding {
477 path: path.into(),
478 decoder: Some(decoder),
479 fallback: false,
480 },
481 );
482 self
483 }
484
485 #[must_use]
490 pub fn with_fallback(mut self, name: impl Into<String>, path: impl Into<String>) -> Self {
491 self.insert_binding(
492 name.into(),
493 EnvBinding {
494 path: path.into(),
495 decoder: None,
496 fallback: true,
497 },
498 );
499 self
500 }
501
502 #[must_use]
505 pub fn with_fallback_decoder(
506 mut self,
507 name: impl Into<String>,
508 path: impl Into<String>,
509 decoder: EnvDecoder,
510 ) -> Self {
511 self.insert_binding(
512 name.into(),
513 EnvBinding {
514 path: path.into(),
515 decoder: Some(decoder),
516 fallback: true,
517 },
518 );
519 self
520 }
521
522 fn insert_binding(&mut self, name: String, binding: EnvBinding) {
523 if let Some(existing) = self.bindings.get(&name) {
524 if existing != &binding {
525 self.binding_conflicts.push(EnvBindingConflict {
526 name: name.clone(),
527 first: existing.clone(),
528 second: binding,
529 });
530 }
531 return;
532 }
533
534 self.bindings.insert(name, binding);
535 }
536}
537
538#[derive(Debug, Clone)]
539pub struct ArgsSource {
569 args: Vec<String>,
570}
571
572impl ArgsSource {
573 #[must_use]
575 pub fn from_env() -> Self {
576 Self::from_args(std::env::args())
577 }
578
579 #[must_use]
581 pub fn from_args<I, S>(iter: I) -> Self
582 where
583 I: IntoIterator<Item = S>,
584 S: Into<String>,
585 {
586 Self {
587 args: iter.into_iter().map(Into::into).collect(),
588 }
589 }
590}
591
592#[derive(Debug, Clone)]
593pub struct Layer {
595 trace: SourceTrace,
596 value: Value,
597 entries: BTreeMap<String, SourceTrace>,
598 coercible_string_paths: BTreeSet<String>,
599 indexed_array_paths: BTreeSet<String>,
600 indexed_array_base_lengths: BTreeMap<String, usize>,
601 direct_array_paths: BTreeSet<String>,
602}
603
604impl Layer {
605 pub fn custom<T>(name: impl Into<String>, value: T) -> Result<Self, ConfigError>
607 where
608 T: Serialize,
609 {
610 Self::from_serializable(SourceTrace::new(SourceKind::Custom, name), value)
611 }
612
613 fn from_serializable<T>(trace: SourceTrace, value: T) -> Result<Self, ConfigError>
614 where
615 T: Serialize,
616 {
617 let value = serde_json::to_value(value)?;
618 Self::from_value(trace, value)
619 }
620
621 fn from_value(trace: SourceTrace, value: Value) -> Result<Self, ConfigError> {
622 ensure_root_object(&value)?;
623 ensure_path_safe_keys(&value, "")?;
624
625 let mut paths = Vec::new();
626 collect_paths(&value, "", &mut paths);
627 let entries = paths
628 .into_iter()
629 .map(|path| (path, trace.clone()))
630 .collect::<BTreeMap<_, _>>();
631
632 Ok(Self {
633 trace,
634 value,
635 entries,
636 coercible_string_paths: BTreeSet::new(),
637 indexed_array_paths: BTreeSet::new(),
638 indexed_array_base_lengths: BTreeMap::new(),
639 direct_array_paths: BTreeSet::new(),
640 })
641 }
642
643 pub(crate) fn from_parts(
644 trace: SourceTrace,
645 value: Value,
646 entries: BTreeMap<String, SourceTrace>,
647 coercible_string_paths: BTreeSet<String>,
648 indexed_array_paths: BTreeSet<String>,
649 indexed_array_base_lengths: BTreeMap<String, usize>,
650 direct_array_paths: BTreeSet<String>,
651 ) -> Self {
652 Self {
653 trace,
654 value,
655 entries,
656 coercible_string_paths,
657 indexed_array_paths,
658 indexed_array_base_lengths,
659 direct_array_paths,
660 }
661 }
662
663 pub fn from_patch<P>(name: impl Into<String>, patch: &P) -> Result<Self, ConfigError>
686 where
687 P: TierPatch,
688 {
689 Self::from_patch_with_trace(
690 SourceTrace {
691 kind: SourceKind::Custom,
692 name: name.into(),
693 location: None,
694 },
695 patch,
696 )
697 }
698
699 pub(crate) fn from_patch_with_trace<P>(
700 trace: SourceTrace,
701 patch: &P,
702 ) -> Result<Self, ConfigError>
703 where
704 P: TierPatch,
705 {
706 let mut builder = crate::patch::PatchLayerBuilder::from_trace(trace);
707 patch.write_layer(&mut builder, "")?;
708 Ok(builder.finish())
709 }
710}
711
712struct NamedNormalizer<T> {
713 name: String,
714 run: Normalizer<T>,
715}
716
717struct NamedValidator<T> {
718 name: String,
719 run: Validator<T>,
720}
721
722enum PendingCustomLayer {
723 Immediate(Layer),
724 DeferredPatch(DeferredPatchLayer),
725}
726
727#[derive(Debug, Clone)]
728struct ParsedArgs {
729 profile: Option<String>,
730 files: Vec<FileSource>,
731 layer: Option<Layer>,
732}
733
734#[derive(Debug)]
735pub struct LoadedConfig<T> {
737 config: T,
738 report: ConfigReport,
739}
740
741impl<T> LoadedConfig<T> {
742 #[must_use]
744 pub fn config(&self) -> &T {
745 &self.config
746 }
747
748 #[must_use]
750 pub fn report(&self) -> &ConfigReport {
751 &self.report
752 }
753
754 pub fn into_parts(self) -> (T, ConfigReport) {
756 (self.config, self.report)
757 }
758
759 pub fn into_inner(self) -> T {
761 self.config
762 }
763}
764
765impl<T> Clone for LoadedConfig<T>
766where
767 T: Clone,
768{
769 fn clone(&self) -> Self {
770 Self {
771 config: self.config.clone(),
772 report: self.report.clone(),
773 }
774 }
775}
776
777impl<T> std::ops::Deref for LoadedConfig<T> {
778 type Target = T;
779
780 fn deref(&self) -> &Self::Target {
781 &self.config
782 }
783}
784
785#[cfg(feature = "schema")]
786impl<T> LoadedConfig<T>
787where
788 T: Serialize + DeserializeOwned + crate::JsonSchema + crate::TierMetadata,
789{
790 #[must_use]
792 pub fn export_bundle(&self, options: &crate::EnvDocOptions) -> crate::ExportBundleReport {
793 crate::ExportBundleReport {
794 format_version: crate::EXPORT_BUNDLE_FORMAT_VERSION,
795 doctor: self.report.doctor_report(),
796 audit: self.report.audit_report(),
797 env_docs: crate::env_docs_report::<T>(options),
798 json_schema: crate::json_schema_report::<T>(),
799 annotated_json_schema: crate::annotated_json_schema_report::<T>(),
800 example: crate::config_example_report::<T>(),
801 }
802 }
803
804 #[must_use]
806 pub fn export_bundle_json(&self, options: &crate::EnvDocOptions) -> Value {
807 json_value(
808 &self.export_bundle(options),
809 Value::Object(Default::default()),
810 )
811 }
812
813 #[must_use]
815 pub fn export_bundle_json_pretty(&self, options: &crate::EnvDocOptions) -> String {
816 json_pretty(
817 &self.export_bundle_json(options),
818 "{\"error\":\"failed to render export bundle\"}",
819 )
820 }
821}
822
823pub struct ConfigLoader<T> {
860 defaults: T,
861 files: Vec<FileSource>,
862 env_sources: Vec<EnvSource>,
863 args_source: Option<ArgsSource>,
864 custom_layers: Vec<PendingCustomLayer>,
865 typed_arg_layers: Vec<DeferredPatchLayer>,
866 metadata: ConfigMetadata,
867 secret_paths: BTreeSet<String>,
868 normalizers: Vec<NamedNormalizer<T>>,
869 validators: Vec<NamedValidator<T>>,
870 profile: Option<String>,
871 unknown_field_policy: UnknownFieldPolicy,
872 env_decoders: BTreeMap<String, EnvDecoder>,
873 custom_env_decoders: BTreeMap<String, CustomEnvDecoder>,
874 config_version: Option<(String, u32)>,
875 migrations: Vec<ConfigMigration>,
876}
877
878impl<T> ConfigLoader<T>
879where
880 T: Serialize + DeserializeOwned,
881{
882 #[must_use]
884 pub fn new(defaults: T) -> Self {
885 Self {
886 defaults,
887 files: Vec::new(),
888 env_sources: Vec::new(),
889 args_source: None,
890 custom_layers: Vec::new(),
891 typed_arg_layers: Vec::new(),
892 metadata: ConfigMetadata::default(),
893 secret_paths: BTreeSet::new(),
894 normalizers: Vec::new(),
895 validators: Vec::new(),
896 profile: None,
897 unknown_field_policy: UnknownFieldPolicy::Deny,
898 env_decoders: BTreeMap::new(),
899 custom_env_decoders: BTreeMap::new(),
900 config_version: None,
901 migrations: Vec::new(),
902 }
903 }
904
905 #[must_use]
907 pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
908 self.files.push(FileSource::new(path));
909 self
910 }
911
912 #[must_use]
914 pub fn optional_file(mut self, path: impl Into<PathBuf>) -> Self {
915 self.files.push(FileSource::optional(path));
916 self
917 }
918
919 #[must_use]
921 pub fn with_file(mut self, file: FileSource) -> Self {
922 self.files.push(file);
923 self
924 }
925
926 #[must_use]
928 pub fn env(mut self, source: EnvSource) -> Self {
929 self.env_sources.push(source);
930 self
931 }
932
933 #[must_use]
971 pub fn env_decoder(mut self, path: impl Into<String>, decoder: EnvDecoder) -> Self {
972 let path = path.into();
973 self.env_decoders.insert(path, decoder);
974 self
975 }
976
977 #[must_use]
1013 pub fn env_decoder_with<F>(mut self, path: impl Into<String>, decoder: F) -> Self
1014 where
1015 F: Fn(&str) -> Result<Value, String> + Send + Sync + 'static,
1016 {
1017 let path = path.into();
1018 self.custom_env_decoders.insert(path, Arc::new(decoder));
1019 self
1020 }
1021
1022 #[must_use]
1025 pub fn config_version(mut self, path: impl Into<String>, current_version: u32) -> Self {
1026 self.config_version = Some((path.into(), current_version));
1027 self
1028 }
1029
1030 #[must_use]
1032 pub fn migration(mut self, migration: ConfigMigration) -> Self {
1033 self.migrations.push(migration);
1034 self
1035 }
1036
1037 #[must_use]
1039 pub fn args(mut self, source: ArgsSource) -> Self {
1040 self.args_source = Some(source);
1041 self
1042 }
1043
1044 pub fn layer(mut self, layer: Layer) -> Self {
1046 self.custom_layers
1047 .push(PendingCustomLayer::Immediate(layer));
1048 self
1049 }
1050
1051 pub fn patch<P>(mut self, name: impl Into<String>, patch: &P) -> Result<Self, ConfigError>
1084 where
1085 P: TierPatch,
1086 {
1087 let mut builder = crate::patch::PatchLayerBuilder::from_trace_deferred(SourceTrace {
1088 kind: SourceKind::Custom,
1089 name: name.into(),
1090 location: None,
1091 });
1092 patch.write_layer(&mut builder, "")?;
1093 let layer = builder.finish_deferred();
1094 if !layer.is_empty() {
1095 self.custom_layers
1096 .push(PendingCustomLayer::DeferredPatch(layer));
1097 }
1098 Ok(self)
1099 }
1100
1101 #[cfg(feature = "clap")]
1102 pub fn clap_overrides<P>(mut self, patch: &P) -> Result<Self, ConfigError>
1151 where
1152 P: TierPatch,
1153 {
1154 let mut builder = crate::patch::PatchLayerBuilder::from_trace_deferred(SourceTrace {
1155 kind: SourceKind::Arguments,
1156 name: "typed-clap".to_owned(),
1157 location: None,
1158 });
1159 patch.write_layer(&mut builder, "")?;
1160 let layer = builder.finish_deferred();
1161 if !layer.is_empty() {
1162 self.typed_arg_layers.push(layer);
1163 }
1164 Ok(self)
1165 }
1166
1167 #[cfg(feature = "clap")]
1168 pub fn clap_overrides_from<C, P, F>(self, cli: &C, project: F) -> Result<Self, ConfigError>
1223 where
1224 P: TierPatch,
1225 F: FnOnce(&C) -> &P,
1226 {
1227 self.clap_overrides(project(cli))
1228 }
1229
1230 #[must_use]
1232 pub fn secret_path(mut self, path: impl Into<String>) -> Self {
1233 let path = path.into();
1234 if !path.is_empty() && path != "." {
1235 self.secret_paths.insert(path);
1236 }
1237 self
1238 }
1239
1240 #[must_use]
1246 pub fn metadata(mut self, metadata: ConfigMetadata) -> Self {
1247 self.secret_paths.extend(metadata.secret_paths());
1248 self.metadata.extend(metadata);
1249 self
1250 }
1251
1252 #[must_use]
1254 pub fn profile(mut self, profile: impl Into<String>) -> Self {
1255 self.profile = Some(profile.into());
1256 self
1257 }
1258
1259 #[must_use]
1264 pub fn derive_metadata(self) -> Self
1265 where
1266 T: TierMetadata,
1267 {
1268 self.metadata(T::metadata())
1269 }
1270
1271 #[must_use]
1273 pub fn unknown_field_policy(mut self, policy: UnknownFieldPolicy) -> Self {
1274 self.unknown_field_policy = policy;
1275 self
1276 }
1277
1278 #[must_use]
1280 pub fn allow_unknown_fields(self) -> Self {
1281 self.unknown_field_policy(UnknownFieldPolicy::Allow)
1282 }
1283
1284 #[must_use]
1286 pub fn warn_unknown_fields(self) -> Self {
1287 self.unknown_field_policy(UnknownFieldPolicy::Warn)
1288 }
1289
1290 #[must_use]
1292 pub fn deny_unknown_fields(self) -> Self {
1293 self.unknown_field_policy(UnknownFieldPolicy::Deny)
1294 }
1295
1296 #[must_use]
1298 pub fn normalizer<F, E>(mut self, name: impl Into<String>, normalizer: F) -> Self
1299 where
1300 F: Fn(&mut T) -> Result<(), E> + Send + Sync + 'static,
1301 E: Display,
1302 {
1303 self.normalizers.push(NamedNormalizer {
1304 name: name.into(),
1305 run: Box::new(move |config| normalizer(config).map_err(|error| error.to_string())),
1306 });
1307 self
1308 }
1309
1310 #[must_use]
1312 pub fn validator<F>(mut self, name: impl Into<String>, validator: F) -> Self
1313 where
1314 F: Fn(&T) -> Result<(), ValidationErrors> + Send + Sync + 'static,
1315 {
1316 self.validators.push(NamedValidator {
1317 name: name.into(),
1318 run: Box::new(validator),
1319 });
1320 self
1321 }
1322}
1323
1324#[cfg(feature = "schema")]
1325impl<T> ConfigLoader<T>
1326where
1327 T: Serialize + DeserializeOwned + schemars::JsonSchema,
1328{
1329 #[must_use]
1331 pub fn discover_secret_paths_from_schema(mut self) -> Self {
1332 for path in schema_secret_paths::<T>() {
1333 self.secret_paths.insert(path);
1334 }
1335 self
1336 }
1337}
1338
1339fn is_valid_hostname(value: &str) -> bool {
1340 if value.is_empty() || value.len() > 253 {
1341 return false;
1342 }
1343
1344 value.split('.').all(|label| {
1345 !label.is_empty()
1346 && label.len() <= 63
1347 && !label.starts_with('-')
1348 && !label.ends_with('-')
1349 && label
1350 .chars()
1351 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
1352 })
1353}
1354
1355fn is_valid_url(value: &str) -> bool {
1356 if value.is_empty()
1357 || value
1358 .chars()
1359 .any(|ch| ch.is_whitespace() || ch.is_control())
1360 {
1361 return false;
1362 }
1363
1364 let Some((scheme, rest)) = value.split_once(':') else {
1365 return false;
1366 };
1367 if !is_valid_url_scheme(scheme) || rest.is_empty() {
1368 return false;
1369 }
1370
1371 if scheme == "mailto" {
1372 return !rest.starts_with('/')
1373 && has_valid_percent_escapes(rest)
1374 && rest
1375 .chars()
1376 .all(|ch| !ch.is_whitespace() && !ch.is_control());
1377 }
1378
1379 if let Some(authority_and_tail) = rest.strip_prefix("//") {
1380 return is_valid_hierarchical_url(scheme, authority_and_tail);
1381 }
1382
1383 if rest.starts_with('/') {
1384 return matches!(scheme, "file" | "unix")
1385 && has_valid_percent_escapes(rest)
1386 && rest
1387 .chars()
1388 .all(|ch| !ch.is_whitespace() && !ch.is_control());
1389 }
1390
1391 false
1392}
1393
1394fn is_valid_url_scheme(scheme: &str) -> bool {
1395 let mut chars = scheme.chars();
1396 matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic())
1397 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
1398}
1399
1400fn is_valid_hierarchical_url(scheme: &str, authority_and_tail: &str) -> bool {
1401 let split_at = authority_and_tail
1402 .find(['/', '?', '#'])
1403 .unwrap_or(authority_and_tail.len());
1404 let (authority, tail) = authority_and_tail.split_at(split_at);
1405
1406 if authority.is_empty() {
1407 return matches!(scheme, "file" | "unix")
1408 && !tail.is_empty()
1409 && has_valid_percent_escapes(tail)
1410 && tail
1411 .chars()
1412 .all(|ch| !ch.is_whitespace() && !ch.is_control());
1413 }
1414
1415 if !has_valid_percent_escapes(authority) || !has_valid_percent_escapes(tail) {
1416 return false;
1417 }
1418
1419 let host_port = authority
1420 .rsplit_once('@')
1421 .map_or(authority, |(userinfo, host_port)| {
1422 if userinfo.is_empty() || userinfo.contains('@') || !is_valid_url_userinfo(userinfo) {
1423 ""
1424 } else {
1425 host_port
1426 }
1427 });
1428 if !is_valid_url_host_port(host_port) {
1429 return false;
1430 }
1431
1432 tail.chars()
1433 .all(|ch| !ch.is_whitespace() && !ch.is_control())
1434}
1435
1436fn is_valid_url_host_port(host_port: &str) -> bool {
1437 if host_port.is_empty() {
1438 return false;
1439 }
1440
1441 if let Some(ipv6) = host_port.strip_prefix('[') {
1442 let Some((host, suffix)) = ipv6.split_once(']') else {
1443 return false;
1444 };
1445 if host.parse::<std::net::Ipv6Addr>().is_err() {
1446 return false;
1447 }
1448 return suffix.is_empty() || parse_url_port(suffix.strip_prefix(':')).is_some();
1449 }
1450
1451 let (host, port) = match host_port.rsplit_once(':') {
1452 Some((host, port)) if !host.contains(':') => (host, Some(port)),
1453 Some(_) => return false,
1454 None => (host_port, None),
1455 };
1456
1457 if host.is_empty() || !(host.parse::<IpAddr>().is_ok() || is_valid_hostname(host)) {
1458 return false;
1459 }
1460
1461 port.is_none_or(|port| parse_url_port(Some(port)).is_some())
1462}
1463
1464fn parse_url_port(port: Option<&str>) -> Option<u16> {
1465 let port = port?;
1466 if port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) {
1467 return None;
1468 }
1469 port.parse::<u16>().ok()
1470}
1471
1472fn is_valid_url_userinfo(value: &str) -> bool {
1473 value.chars().all(|ch| {
1474 ch.is_ascii_alphanumeric()
1475 || matches!(
1476 ch,
1477 '-' | '.'
1478 | '_'
1479 | '~'
1480 | '!'
1481 | '$'
1482 | '&'
1483 | '\''
1484 | '('
1485 | ')'
1486 | '*'
1487 | '+'
1488 | ','
1489 | ';'
1490 | '='
1491 | ':'
1492 | '%'
1493 )
1494 })
1495}
1496
1497fn has_valid_percent_escapes(value: &str) -> bool {
1498 let bytes = value.as_bytes();
1499 let mut index = 0usize;
1500 while index < bytes.len() {
1501 if bytes[index] == b'%' {
1502 let Some(first) = bytes.get(index + 1) else {
1503 return false;
1504 };
1505 let Some(second) = bytes.get(index + 2) else {
1506 return false;
1507 };
1508 if !first.is_ascii_hexdigit() || !second.is_ascii_hexdigit() {
1509 return false;
1510 }
1511 index += 3;
1512 continue;
1513 }
1514 index += 1;
1515 }
1516 true
1517}
1518
1519fn is_valid_email(value: &str) -> bool {
1520 if value.is_empty() || value.contains(char::is_whitespace) || value.matches('@').count() != 1 {
1521 return false;
1522 }
1523
1524 let Some((local, domain)) = value.split_once('@') else {
1525 return false;
1526 };
1527
1528 if local.is_empty()
1529 || domain.is_empty()
1530 || local.starts_with('.')
1531 || local.ends_with('.')
1532 || local.contains("..")
1533 {
1534 return false;
1535 }
1536
1537 static LOCAL_PART_RE: OnceLock<Regex> = OnceLock::new();
1538 let local_part_re = LOCAL_PART_RE.get_or_init(|| {
1539 Regex::new(r"^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*$")
1540 .expect("email local-part regex must compile")
1541 });
1542 if !local_part_re.is_match(local) {
1543 return false;
1544 }
1545
1546 if let Some(ip_literal) = domain
1547 .strip_prefix('[')
1548 .and_then(|domain| domain.strip_suffix(']'))
1549 {
1550 return ip_literal.parse::<IpAddr>().is_ok();
1551 }
1552
1553 domain.parse::<std::net::Ipv4Addr>().is_err() && is_valid_hostname(domain)
1554}
1555
1556fn parse_args(source: ArgsSource) -> Result<ParsedArgs, ConfigError> {
1557 let mut args = source.args.into_iter();
1558 let mut files = Vec::new();
1559 let mut profile = None;
1560 let mut root = Value::Object(Map::new());
1561 let mut entries = BTreeMap::new();
1562 let mut coercible_string_paths = BTreeSet::new();
1563 let mut indexed_array_paths = BTreeSet::new();
1564 let mut indexed_array_base_lengths = BTreeMap::new();
1565 let mut current_array_lengths = BTreeMap::new();
1566 let mut direct_array_paths = BTreeSet::new();
1567 let mut claimed_paths = BTreeMap::<String, String>::new();
1568
1569 while let Some(arg) = args.next() {
1570 if let Some(value) = arg.strip_prefix("--config=") {
1571 files.push(FileSource::new(value));
1572 continue;
1573 }
1574
1575 if arg == "--config" {
1576 let value = args.next().ok_or_else(|| ConfigError::MissingArgValue {
1577 flag: "--config".to_owned(),
1578 })?;
1579 files.push(FileSource::new(value));
1580 continue;
1581 }
1582
1583 if let Some(value) = arg.strip_prefix("--profile=") {
1584 profile = Some(value.to_owned());
1585 continue;
1586 }
1587
1588 if arg == "--profile" {
1589 profile = Some(args.next().ok_or_else(|| ConfigError::MissingArgValue {
1590 flag: "--profile".to_owned(),
1591 })?);
1592 continue;
1593 }
1594
1595 let set_value = if let Some(value) = arg.strip_prefix("--set=") {
1596 Some(value.to_owned())
1597 } else if arg == "--set" {
1598 Some(args.next().ok_or_else(|| ConfigError::MissingArgValue {
1599 flag: "--set".to_owned(),
1600 })?)
1601 } else {
1602 None
1603 };
1604
1605 let Some(set_value) = set_value else {
1606 continue;
1607 };
1608
1609 let (raw_path, raw_value) =
1610 set_value
1611 .split_once('=')
1612 .ok_or_else(|| ConfigError::InvalidArg {
1613 arg: set_value.clone(),
1614 message: "expected key=value".to_owned(),
1615 })?;
1616 let path =
1617 try_normalize_external_path(raw_path).map_err(|message| ConfigError::InvalidArg {
1618 arg: format!("--set {raw_path}={raw_value}"),
1619 message,
1620 })?;
1621 if path.is_empty() {
1622 return Err(ConfigError::InvalidArg {
1623 arg: set_value,
1624 message: "configuration path cannot be empty".to_owned(),
1625 });
1626 }
1627
1628 let segments = path.split('.').collect::<Vec<_>>();
1629 let parsed =
1630 parse_override_value(raw_value).map_err(|message| ConfigError::InvalidArg {
1631 arg: format!("--set {path}={raw_value}"),
1632 message,
1633 })?;
1634 let arg_trace_name = format!("--set {raw_path}={raw_value}");
1635 let is_direct_array = parsed.value.is_array();
1636 claim_arg_path(
1637 &arg_trace_name,
1638 &path,
1639 is_direct_array,
1640 &direct_array_paths,
1641 &mut claimed_paths,
1642 )?;
1643 record_indexed_array_state(
1644 &mut current_array_lengths,
1645 &mut indexed_array_base_lengths,
1646 &path,
1647 &segments,
1648 );
1649 if is_direct_array {
1650 record_direct_array_state(
1651 &mut current_array_lengths,
1652 &mut indexed_array_base_lengths,
1653 &path,
1654 &parsed.value,
1655 );
1656 }
1657 insert_path(&mut root, &segments, parsed.value).map_err(|message| {
1658 ConfigError::InvalidArg {
1659 arg: format!("--set {path}={raw_value}"),
1660 message,
1661 }
1662 })?;
1663 for suffix in parsed.string_coercion_suffixes {
1664 coercible_string_paths.insert(if suffix.is_empty() {
1665 path.clone()
1666 } else {
1667 join_path(&path, &suffix)
1668 });
1669 }
1670 indexed_array_paths.extend(indexed_array_container_paths(&segments));
1671 if is_direct_array {
1672 direct_array_paths.insert(path.clone());
1673 }
1674
1675 entries.insert(
1676 path.clone(),
1677 SourceTrace::new(SourceKind::Arguments, arg_trace_name.clone()),
1678 );
1679
1680 let mut prefix = String::new();
1681 for segment in segments {
1682 if !prefix.is_empty() {
1683 prefix.push('.');
1684 }
1685 prefix.push_str(segment);
1686 let entry = entries
1687 .entry(prefix.clone())
1688 .or_insert_with(|| SourceTrace::new(SourceKind::Arguments, arg_trace_name.clone()));
1689 if prefix != path && entry.name != arg_trace_name {
1690 *entry = SourceTrace::new(SourceKind::Arguments, "arguments");
1691 }
1692 }
1693 }
1694
1695 let layer = if entries.is_empty() {
1696 None
1697 } else {
1698 Some(Layer {
1699 trace: SourceTrace::new(SourceKind::Arguments, "arguments"),
1700 value: root,
1701 entries,
1702 coercible_string_paths,
1703 indexed_array_paths,
1704 indexed_array_base_lengths,
1705 direct_array_paths,
1706 })
1707 };
1708
1709 Ok(ParsedArgs {
1710 profile,
1711 files,
1712 layer,
1713 })
1714}
1715
1716fn claim_arg_path(
1717 arg: &str,
1718 path: &str,
1719 is_direct_array: bool,
1720 direct_array_paths: &BTreeSet<String>,
1721 claimed_paths: &mut BTreeMap<String, String>,
1722) -> Result<(), ConfigError> {
1723 for (existing_path, existing_arg) in claimed_paths.iter() {
1724 if existing_path == path {
1725 return Err(ConfigError::InvalidArg {
1726 arg: arg.to_owned(),
1727 message: format!(
1728 "conflicting CLI overrides `{existing_arg}` and `{arg}` both target `{path}`"
1729 ),
1730 });
1731 }
1732
1733 if existing_path
1734 .strip_prefix(path)
1735 .is_some_and(|suffix| suffix.starts_with('.'))
1736 || path
1737 .strip_prefix(existing_path)
1738 .is_some_and(|suffix| suffix.starts_with('.'))
1739 {
1740 if direct_array_overlap_allowed(
1741 existing_path,
1742 path,
1743 is_direct_array,
1744 direct_array_paths,
1745 ) {
1746 continue;
1747 }
1748 return Err(ConfigError::InvalidArg {
1749 arg: arg.to_owned(),
1750 message: format!(
1751 "conflicting CLI overrides `{existing_arg}` and `{arg}` target overlapping configuration paths `{existing_path}` and `{path}`"
1752 ),
1753 });
1754 }
1755 }
1756
1757 claimed_paths.insert(path.to_owned(), arg.to_owned());
1758 Ok(())
1759}
1760
1761fn direct_array_overlap_allowed(
1762 existing_path: &str,
1763 new_path: &str,
1764 new_is_direct_array: bool,
1765 direct_array_paths: &BTreeSet<String>,
1766) -> bool {
1767 direct_array_prefix_allows(
1768 existing_path,
1769 new_path,
1770 direct_array_paths.contains(existing_path),
1771 ) || direct_array_prefix_allows(new_path, existing_path, new_is_direct_array)
1772}
1773
1774fn direct_array_prefix_allows(prefix: &str, other: &str, is_direct_array: bool) -> bool {
1775 if !is_direct_array {
1776 return false;
1777 }
1778 let remainder = if prefix.is_empty() {
1779 other
1780 } else {
1781 let Some(remainder) = other.strip_prefix(prefix) else {
1782 return false;
1783 };
1784 let Some(remainder) = remainder.strip_prefix('.') else {
1785 return false;
1786 };
1787 remainder
1788 };
1789 remainder
1790 .split('.')
1791 .next()
1792 .is_some_and(|segment| segment.parse::<usize>().is_ok())
1793}
1794
1795fn load_file_layer(file: FileSource, profile: Option<&str>) -> Result<Option<Layer>, ConfigError> {
1796 let resolved_paths = file
1797 .candidates
1798 .iter()
1799 .map(|path| resolve_profile_path(path, profile))
1800 .collect::<Result<Vec<_>, _>>()?;
1801 let path = resolved_paths.iter().find(|path| path.exists()).cloned();
1802 let Some(path) = path else {
1803 return if file.required {
1804 match resolved_paths.as_slice() {
1805 [] => Err(ConfigError::InvalidArg {
1806 arg: "file source".to_owned(),
1807 message: "at least one candidate path must be provided".to_owned(),
1808 }),
1809 [single] => Err(ConfigError::MissingFile {
1810 path: single.clone(),
1811 }),
1812 _ => Err(ConfigError::MissingFiles {
1813 paths: resolved_paths,
1814 }),
1815 }
1816 } else {
1817 Ok(None)
1818 };
1819 };
1820
1821 let content = std::fs::read_to_string(&path).map_err(|source| ConfigError::ReadFile {
1822 path: path.clone(),
1823 source,
1824 })?;
1825 let format = match file.format {
1826 Some(format) => format,
1827 None => infer_format(&path)?,
1828 };
1829 let value = parse_file_value(&path, &content, format)?;
1830
1831 let layer = Layer::from_value(
1832 SourceTrace::new(SourceKind::File, path.display().to_string()),
1833 value,
1834 )?;
1835
1836 Ok(Some(layer))
1837}
1838
1839fn resolve_profile_path(path: &Path, profile: Option<&str>) -> Result<PathBuf, ConfigError> {
1840 let raw = path.to_string_lossy();
1841 if raw.contains("{profile}") {
1842 let profile = profile.ok_or_else(|| ConfigError::MissingProfile {
1843 path: path.to_path_buf(),
1844 })?;
1845 Ok(PathBuf::from(raw.replace("{profile}", profile)))
1846 } else {
1847 Ok(path.to_path_buf())
1848 }
1849}
1850
1851#[cfg(feature = "schema")]
1852fn schema_secret_paths<T>() -> BTreeSet<String>
1853where
1854 T: schemars::JsonSchema,
1855{
1856 let schema = crate::schema::json_schema_for::<T>();
1857 let mut paths = BTreeSet::new();
1858 collect_secret_paths_from_schema(&schema, &schema, "", &mut paths, &mut BTreeSet::new());
1859 paths
1860}
1861
1862#[cfg(feature = "schema")]
1863fn collect_secret_paths_from_schema(
1864 schema: &Value,
1865 root: &Value,
1866 current: &str,
1867 paths: &mut BTreeSet<String>,
1868 visited_refs: &mut BTreeSet<String>,
1869) {
1870 let Some(object) = schema.as_object() else {
1871 return;
1872 };
1873
1874 let is_secret = object
1875 .get("x-tier-secret")
1876 .and_then(Value::as_bool)
1877 .unwrap_or(false)
1878 || object
1879 .get("writeOnly")
1880 .and_then(Value::as_bool)
1881 .unwrap_or(false);
1882
1883 if is_secret && !current.is_empty() {
1884 paths.insert(current.to_owned());
1885 }
1886
1887 if let Some(reference) = object.get("$ref").and_then(Value::as_str)
1888 && visited_refs.insert(reference.to_owned())
1889 && let Some(target) = resolve_schema_ref(root, reference)
1890 {
1891 collect_secret_paths_from_schema(target, root, current, paths, visited_refs);
1892 visited_refs.remove(reference);
1893 }
1894
1895 if let Some(properties) = object.get("properties").and_then(Value::as_object) {
1896 for (key, child) in properties {
1897 let next = crate::report::join_path(current, key);
1898 collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1899 }
1900 }
1901
1902 if let Some(items) = object.get("prefixItems").and_then(Value::as_array) {
1903 for (index, child) in items.iter().enumerate() {
1904 let next = crate::report::join_path(current, &index.to_string());
1905 collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1906 }
1907 }
1908
1909 if let Some(items) = object.get("items").and_then(Value::as_array) {
1910 for (index, child) in items.iter().enumerate() {
1911 let next = crate::report::join_path(current, &index.to_string());
1912 collect_secret_paths_from_schema(child, root, &next, paths, visited_refs);
1913 }
1914 }
1915
1916 if let Some(items) = object.get("items") {
1917 let next = crate::report::join_path(current, "*");
1918 collect_secret_paths_from_schema(items, root, &next, paths, visited_refs);
1919 }
1920
1921 if let Some(additional) = object
1922 .get("additionalProperties")
1923 .filter(|value| value.is_object())
1924 {
1925 let next = crate::report::join_path(current, "*");
1926 collect_secret_paths_from_schema(additional, root, &next, paths, visited_refs);
1927 }
1928
1929 for keyword in ["allOf", "anyOf", "oneOf"] {
1930 if let Some(array) = object.get(keyword).and_then(Value::as_array) {
1931 for child in array {
1932 collect_secret_paths_from_schema(child, root, current, paths, visited_refs);
1933 }
1934 }
1935 }
1936}
1937
1938#[cfg(feature = "schema")]
1939fn resolve_schema_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
1940 let pointer = reference.strip_prefix('#')?;
1941 root.pointer(pointer)
1942}
1943
1944fn infer_format(path: &Path) -> Result<FileFormat, ConfigError> {
1945 let extension = path
1946 .extension()
1947 .and_then(|extension| extension.to_str())
1948 .map(str::to_ascii_lowercase)
1949 .ok_or_else(|| ConfigError::InvalidArg {
1950 arg: path.display().to_string(),
1951 message: "cannot infer file format without an extension".to_owned(),
1952 })?;
1953
1954 match extension.as_str() {
1955 "json" => Ok(FileFormat::Json),
1956 "toml" => Ok(FileFormat::Toml),
1957 "yaml" | "yml" => Ok(FileFormat::Yaml),
1958 other => Err(ConfigError::InvalidArg {
1959 arg: path.display().to_string(),
1960 message: format!("unsupported file format extension: {other}"),
1961 }),
1962 }
1963}
1964
1965fn parse_file_value(path: &Path, content: &str, format: FileFormat) -> Result<Value, ConfigError> {
1966 match format {
1967 FileFormat::Json => {
1968 #[cfg(feature = "json")]
1969 {
1970 let value =
1971 serde_json::from_str(content).map_err(|error| ConfigError::ParseFile {
1972 path: path.to_path_buf(),
1973 format,
1974 location: Some(LineColumn {
1975 line: error.line(),
1976 column: error.column(),
1977 }),
1978 message: error.to_string(),
1979 })?;
1980 Ok(value)
1981 }
1982
1983 #[cfg(not(feature = "json"))]
1984 {
1985 let _ = (path, content);
1986 Err(ConfigError::InvalidArg {
1987 arg: "json".to_owned(),
1988 message: "json support is disabled for this build".to_owned(),
1989 })
1990 }
1991 }
1992 FileFormat::Toml => {
1993 #[cfg(feature = "toml")]
1994 {
1995 let value = toml::from_str::<toml::Value>(content).map_err(|error| {
1996 ConfigError::ParseFile {
1997 path: path.to_path_buf(),
1998 format,
1999 location: error
2000 .span()
2001 .map(|span| offset_to_line_column(content, span.start)),
2002 message: error.to_string(),
2003 }
2004 })?;
2005 serde_json::to_value(value).map_err(ConfigError::from)
2006 }
2007
2008 #[cfg(not(feature = "toml"))]
2009 {
2010 let _ = (path, content);
2011 Err(ConfigError::InvalidArg {
2012 arg: "toml".to_owned(),
2013 message: "toml support is disabled for this build".to_owned(),
2014 })
2015 }
2016 }
2017 FileFormat::Yaml => {
2018 #[cfg(feature = "yaml")]
2019 {
2020 let value = serde_yaml::from_str::<Value>(content).map_err(|error| {
2021 ConfigError::ParseFile {
2022 path: path.to_path_buf(),
2023 format,
2024 location: error.location().map(|location| LineColumn {
2025 line: location.line(),
2026 column: location.column(),
2027 }),
2028 message: error.to_string(),
2029 }
2030 })?;
2031 Ok(value)
2032 }
2033
2034 #[cfg(not(feature = "yaml"))]
2035 {
2036 let _ = (path, content);
2037 Err(ConfigError::InvalidArg {
2038 arg: "yaml".to_owned(),
2039 message: "yaml support is disabled for this build".to_owned(),
2040 })
2041 }
2042 }
2043 }
2044}
2045
2046#[cfg(feature = "toml")]
2047fn offset_to_line_column(input: &str, offset: usize) -> LineColumn {
2048 let mut line = 1;
2049 let mut column = 1;
2050 for (index, byte) in input.bytes().enumerate() {
2051 if index == offset {
2052 break;
2053 }
2054 if byte == b'\n' {
2055 line += 1;
2056 column = 1;
2057 } else {
2058 column += 1;
2059 }
2060 }
2061 LineColumn { line, column }
2062}