1use crate::error::{Result, TemplateError};
12use serde_json::Value;
13use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
20pub struct TomlFile {
21 pub path: PathBuf,
23 pub content: String,
25 pub parsed: Value,
27 pub metadata: TomlMetadata,
29}
30
31#[derive(Debug, Clone)]
33pub struct TomlMetadata {
34 pub size: u64,
36 pub modified: std::time::SystemTime,
38 pub permissions: std::fs::Permissions,
40 pub variables_used: HashSet<String>,
42 pub functions_used: HashSet<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct TomlLoader {
49 search_paths: Vec<PathBuf>,
51 extensions: Vec<String>,
53 recursive: bool,
55 validation_rules: Vec<crate::validation::ValidationRule>,
57}
58
59impl Default for TomlLoader {
60 fn default() -> Self {
61 Self {
62 search_paths: Vec::new(),
63 extensions: vec!["toml".to_string(), "clnrm.toml".to_string()],
64 recursive: true,
65 validation_rules: Vec::new(),
66 }
67 }
68}
69
70impl TomlLoader {
71 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn with_search_path<P: AsRef<Path>>(mut self, path: P) -> Self {
78 self.search_paths.push(path.as_ref().to_path_buf());
79 self
80 }
81
82 pub fn with_search_paths<I, P>(mut self, paths: I) -> Self
84 where
85 I: IntoIterator<Item = P>,
86 P: AsRef<Path>,
87 {
88 for path in paths {
89 self.search_paths.push(path.as_ref().to_path_buf());
90 }
91 self
92 }
93
94 pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
96 where
97 I: IntoIterator<Item = S>,
98 S: Into<String>,
99 {
100 self.extensions = extensions.into_iter().map(|s| s.into()).collect();
101 self
102 }
103
104 pub fn recursive(mut self, recursive: bool) -> Self {
106 self.recursive = recursive;
107 self
108 }
109
110 pub fn with_validation_rule(mut self, rule: crate::validation::ValidationRule) -> Self {
112 self.validation_rules.push(rule);
113 self
114 }
115
116 pub fn load_file<P: AsRef<Path>>(&self, path: P) -> Result<TomlFile> {
121 let path = path.as_ref();
122
123 if !path.exists() {
124 return Err(TemplateError::IoError(format!(
125 "TOML file not found: {}",
126 path.display()
127 )));
128 }
129
130 if !path.is_file() {
131 return Err(TemplateError::IoError(format!(
132 "Path is not a file: {}",
133 path.display()
134 )));
135 }
136
137 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
139 if !self.extensions.contains(&ext.to_string()) {
140 return Err(TemplateError::ValidationError(format!(
141 "File extension '{}' not supported. Expected: {:?}",
142 ext, self.extensions
143 )));
144 }
145 }
146
147 let content = fs::read_to_string(path)
148 .map_err(|e| TemplateError::IoError(format!("Failed to read TOML file: {}", e)))?;
149
150 let parsed = toml::from_str::<Value>(&content)
151 .map_err(|e| TemplateError::ValidationError(format!("Invalid TOML format: {}", e)))?;
152
153 let metadata = path
154 .metadata()
155 .map_err(|e| TemplateError::IoError(format!("Failed to read file metadata: {}", e)))?;
156
157 let file = TomlFile {
158 path: path.to_path_buf(),
159 content,
160 parsed,
161 metadata: TomlMetadata {
162 size: metadata.len(),
163 modified: metadata.modified().map_err(|e| {
164 TemplateError::IoError(format!("Failed to get modification time: {}", e))
165 })?,
166 permissions: metadata.permissions(),
167 variables_used: HashSet::new(),
168 functions_used: HashSet::new(),
169 },
170 };
171
172 for rule in &self.validation_rules {
174 rule.validate(&file.parsed, &file.path.to_string_lossy())?;
175 }
176
177 Ok(file)
178 }
179
180 pub fn load_all(&self) -> Result<HashMap<PathBuf, TomlFile>> {
184 let mut files = HashMap::new();
185
186 for search_path in &self.search_paths {
187 self.scan_directory(search_path, &mut files)?;
188 }
189
190 Ok(files)
191 }
192
193 fn scan_directory(&self, dir: &Path, files: &mut HashMap<PathBuf, TomlFile>) -> Result<()> {
195 use walkdir::WalkDir;
196
197 let walker = if self.recursive {
198 WalkDir::new(dir)
199 } else {
200 WalkDir::new(dir).max_depth(1)
201 };
202
203 for entry in walker {
204 let entry = entry.map_err(|e| {
205 TemplateError::IoError(format!("Failed to read directory entry: {}", e))
206 })?;
207
208 if entry.file_type().is_file() {
209 let path = entry.path();
210
211 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
213 if self.extensions.contains(&ext.to_string()) {
214 match self.load_file(path) {
215 Ok(file) => {
216 files.insert(path.to_path_buf(), file);
217 }
218 Err(e) => {
219 eprintln!("Warning: Failed to load TOML file {:?}: {}", path, e);
220 }
221 }
222 }
223 }
224 }
225 }
226
227 Ok(())
228 }
229
230 pub fn load_glob(&self, pattern: &str) -> Result<HashMap<PathBuf, TomlFile>> {
232 use globset::{Glob, GlobSetBuilder};
233
234 let glob = Glob::new(pattern).map_err(|e| {
235 TemplateError::ConfigError(format!("Invalid glob pattern '{}': {}", pattern, e))
236 })?;
237
238 let glob_set = GlobSetBuilder::new()
239 .add(glob)
240 .build()
241 .map_err(|e| TemplateError::ConfigError(format!("Failed to build glob set: {}", e)))?;
242
243 let mut files = HashMap::new();
244
245 for search_path in &self.search_paths {
246 self.scan_glob_pattern(search_path, &glob_set, &mut files)?;
247 }
248
249 Ok(files)
250 }
251
252 fn scan_glob_pattern(
254 &self,
255 dir: &Path,
256 glob_set: &globset::GlobSet,
257 files: &mut HashMap<PathBuf, TomlFile>,
258 ) -> Result<()> {
259 use walkdir::WalkDir;
260
261 let walker = if self.recursive {
262 WalkDir::new(dir)
263 } else {
264 WalkDir::new(dir).max_depth(1)
265 };
266
267 for entry in walker {
268 let entry = entry.map_err(|e| {
269 TemplateError::IoError(format!("Failed to read directory entry: {}", e))
270 })?;
271
272 if entry.file_type().is_file() {
273 let path_str = entry.path().to_string_lossy();
274 if glob_set.is_match(&*path_str) {
275 match self.load_file(entry.path()) {
276 Ok(file) => {
277 files.insert(entry.path().to_path_buf(), file);
278 }
279 Err(e) => {
280 eprintln!(
281 "Warning: Failed to load TOML file {:?}: {}",
282 entry.path(),
283 e
284 );
285 }
286 }
287 }
288 }
289 }
290
291 Ok(())
292 }
293}
294
295#[derive(Debug, Clone)]
297pub struct TomlWriter {
298 pretty: bool,
300 backup: bool,
302 validate: bool,
304 header: Option<String>,
306}
307
308impl Default for TomlWriter {
309 fn default() -> Self {
310 Self {
311 pretty: true,
312 backup: true,
313 validate: true,
314 header: Some("# Generated by clnrm-template".to_string()),
315 }
316 }
317}
318
319impl TomlWriter {
320 pub fn new() -> Self {
322 Self::default()
323 }
324
325 pub fn pretty(mut self, pretty: bool) -> Self {
327 self.pretty = pretty;
328 self
329 }
330
331 pub fn backup(mut self, backup: bool) -> Self {
333 self.backup = backup;
334 self
335 }
336
337 pub fn validate(mut self, validate: bool) -> Self {
339 self.validate = validate;
340 self
341 }
342
343 pub fn with_header<S: Into<String>>(mut self, header: S) -> Self {
345 self.header = Some(header.into());
346 self
347 }
348
349 pub fn write_file<P: AsRef<Path>>(
356 &self,
357 path: P,
358 content: &str,
359 validator: Option<&crate::validation::TemplateValidator>,
360 ) -> Result<()> {
361 let path = path.as_ref();
362
363 if self.validate {
365 if let Some(validator) = validator {
366 validator.validate(content, &path.to_string_lossy())?;
367 }
368 }
369
370 if self.backup && path.exists() {
372 self.create_backup(path)?;
373 }
374
375 let final_content = if let Some(ref header) = self.header {
377 format!("{}\n{}\n", header, content)
378 } else {
379 content.to_string()
380 };
381
382 let mut file = fs::File::create(path)
384 .map_err(|e| TemplateError::IoError(format!("Failed to create file: {}", e)))?;
385
386 file.write_all(final_content.as_bytes())
387 .map_err(|e| TemplateError::IoError(format!("Failed to write file: {}", e)))?;
388
389 file.sync_all()
390 .map_err(|e| TemplateError::IoError(format!("Failed to sync file: {}", e)))?;
391
392 Ok(())
393 }
394
395 fn create_backup(&self, path: &Path) -> Result<()> {
397 let _backup_path = self.backup_path(path);
398
399 fs::copy(path, &_backup_path)
400 .map_err(|e| TemplateError::IoError(format!("Failed to create backup: {}", e)))?;
401
402 Ok(())
403 }
404
405 fn backup_path(&self, path: &Path) -> PathBuf {
407 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
408 let stem = path.file_stem().unwrap_or_default().to_string_lossy();
409 let _ext = path.extension().unwrap_or_default().to_string_lossy();
410
411 path.with_file_name(format!("{}.{}.bak", stem, timestamp))
412 }
413}
414
415pub struct TomlMerger {
417 strategy: MergeStrategy,
419 preserve_formatting: bool,
421 deep_merge: bool,
423}
424
425pub enum MergeStrategy {
426 Overwrite,
428 MergeArrays,
430 Preserve,
432 Custom,
434}
435
436impl std::fmt::Debug for MergeStrategy {
437 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438 match self {
439 MergeStrategy::Overwrite => write!(f, "Overwrite"),
440 MergeStrategy::MergeArrays => write!(f, "MergeArrays"),
441 MergeStrategy::Preserve => write!(f, "Preserve"),
442 MergeStrategy::Custom => write!(f, "Custom"),
443 }
444 }
445}
446
447impl Clone for MergeStrategy {
448 fn clone(&self) -> Self {
449 match self {
450 MergeStrategy::Overwrite => MergeStrategy::Overwrite,
451 MergeStrategy::MergeArrays => MergeStrategy::MergeArrays,
452 MergeStrategy::Preserve => MergeStrategy::Preserve,
453 MergeStrategy::Custom => MergeStrategy::Custom,
454 }
455 }
456}
457
458impl Default for TomlMerger {
459 fn default() -> Self {
460 Self {
461 strategy: MergeStrategy::Overwrite,
462 preserve_formatting: false,
463 deep_merge: true,
464 }
465 }
466}
467
468impl TomlMerger {
469 pub fn new() -> Self {
471 Self::default()
472 }
473
474 pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
476 self.strategy = strategy;
477 self
478 }
479
480 pub fn preserve_formatting(mut self, preserve: bool) -> Self {
482 self.preserve_formatting = preserve;
483 self
484 }
485
486 pub fn deep_merge(mut self, deep: bool) -> Self {
488 self.deep_merge = deep;
489 self
490 }
491
492 pub fn merge(&self, base: &Value, overlay: &Value) -> Result<Value> {
498 match (&base, &overlay) {
499 (Value::Object(base_obj), Value::Object(overlay_obj)) => {
500 let mut result = base_obj.clone();
501
502 for (key, overlay_value) in overlay_obj {
503 if let Some(base_value) = base_obj.get(key) {
504 let merged = self.merge_values(base_value, overlay_value)?;
505 result.insert(key.clone(), merged);
506 } else {
507 result.insert(key.clone(), overlay_value.clone());
508 }
509 }
510
511 Ok(Value::Object(result))
512 }
513 _ => {
514 Ok(overlay.clone())
516 }
517 }
518 }
519
520 fn merge_values(&self, base: &Value, overlay: &Value) -> Result<Value> {
522 match &self.strategy {
523 MergeStrategy::Overwrite => Ok(overlay.clone()),
524 MergeStrategy::Preserve => Ok(base.clone()),
525 MergeStrategy::MergeArrays => {
526 if let (Value::Array(base_arr), Value::Array(overlay_arr)) = (base, overlay) {
527 let mut merged = base_arr.clone();
528 merged.extend(overlay_arr.iter().cloned());
529 Ok(Value::Array(merged))
530 } else {
531 Ok(overlay.clone())
532 }
533 }
534 MergeStrategy::Custom => Ok(overlay.clone()), }
536 }
537
538 pub fn merge_files(&self, files: &[&TomlFile]) -> Result<TomlFile> {
543 if files.is_empty() {
544 return Err(TemplateError::ValidationError(
545 "No files to merge".to_string(),
546 ));
547 }
548
549 let mut merged_value = files[0].parsed.clone();
550
551 for file in &files[1..] {
552 merged_value = self.merge(&merged_value, &file.parsed)?;
553 }
554
555 let merged_content = if self.preserve_formatting {
557 toml::to_string_pretty(&merged_value)
559 .unwrap_or_else(|_| toml::to_string(&merged_value).unwrap_or_default())
560 } else {
561 toml::to_string(&merged_value).map_err(|e| {
562 TemplateError::ValidationError(format!("Failed to serialize merged TOML: {}", e))
563 })?
564 };
565
566 Ok(TomlFile {
567 path: files[0].path.with_extension("merged.toml"),
568 content: merged_content,
569 parsed: merged_value,
570 metadata: files[0].metadata.clone(), })
572 }
573}
574
575pub struct TomlUtils;
577
578impl TomlUtils {
579 pub fn extract_variables(content: &str) -> Result<HashSet<String>> {
584 let parsed = toml::from_str::<Value>(content).map_err(|e| {
585 TemplateError::ValidationError(format!("Invalid TOML for variable extraction: {}", e))
586 })?;
587
588 let mut variables = HashSet::new();
589 Self::extract_variables_recursive(&parsed, &mut variables, "");
590 Ok(variables)
591 }
592
593 fn extract_variables_recursive(value: &Value, variables: &mut HashSet<String>, prefix: &str) {
595 match value {
596 Value::String(s) => {
597 if s.contains("{{") && s.contains("}}") {
599 if let Some(start) = s.find("{{") {
601 if let Some(end) = s.find("}}") {
602 let var_part = &s[start + 2..end];
603 if !var_part.trim().is_empty() {
604 let var_name = if prefix.is_empty() {
605 var_part.trim().to_string()
606 } else {
607 format!("{}.{}", prefix, var_part.trim())
608 };
609 variables.insert(var_name);
610 }
611 }
612 }
613 }
614 }
615 Value::Object(obj) => {
616 for (key, value) in obj {
617 let new_prefix = if prefix.is_empty() {
618 key.clone()
619 } else {
620 format!("{}.{}", prefix, key)
621 };
622 Self::extract_variables_recursive(value, variables, &new_prefix);
623 }
624 }
625 Value::Array(arr) => {
626 for (i, value) in arr.iter().enumerate() {
627 let new_prefix = if prefix.is_empty() {
628 format!("{}", i)
629 } else {
630 format!("{}.{}", prefix, i)
631 };
632 Self::extract_variables_recursive(value, variables, &new_prefix);
633 }
634 }
635 _ => {} }
637 }
638
639 pub fn validate_structure(file: &TomlFile, required_sections: &[&str]) -> Result<()> {
645 let obj = file
646 .parsed
647 .as_object()
648 .ok_or_else(|| TemplateError::ValidationError("TOML must be an object".to_string()))?;
649
650 for section in required_sections {
651 if !obj.contains_key(*section) {
652 return Err(TemplateError::ValidationError(format!(
653 "Required section '{}' missing in TOML file: {}",
654 section,
655 file.path.display()
656 )));
657 }
658 }
659
660 Ok(())
661 }
662
663 pub fn diff(file1: &TomlFile, file2: &TomlFile) -> TomlDiff {
669 let mut added = Vec::new();
670 let mut removed = Vec::new();
671 let mut changed = Vec::new();
672
673 if let (Some(obj1), Some(obj2)) = (file1.parsed.as_object(), file2.parsed.as_object()) {
675 for (key, value2) in obj2 {
676 if let Some(value1) = obj1.get(key) {
677 if value1 != value2 {
678 changed.push((key.clone(), value1.clone(), value2.clone()));
679 }
680 } else {
681 added.push((key.clone(), value2.clone()));
682 }
683 }
684
685 for (key, _) in obj1 {
686 if !obj2.contains_key(key) {
687 removed.push(key.clone());
688 }
689 }
690 }
691
692 TomlDiff {
693 added,
694 removed,
695 changed,
696 }
697 }
698
699 pub fn format_toml(content: &str) -> Result<String> {
704 let parsed = toml::from_str::<Value>(content).map_err(|e| {
705 TemplateError::ValidationError(format!("Invalid TOML for formatting: {}", e))
706 })?;
707
708 toml::to_string_pretty(&parsed)
709 .map_err(|e| TemplateError::ValidationError(format!("Failed to format TOML: {}", e)))
710 }
711
712 pub fn validate_toml_syntax(content: &str) -> Result<()> {
717 toml::from_str::<Value>(content)
719 .map_err(|e| TemplateError::ValidationError(format!("Invalid TOML syntax: {}", e)))?;
720
721 Ok(())
722 }
723
724 pub fn extract_keys(content: &str) -> Result<HashSet<String>> {
729 let parsed = toml::from_str::<Value>(content).map_err(|e| {
730 TemplateError::ValidationError(format!("Invalid TOML for key extraction: {}", e))
731 })?;
732
733 let mut keys = HashSet::new();
734 Self::extract_keys_recursive(&parsed, &mut keys, "");
735 Ok(keys)
736 }
737
738 fn extract_keys_recursive(value: &Value, keys: &mut HashSet<String>, prefix: &str) {
740 match value {
741 Value::Object(obj) => {
742 for (key, value) in obj {
743 let full_key = if prefix.is_empty() {
744 key.clone()
745 } else {
746 format!("{}.{}", prefix, key)
747 };
748 keys.insert(full_key.clone());
749 Self::extract_keys_recursive(value, keys, &full_key);
750 }
751 }
752 Value::Array(arr) => {
753 for (i, value) in arr.iter().enumerate() {
754 let index_key = if prefix.is_empty() {
755 format!("{}", i)
756 } else {
757 format!("{}.{}", prefix, i)
758 };
759 Self::extract_keys_recursive(value, keys, &index_key);
760 }
761 }
762 _ => {} }
764 }
765
766 pub fn contains_templates(content: &str) -> bool {
771 content.contains("{{") || content.contains("{%") || content.contains("{#")
772 }
773
774 pub fn count_variables(content: &str) -> usize {
779 let mut count = 0;
780 let mut in_braces = false;
781
782 for ch in content.chars() {
783 match ch {
784 '{' => {
785 if let Some(next) = content.chars().nth(count + 1) {
786 if next == '{' {
787 in_braces = true;
788 }
789 }
790 }
791 '}' => {
792 if let Some(prev) = content.chars().nth(count - 1) {
793 if prev == '}' && in_braces {
794 in_braces = false;
795 }
796 }
797 }
798 _ => {
799 if in_braces {
800 count += 1;
802 }
803 }
804 }
805 }
806
807 count
808 }
809}
810
811#[derive(Debug, Clone)]
813pub struct TomlDiff {
814 pub added: Vec<(String, Value)>,
816 pub removed: Vec<String>,
818 pub changed: Vec<(String, Value, Value)>,
820}
821
822pub struct TomlFileBuilder {
824 loader: TomlLoader,
825 writer: TomlWriter,
826 merger: TomlMerger,
827}
828
829impl TomlFileBuilder {
830 pub fn new() -> Self {
832 Self {
833 loader: TomlLoader::new(),
834 writer: TomlWriter::new(),
835 merger: TomlMerger::new(),
836 }
837 }
838
839 pub fn loader<F>(mut self, f: F) -> Self
841 where
842 F: FnOnce(TomlLoader) -> TomlLoader,
843 {
844 self.loader = f(self.loader);
845 self
846 }
847
848 pub fn writer<F>(mut self, f: F) -> Self
850 where
851 F: FnOnce(TomlWriter) -> TomlWriter,
852 {
853 self.writer = f(self.writer);
854 self
855 }
856
857 pub fn merger<F>(mut self, f: F) -> Self
859 where
860 F: FnOnce(TomlMerger) -> TomlMerger,
861 {
862 self.merger = f(self.merger);
863 self
864 }
865
866 pub fn load<P: AsRef<Path>>(self, path: P) -> Result<TomlFile> {
868 self.loader.load_file(path)
869 }
870
871 pub fn write<P: AsRef<Path>>(
873 self,
874 path: P,
875 content: &str,
876 validator: Option<&crate::validation::TemplateValidator>,
877 ) -> Result<()> {
878 self.writer.write_file(path, content, validator)
879 }
880
881 pub fn merge(self, files: &[&TomlFile]) -> Result<TomlFile> {
883 self.merger.merge_files(files)
884 }
885}
886
887impl Default for TomlFileBuilder {
888 fn default() -> Self {
889 Self::new()
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use tempfile::tempdir;
897
898 #[test]
899 fn test_toml_file_loading() {
900 let temp_dir = tempdir().unwrap();
901 let toml_file = temp_dir.path().join("test.toml");
902
903 let content = r#"
904[service]
905name = "test-service"
906
907[meta]
908version = "1.0.0"
909 "#;
910
911 fs::write(&toml_file, content).unwrap();
912
913 let loader = TomlLoader::new()
914 .with_search_path(&temp_dir)
915 .with_extensions(vec!["toml"]);
916
917 let file = loader.load_file(&toml_file).unwrap();
918
919 assert_eq!(file.path, toml_file);
920 assert_eq!(file.content, content);
921 assert!(file.parsed.get("service").is_some());
922 assert!(file.parsed.get("meta").is_some());
923 }
924
925 #[test]
926 fn test_toml_merging() {
927 let base_content = r#"
928[service]
929name = "base-service"
930
931[meta]
932version = "1.0.0"
933 "#;
934
935 let overlay_content = r#"
936[service]
937description = "overlay description"
938
939[config]
940debug = true
941 "#;
942
943 let base_parsed = toml::from_str::<Value>(base_content).unwrap();
944 let overlay_parsed = toml::from_str::<Value>(overlay_content).unwrap();
945
946 let merger = TomlMerger::new();
947 let merged = merger.merge(&base_parsed, &overlay_parsed).unwrap();
948
949 assert!(merged.get("service").unwrap().get("name").is_some());
951 assert!(merged.get("service").unwrap().get("description").is_some());
952 assert!(merged.get("meta").is_some());
953 assert!(merged.get("config").is_some());
954 }
955
956 #[test]
957 fn test_variable_extraction() {
958 let content = r#"
959service = "{{ service_name }}"
960config = "{{ config.env }}"
961 "#;
962
963 let variables = TomlUtils::extract_variables(content).unwrap();
964 assert!(variables.contains("service_name"));
965 assert!(variables.contains("config.env"));
966 }
967
968 #[test]
969 fn test_toml_validation() {
970 let temp_dir = tempdir().unwrap();
971 let toml_file = temp_dir.path().join("config.toml");
972
973 let content = r#"
974[service]
975name = "test-service"
976
977[meta]
978version = "1.0.0"
979 "#;
980
981 fs::write(&toml_file, content).unwrap();
982
983 let file = TomlLoader::new().load_file(&toml_file).unwrap();
984 TomlUtils::validate_structure(&file, &["service", "meta"]).unwrap();
985 }
986
987 #[test]
988 fn test_toml_formatting() {
989 let content = r#"[service]name="test"[meta]version="1.0.0""#;
990 let formatted = TomlUtils::format_toml(content).unwrap();
991
992 assert!(formatted.contains("[service]"));
993 assert!(formatted.contains("[meta]"));
994 assert!(formatted.contains("name = \"test\""));
995 assert!(formatted.contains("version = \"1.0.0\""));
996 }
997
998 #[test]
999 fn test_toml_key_extraction() {
1000 let content = r#"
1001[service]
1002name = "test"
1003
1004[config]
1005debug = true
1006
1007[database]
1008host = "localhost"
1009 "#;
1010
1011 let keys = TomlUtils::extract_keys(content).unwrap();
1012 assert!(keys.contains("service"));
1013 assert!(keys.contains("service.name"));
1014 assert!(keys.contains("config"));
1015 assert!(keys.contains("config.debug"));
1016 assert!(keys.contains("database"));
1017 assert!(keys.contains("database.host"));
1018 }
1019}