1use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use crate::registry::{ConfigError, ConfigRegistry};
47use crate::schema::{dot_path, toml_to_config_value};
49
50#[derive(Debug, Default)]
54pub struct LoadOutcome {
55 pub messages: Vec<LoadMessage>,
58 pub structural: BTreeMap<String, toml::Table>,
63 pub raw_tree: toml::Table,
72}
73
74impl LoadOutcome {
75 pub fn extend(&mut self, other: LoadOutcome) {
80 self.messages.extend(other.messages);
81 for (k, v) in other.structural {
82 self.structural.insert(k, v);
83 }
84 deep_merge_table(&mut self.raw_tree, other.raw_tree);
85 }
86}
87
88pub(crate) fn deep_merge_table(base: &mut toml::Table, incoming: toml::Table) {
97 for (key, incoming_val) in incoming {
98 match (base.get_mut(&key), incoming_val) {
99 (Some(toml::Value::Table(base_sub)), toml::Value::Table(in_sub)) => {
100 deep_merge_table(base_sub, in_sub);
101 }
102 (_, val) => {
103 base.insert(key, val);
104 }
105 }
106 }
107}
108
109#[derive(Debug, Clone)]
113pub struct LoadMessage {
114 pub level: LoadMessageLevel,
117 pub source: PathBuf,
119 pub body: String,
122 pub option: std::option::Option<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum LoadMessageLevel {
143 Error,
145 Warning,
148}
149
150pub fn lookup_dotted_path<'a>(tree: &'a toml::Table, path: &str) -> Option<&'a toml::Value> {
169 let mut node: &toml::Table = tree;
170 let segments: Vec<&str> = path.split('.').collect();
171 let last_idx = segments.len().checked_sub(1)?;
172 for (i, seg) in segments.iter().enumerate() {
173 let value = node.get(*seg)?;
174 if i == last_idx {
175 return Some(value);
176 }
177 node = value.as_table()?;
178 }
179 None
180}
181
182pub fn config_home() -> Option<PathBuf> {
200 let xdg = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
201 #[cfg(unix)]
202 let fallback = dirs::home_dir().map(|h| h.join(".config"));
203 #[cfg(not(unix))]
204 let fallback = dirs::config_dir();
205 resolve_config_home(xdg, fallback)
206}
207
208pub fn cache_home() -> Option<PathBuf> {
220 cache_home_from(config_home().as_deref())
221}
222
223pub fn cache_home_from(config_home: Option<&Path>) -> Option<PathBuf> {
225 config_home.map(|d| d.join("lattice").join("cache"))
226}
227
228pub fn migrate_path(old: &Path, new: &Path) -> bool {
237 if old == new || !old.exists() || new.exists() {
238 return false;
239 }
240 if let Some(parent) = new.parent()
241 && std::fs::create_dir_all(parent).is_err()
242 {
243 return false;
244 }
245 std::fs::rename(old, new).is_ok()
249}
250
251fn resolve_config_home(
254 xdg_config_home: Option<PathBuf>,
255 fallback: Option<PathBuf>,
256) -> Option<PathBuf> {
257 if let Some(xdg) = xdg_config_home
258 && xdg.is_absolute()
259 {
260 return Some(xdg);
261 }
262 fallback
263}
264
265pub fn default_user_config_path() -> Option<PathBuf> {
270 config_home().map(|d| d.join("lattice").join("lattice.toml"))
271}
272
273pub fn project_config_path(workspace_root: &Path) -> PathBuf {
278 workspace_root.join(".lattice").join("config.toml")
279}
280
281pub fn load_default_paths(
286 registry: &ConfigRegistry,
287 workspace_root: Option<&Path>,
288 structural_prefixes: &[&str],
289) -> LoadOutcome {
290 let mut out = LoadOutcome::default();
291 registry.clear_all_failed_assignments();
298 if let Some(user) = default_user_config_path()
299 && user.exists()
300 {
301 out.extend(load_file(registry, &user, structural_prefixes));
302 }
303 if let Some(root) = workspace_root {
304 let proj = project_config_path(root);
305 if proj.exists() {
306 out.extend(load_file(registry, &proj, structural_prefixes));
307 }
308 }
309 for message in &out.messages {
314 if let Some(option) = &message.option {
315 registry.record_failed_assignment(
316 option,
317 message.body.clone(),
318 Some(message.source.clone()),
319 );
320 }
321 }
322 out
323}
324
325pub fn load_file(
330 registry: &ConfigRegistry,
331 path: &Path,
332 structural_prefixes: &[&str],
333) -> LoadOutcome {
334 let mut out = LoadOutcome::default();
335 let text = match std::fs::read_to_string(path) {
336 Ok(s) => s,
337 Err(e) => {
338 out.messages.push(LoadMessage {
339 level: LoadMessageLevel::Error,
340 source: path.to_path_buf(),
341 body: format!("read failed: {e}"),
342 option: None,
344 });
345 return out;
346 }
347 };
348 let table: toml::Table = match text.parse() {
349 Ok(t) => t,
350 Err(e) => {
351 out.messages.push(LoadMessage {
354 level: LoadMessageLevel::Error,
355 source: path.to_path_buf(),
356 body: format!("parse failed: {e}"),
357 option: None,
360 });
361 return out;
362 }
363 };
364 out.raw_tree = table.clone();
368 walk_table(registry, path, &mut out, structural_prefixes, &[], &table);
369 out
370}
371
372fn walk_table(
391 registry: &ConfigRegistry,
392 source: &Path,
393 out: &mut LoadOutcome,
394 structural_prefixes: &[&str],
395 prefix: &[String],
396 table: &toml::Table,
397) {
398 for (key, value) in table {
399 let mut path: Vec<String> = prefix.to_vec();
400 path.push(key.clone());
401 let dotted = path.join(".");
402 match value {
403 toml::Value::Table(sub) => {
404 if apply_tree_if_composite(registry, source, out, &dotted, value) {
411 continue;
412 }
413 if structural_prefixes.iter().any(|p| *p == dotted) {
414 record_namespace_children(source, out, &dotted, sub);
415 } else {
416 walk_table(registry, source, out, structural_prefixes, &path, sub);
417 }
418 }
419 scalar => {
420 apply_scalar(registry, source, out, &dotted, scalar);
421 }
422 }
423 }
424}
425
426fn record_namespace_children(
431 source: &Path,
432 out: &mut LoadOutcome,
433 namespace_dotted: &str,
434 table: &toml::Table,
435) {
436 for (key, value) in table {
437 let dotted = format!("{namespace_dotted}.{key}");
438 match value {
439 toml::Value::Table(sub) => {
440 out.structural.insert(dotted, sub.clone());
441 }
442 _ => {
443 out.messages.push(LoadMessage {
444 level: LoadMessageLevel::Warning,
445 source: source.to_path_buf(),
446 body: format!(
447 "`{dotted}`: structural namespace `{namespace_dotted}` \
448 expected a sub-table; got a scalar",
449 ),
450 option: Some(dotted.to_string()),
451 });
452 }
453 }
454 }
455}
456
457fn apply_scalar(
462 registry: &ConfigRegistry,
463 source: &Path,
464 out: &mut LoadOutcome,
465 dotted: &str,
466 value: &toml::Value,
467) {
468 if apply_tree_if_composite(registry, source, out, dotted, value) {
478 return;
479 }
480 if let toml::Value::Array(items) = value {
481 apply_array(registry, source, out, dotted, items);
482 return;
483 }
484 let formatted = match format_scalar(value) {
485 Some(s) => s,
486 None => {
487 out.messages.push(LoadMessage {
491 level: LoadMessageLevel::Warning,
492 source: source.to_path_buf(),
493 body: format!(
494 "`{dotted}`: list / inline-table values aren't \
495 applicable to scalar options; move it under a \
496 structural section",
497 ),
498 option: Some(dotted.to_string()),
499 });
500 return;
501 }
502 };
503 apply_assignment(registry, source, out, dotted, &formatted);
504}
505
506fn apply_array(
515 registry: &ConfigRegistry,
516 source: &Path,
517 out: &mut LoadOutcome,
518 dotted: &str,
519 items: &[toml::Value],
520) {
521 let accepts_list = registry
522 .lookup(dotted)
523 .map(|opt| opt.accepts_list())
524 .unwrap_or(false);
525 if !accepts_list {
526 out.messages.push(LoadMessage {
527 level: LoadMessageLevel::Warning,
528 source: source.to_path_buf(),
529 body: format!(
530 "`{dotted}`: list / inline-table values aren't \
531 applicable to scalar options; move it under a \
532 structural section",
533 ),
534 option: Some(dotted.to_string()),
535 });
536 return;
537 }
538 let mut parts = Vec::with_capacity(items.len());
539 for item in items {
540 match format_scalar(item) {
541 Some(s) => parts.push(s),
542 None => {
543 out.messages.push(LoadMessage {
544 level: LoadMessageLevel::Warning,
545 source: source.to_path_buf(),
546 body: format!(
547 "`{dotted}`: nested list / table values aren't \
548 valid list elements",
549 ),
550 option: Some(dotted.to_string()),
551 });
552 return;
553 }
554 }
555 }
556 let joined = parts.join(",");
559 apply_assignment(registry, source, out, dotted, &joined);
560}
561
562fn apply_assignment(
566 registry: &ConfigRegistry,
567 source: &Path,
568 out: &mut LoadOutcome,
569 dotted: &str,
570 value: &str,
571) {
572 let assign = format!("{dotted}={value}");
573 if let Err(err) = registry.parse_and_set_command(&assign) {
574 let body = match err {
575 ConfigError::UnknownOption(name) => format!("unknown option `{name}`"),
576 ConfigError::Validation(msg) => format!("`{dotted}`: {msg}"),
577 ConfigError::Parse(msg) => format!("`{dotted}`: parse error: {msg}"),
578 other => format!("`{dotted}`: {other}"),
579 };
580 out.messages.push(LoadMessage {
581 level: LoadMessageLevel::Warning,
582 source: source.to_path_buf(),
583 body,
584 option: Some(dotted.to_string()),
585 });
586 }
587}
588
589fn apply_tree_if_composite(
600 registry: &ConfigRegistry,
601 source: &Path,
602 out: &mut LoadOutcome,
603 dotted: &str,
604 value: &toml::Value,
605) -> bool {
606 let Some(opt) = registry.lookup(dotted) else {
607 return false;
608 };
609 let schema = opt.schema();
610 if !schema.is_composite() {
611 return false;
612 }
613 let tree = match toml_to_config_value(value) {
614 Ok(v) => v,
615 Err(err) => {
616 out.messages.push(LoadMessage {
617 level: LoadMessageLevel::Warning,
618 source: source.to_path_buf(),
619 body: format!("`{dotted}{}`: {}", err.path, err.message),
620 option: Some(dotted.to_string()),
621 });
622 return true;
623 }
624 };
625 if let Err(err) = crate::schema::validate(&schema, &tree) {
633 out.messages.push(LoadMessage {
634 level: LoadMessageLevel::Warning,
635 source: source.to_path_buf(),
636 body: format!("`{dotted}{}`: {}", dot_path(&err.path), err.message),
637 option: Some(dotted.to_string()),
638 });
639 return true;
640 }
641 if let Err(err) = opt.set_value(&tree) {
642 out.messages.push(LoadMessage {
643 level: LoadMessageLevel::Warning,
644 source: source.to_path_buf(),
645 body: format!("`{dotted}`: {err}"),
646 option: Some(dotted.to_string()),
647 });
648 }
649 true
650}
651
652fn format_scalar(value: &toml::Value) -> Option<String> {
656 match value {
657 toml::Value::String(s) => Some(s.clone()),
658 toml::Value::Integer(i) => Some(i.to_string()),
659 toml::Value::Float(f) => Some(f.to_string()),
660 toml::Value::Boolean(b) => Some(if *b { "true".into() } else { "false".into() }),
661 toml::Value::Datetime(d) => Some(d.to_string()),
662 toml::Value::Array(_) | toml::Value::Table(_) => None,
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 #![allow(clippy::unwrap_used, clippy::panic)]
669 use super::*;
670 use crate::option::Option as ConfigOption;
671 use crate::schema::ConfigValue;
674
675 #[test]
676 fn config_home_prefers_absolute_xdg_override() {
677 let custom = if cfg!(windows) {
682 r"C:\custom\xdg"
683 } else {
684 "/custom/xdg"
685 };
686 let got = resolve_config_home(
687 Some(PathBuf::from(custom)),
688 Some(PathBuf::from("/home/u/.config")),
689 );
690 assert_eq!(got, Some(PathBuf::from(custom)));
691 }
692
693 #[test]
694 fn config_home_ignores_relative_or_empty_xdg() {
695 let fallback = Some(PathBuf::from("/home/u/.config"));
697 assert_eq!(
698 resolve_config_home(Some(PathBuf::from("relative/path")), fallback.clone()),
699 fallback
700 );
701 assert_eq!(
703 resolve_config_home(Some(PathBuf::from("")), fallback.clone()),
704 fallback
705 );
706 }
707
708 #[test]
709 fn config_home_falls_back_when_no_override() {
710 let fallback = Some(PathBuf::from("/home/u/.config"));
711 assert_eq!(resolve_config_home(None, fallback.clone()), fallback);
712 }
713
714 #[test]
715 fn config_home_is_none_when_nothing_resolves() {
716 assert_eq!(resolve_config_home(None, None), None);
717 }
718
719 #[test]
720 fn default_user_config_path_ends_with_xdg_lattice_toml() {
721 if let Some(p) = default_user_config_path() {
725 assert!(
726 p.ends_with("lattice/lattice.toml"),
727 "expected .../lattice/lattice.toml, got {p:?}"
728 );
729 }
730 }
731
732 fn registry_with_options() -> ConfigRegistry {
733 let r = ConfigRegistry::new();
734 r.register(ConfigOption::<bool>::new(
735 "number",
736 true,
737 "Show absolute line numbers.",
738 ));
739 r.register(ConfigOption::<i64>::new("tabstop", 8, "Tab width."));
740 r.register(
741 ConfigOption::<i64>::builder("scrolloff", 0, "Scroll-off margin.")
742 .validate(|i| {
743 if (0..=64).contains(i) {
744 Ok(())
745 } else {
746 Err(format!("scrolloff out of range [0, 64]: {i}"))
747 }
748 })
749 .build(),
750 );
751 r.register(ConfigOption::<String>::new(
752 "ui.separator",
753 "│".into(),
754 "Pane separator glyph.",
755 ));
756 r
757 }
758
759 fn write_temp(name: &str, contents: &str) -> PathBuf {
760 let dir = std::env::temp_dir().join(format!(
761 "lattice-loader-test-{}-{}",
762 std::process::id(),
763 name,
764 ));
765 let _ = std::fs::remove_dir_all(&dir);
766 std::fs::create_dir_all(&dir).unwrap();
767 let path = dir.join("lattice.toml");
768 std::fs::write(&path, contents).unwrap();
769 path
770 }
771
772 #[derive(Debug, Clone, PartialEq, Eq)]
781 struct Template {
782 key: String,
783 target: String,
784 body: Option<String>,
785 }
786
787 #[derive(Debug, Clone, PartialEq, Eq, Default)]
788 struct Templates(Vec<Template>);
789
790 impl crate::OptionType for Templates {
791 fn parse(s: &str) -> Result<Self, String> {
798 if s.is_empty() {
799 return Ok(Templates(Vec::new()));
800 }
801 s.split(';')
802 .map(|item| {
803 let (key, target) = item
804 .split_once('>')
805 .ok_or_else(|| format!("expected `key>target`, got `{item}`"))?;
806 Ok(Template {
807 key: key.to_string(),
808 target: target.to_string(),
809 body: None,
810 })
811 })
812 .collect::<Result<Vec<_>, String>>()
813 .map(Templates)
814 }
815
816 fn format(&self) -> String {
817 self.0
818 .iter()
819 .map(|t| format!("{}>{}", t.key, t.target))
820 .collect::<Vec<_>>()
821 .join(";")
822 }
823
824 fn type_label() -> &'static str {
825 "templates"
826 }
827
828 fn schema() -> crate::ConfigSchema {
829 use crate::{ConfigSchema, SchemaField};
830 ConfigSchema::list(ConfigSchema::record([
831 SchemaField::new("key", ConfigSchema::string(), "the key to press"),
832 SchemaField::new(
833 "target",
834 ConfigSchema::record([SchemaField::new(
835 "file",
836 ConfigSchema::string(),
837 "where it lands",
838 )]),
839 "where the capture goes",
840 ),
841 SchemaField::new("body", ConfigSchema::string(), "template body").optional(),
842 ]))
843 }
844
845 fn to_value(&self) -> ConfigValue {
846 ConfigValue::List(
847 self.0
848 .iter()
849 .map(|t| {
850 let mut fields = vec![
851 ("key".to_string(), ConfigValue::Str(t.key.clone())),
852 (
853 "target".to_string(),
854 ConfigValue::record([(
855 "file".to_string(),
856 ConfigValue::Str(t.target.clone()),
857 )]),
858 ),
859 ];
860 if let Some(b) = &t.body {
861 fields.push(("body".to_string(), ConfigValue::Str(b.clone())));
862 }
863 ConfigValue::record(fields)
864 })
865 .collect(),
866 )
867 }
868
869 fn from_value(value: &ConfigValue) -> Result<Self, String> {
870 let items = value
871 .as_list()
872 .ok_or_else(|| format!("expected list, got {}", value.kind_label()))?;
873 items
874 .iter()
875 .map(|item| {
876 let key = item
877 .field("key")
878 .and_then(ConfigValue::as_str)
879 .ok_or("missing `key`")?
880 .to_string();
881 let target = item
882 .field("target")
883 .and_then(|t| t.field("file"))
884 .and_then(ConfigValue::as_str)
885 .ok_or("missing `target.file`")?
886 .to_string();
887 let body = item
888 .field("body")
889 .and_then(ConfigValue::as_str)
890 .map(str::to_string);
891 Ok(Template { key, target, body })
892 })
893 .collect::<Result<Vec<_>, String>>()
894 .map(Templates)
895 }
896 }
897
898 fn registry_with_a_composite_option() -> ConfigRegistry {
899 let r = registry_with_options();
900 r.register(ConfigOption::<Templates>::new(
901 "org.capture-templates",
902 Templates::default(),
903 "Capture templates.",
904 ));
905 r
906 }
907
908 fn templates_of(r: &ConfigRegistry) -> Templates {
909 let opt = r.lookup("org.capture-templates").unwrap();
910 <Templates as crate::OptionType>::from_value(&opt.get_value()).unwrap()
911 }
912
913 #[test]
914 fn an_array_of_tables_lands_as_a_composite_options_value() {
915 let r = registry_with_a_composite_option();
920 let p = write_temp(
921 "composite-array-of-tables",
922 "[[org.capture-templates]]\n\
923 key = \"t\"\n\
924 target = { file = \"~/org/refile.org\" }\n\
925 body = \"\"\"\n\
926 * TODO %?\n\
927 \"\"\"\n\
928 \n\
929 [[org.capture-templates]]\n\
930 key = \"n\"\n\
931 target = { file = \"~/org/notes.org\" }\n",
932 );
933 let out = load_file(&r, &p, &[]);
934 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
935
936 let got = templates_of(&r);
937 assert_eq!(got.0.len(), 2);
938 assert_eq!(got.0[0].key, "t");
939 assert_eq!(got.0[0].target, "~/org/refile.org");
940 assert_eq!(got.0[0].body.as_deref(), Some("* TODO %?\n"));
946 assert_eq!(got.0[1].key, "n");
947 assert_eq!(got.0[1].body, None, "an absent optional field stays absent");
948 }
949
950 #[test]
951 fn a_table_at_an_options_name_is_its_value_not_a_namespace() {
952 let r = registry_with_options();
957 r.register(ConfigOption::<Templates>::new(
958 "org.one-template",
959 Templates::default(),
960 "",
961 ));
962 let p = write_temp(
967 "composite-table",
968 "[org.one-template]\nkey = \"t\"\ntarget = { file = \"a.org\" }\n",
969 );
970 let out = load_file(&r, &p, &[]);
971 assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
972 let body = &out.messages[0].body;
973 assert!(body.contains("org.one-template"), "{body}");
974 assert!(body.contains("expected list"), "{body}");
975 assert!(
976 !body.contains("unknown option"),
977 "the fields must not be read as options: {body}"
978 );
979 }
980
981 #[test]
982 fn a_shape_mismatch_is_reported_with_its_path() {
983 let r = registry_with_a_composite_option();
987 let p = write_temp(
988 "composite-bad-leaf",
989 "[[org.capture-templates]]\n\
990 key = \"t\"\n\
991 target = { file = \"a.org\" }\n\
992 \n\
993 [[org.capture-templates]]\n\
994 key = \"n\"\n\
995 target = { file = 7 }\n",
996 );
997 let out = load_file(&r, &p, &[]);
998 assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
999 let body = &out.messages[0].body;
1000 assert!(
1001 body.contains("org.capture-templates[1].target.file"),
1002 "the path must name the index AND the field: {body}"
1003 );
1004 assert!(body.contains("expected string"), "{body}");
1005 assert!(body.contains("integer"), "{body}");
1006 assert_eq!(templates_of(&r).0.len(), 0);
1010 }
1011
1012 #[test]
1013 fn a_misspelled_field_names_the_key_and_the_alternatives() {
1014 let r = registry_with_a_composite_option();
1015 let p = write_temp(
1016 "composite-typo",
1017 "[[org.capture-templates]]\n\
1018 key = \"t\"\n\
1019 target = { file = \"a.org\" }\n\
1020 bodyy = \"oops\"\n",
1021 );
1022 let out = load_file(&r, &p, &[]);
1023 assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
1024 let body = &out.messages[0].body;
1025 assert!(body.contains("org.capture-templates[0].bodyy"), "{body}");
1026 assert!(body.contains("unknown field"), "{body}");
1027 assert!(body.contains("body"), "{body}");
1028 }
1029
1030 #[test]
1031 fn a_float_is_refused_by_name_rather_than_stringified() {
1032 let r = registry_with_a_composite_option();
1036 let p = write_temp(
1037 "composite-float",
1038 "[[org.capture-templates]]\nkey = 1.5\ntarget = { file = \"a.org\" }\n",
1039 );
1040 let out = load_file(&r, &p, &[]);
1041 assert_eq!(out.messages.len(), 1, "messages: {:?}", out.messages);
1042 let body = &out.messages[0].body;
1043 assert!(body.contains("org.capture-templates[0].key"), "{body}");
1044 assert!(body.contains("floating-point"), "{body}");
1045 }
1046
1047 #[test]
1048 fn scalar_options_are_untouched_by_the_composite_path() {
1049 let r = registry_with_a_composite_option();
1054 let p = write_temp(
1055 "composite-no-regression",
1056 "[completion.per-language.markdown]\nsources = \"buffer\"\n\
1057 \n[ui]\nseparator = \"|\"\n\
1058 \n[tabstop]\nnope = 1\n",
1059 );
1060 let out = load_file(&r, &p, &["completion.per-language"]);
1061 assert!(
1062 out.structural
1063 .contains_key("completion.per-language.markdown"),
1064 "a structural namespace is still captured whole: {:?}",
1065 out.structural.keys().collect::<Vec<_>>()
1066 );
1067 assert_eq!(r.lookup("ui.separator").unwrap().get_formatted(), "|");
1068 assert!(
1071 out.messages
1072 .iter()
1073 .any(|m| m.body.contains("unknown option")),
1074 "messages: {:?}",
1075 out.messages
1076 );
1077 }
1078
1079 #[test]
1080 fn missing_file_returns_empty_outcome() {
1081 let r = registry_with_options();
1082 let path = std::env::temp_dir().join("lattice-loader-no-such-file.toml");
1083 let _ = std::fs::remove_file(&path);
1084 let out = load_file(&r, &path, &[]);
1085 assert_eq!(out.messages.len(), 1);
1087 assert_eq!(out.messages[0].level, LoadMessageLevel::Error);
1088 assert!(out.structural.is_empty());
1089 }
1090
1091 #[test]
1092 fn parses_well_formed_toml_and_writes_scalars() {
1093 let r = registry_with_options();
1094 let p = write_temp(
1095 "well-formed",
1096 "number = false\ntabstop = 4\n[ui]\nseparator = \"|\"\n",
1097 );
1098 let out = load_file(&r, &p, &[]);
1099 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1100 let opt_number = r.lookup("number").unwrap();
1101 assert_eq!(opt_number.get_formatted(), "false");
1102 let opt_tabstop = r.lookup("tabstop").unwrap();
1103 assert_eq!(opt_tabstop.get_formatted(), "4");
1104 let opt_sep = r.lookup("ui.separator").unwrap();
1105 assert_eq!(opt_sep.get_formatted(), "|");
1106 }
1107
1108 #[test]
1109 fn parse_error_emits_one_error_and_stops() {
1110 let r = registry_with_options();
1111 let p = write_temp("malformed", "number = ?broken?\n");
1112 let out = load_file(&r, &p, &[]);
1113 assert_eq!(out.messages.len(), 1);
1114 assert_eq!(out.messages[0].level, LoadMessageLevel::Error);
1115 assert!(out.messages[0].body.contains("parse failed"));
1116 assert_eq!(r.lookup("number").unwrap().get_formatted(), "true");
1118 }
1119
1120 #[test]
1121 fn unknown_key_emits_warning_other_keys_still_apply() {
1122 let r = registry_with_options();
1123 let p = write_temp("unknown", "number = false\nbogus.key = 42\ntabstop = 2\n");
1124 let out = load_file(&r, &p, &[]);
1125 assert_eq!(out.messages.len(), 1);
1126 assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1127 assert!(out.messages[0].body.contains("unknown option"));
1128 assert_eq!(r.lookup("number").unwrap().get_formatted(), "false");
1129 assert_eq!(r.lookup("tabstop").unwrap().get_formatted(), "2");
1130 }
1131
1132 #[test]
1133 fn validation_failure_warns_with_dotted_path_and_skips() {
1134 let r = registry_with_options();
1135 let p = write_temp("invalid", "scrolloff = 999\n");
1136 let out = load_file(&r, &p, &[]);
1137 assert_eq!(out.messages.len(), 1);
1138 assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1139 assert!(out.messages[0].body.contains("scrolloff"));
1140 assert_eq!(r.lookup("scrolloff").unwrap().get_formatted(), "0");
1142 }
1143
1144 #[test]
1145 fn list_at_scalar_position_warns_without_panic() {
1146 let r = registry_with_options();
1147 let p = write_temp("list", "number = [1, 2, 3]\n");
1148 let out = load_file(&r, &p, &[]);
1149 assert_eq!(out.messages.len(), 1);
1150 assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1151 assert!(out.messages[0].body.contains("list"));
1152 }
1153
1154 #[test]
1155 fn toml_array_applies_to_a_list_typed_option() {
1156 let r = registry_with_options();
1160 r.register(ConfigOption::<crate::ModelineZone>::new(
1161 "ui.modeline.left",
1162 crate::ModelineZone::Auto,
1163 "Left modeline zone.",
1164 ));
1165 let p = write_temp(
1166 "modeline-array",
1167 "[ui.modeline]\nleft = [\"core.mode\", \"core.path\"]\n",
1168 );
1169 let out = load_file(&r, &p, &[]);
1170 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1171 assert_eq!(
1172 r.lookup("ui.modeline.left").unwrap().get_formatted(),
1173 "core.mode,core.path",
1174 );
1175 }
1176
1177 #[test]
1178 fn empty_toml_array_clears_a_list_typed_zone() {
1179 let r = registry_with_options();
1182 r.register(ConfigOption::<crate::ModelineZone>::new(
1183 "ui.modeline.right",
1184 crate::ModelineZone::Auto,
1185 "Right modeline zone.",
1186 ));
1187 let p = write_temp("modeline-empty", "[ui.modeline]\nright = []\n");
1188 let out = load_file(&r, &p, &[]);
1189 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1190 assert_eq!(r.lookup("ui.modeline.right").unwrap().get_formatted(), "");
1192 }
1193
1194 #[test]
1195 fn structural_prefix_section_is_buckted_not_walked() {
1196 let r = registry_with_options();
1201 let p = write_temp(
1202 "structural",
1203 "[completion.per-language.markdown]\n\
1204 sources = [\"snippet\", \"buffer-words\"]\n\
1205 auto_trigger = false\n",
1206 );
1207 let out = load_file(&r, &p, &["completion.per-language"]);
1208 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1209 let md = out
1210 .structural
1211 .get("completion.per-language.markdown")
1212 .expect("markdown sub-table recorded");
1213 assert!(md.contains_key("sources"));
1214 assert!(md.contains_key("auto_trigger"));
1215 }
1216
1217 #[test]
1218 fn structural_namespace_with_scalar_child_warns() {
1219 let r = registry_with_options();
1223 let p = write_temp("ns-scalar", "[completion.per-language]\nbroken = 1\n");
1224 let out = load_file(&r, &p, &["completion.per-language"]);
1225 assert_eq!(out.messages.len(), 1);
1226 assert_eq!(out.messages[0].level, LoadMessageLevel::Warning);
1227 assert!(out.messages[0].body.contains("structural namespace"));
1228 assert!(out.structural.is_empty());
1229 }
1230
1231 #[test]
1232 fn structural_namespace_records_each_child_separately() {
1233 let r = registry_with_options();
1235 let p = write_temp(
1236 "two-langs",
1237 "[completion.per-language.markdown]\n\
1238 auto_trigger = false\n\
1239 [completion.per-language.rust]\n\
1240 auto_trigger = true\n",
1241 );
1242 let out = load_file(&r, &p, &["completion.per-language"]);
1243 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1244 assert_eq!(out.structural.len(), 2);
1245 assert!(
1246 out.structural
1247 .contains_key("completion.per-language.markdown")
1248 );
1249 assert!(out.structural.contains_key("completion.per-language.rust"));
1250 }
1251
1252 #[test]
1253 fn structural_section_alongside_scalar_keys_in_same_parent() {
1254 let r = registry_with_options();
1259 r.register(ConfigOption::<bool>::new(
1260 "completion.auto_insert_single",
1261 true,
1262 "",
1263 ));
1264 let p = write_temp(
1265 "mixed",
1266 "[completion]\n\
1267 auto_insert_single = false\n\
1268 [completion.per-language.markdown]\n\
1269 auto_trigger = false\n",
1270 );
1271 let out = load_file(&r, &p, &["completion.per-language"]);
1272 assert!(out.messages.is_empty(), "messages: {:?}", out.messages);
1273 assert_eq!(
1274 r.lookup("completion.auto_insert_single")
1275 .unwrap()
1276 .get_formatted(),
1277 "false",
1278 );
1279 assert!(
1280 out.structural
1281 .contains_key("completion.per-language.markdown")
1282 );
1283 }
1284
1285 #[test]
1286 fn extend_merges_messages_and_structural_in_order() {
1287 let mut a = LoadOutcome::default();
1288 a.messages.push(LoadMessage {
1289 level: LoadMessageLevel::Warning,
1290 source: PathBuf::from("/a"),
1291 body: "first".into(),
1292 option: None,
1293 });
1294 a.structural.insert("k1".into(), toml::Table::new());
1295 let mut b = LoadOutcome::default();
1296 b.messages.push(LoadMessage {
1297 level: LoadMessageLevel::Warning,
1298 source: PathBuf::from("/b"),
1299 body: "second".into(),
1300 option: None,
1301 });
1302 b.structural.insert("k2".into(), toml::Table::new());
1303 a.extend(b);
1304 assert_eq!(a.messages.len(), 2);
1305 assert_eq!(a.messages[0].body, "first");
1306 assert_eq!(a.messages[1].body, "second");
1307 assert_eq!(a.structural.len(), 2);
1308 }
1309
1310 #[test]
1311 fn load_file_populates_raw_tree_with_full_parsed_table() {
1312 let r = registry_with_options();
1313 let p = write_temp(
1314 "raw-tree",
1315 "[lsp.rust-analyzer.cargo]\n\
1316 features = [\"foo\", \"bar\"]\n\
1317 [lsp.rust-analyzer.checkOnSave]\n\
1318 enable = true\n",
1319 );
1320 let out = load_file(&r, &p, &["lsp"]);
1321 let features = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.cargo.features")
1325 .expect("features path");
1326 let arr = features.as_array().expect("array");
1327 assert_eq!(arr.len(), 2);
1328 assert_eq!(arr[0].as_str(), Some("foo"));
1329 assert_eq!(arr[1].as_str(), Some("bar"));
1330 let enable = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.checkOnSave.enable")
1331 .expect("enable path");
1332 assert_eq!(enable.as_bool(), Some(true));
1333 }
1334
1335 #[test]
1336 fn extend_deep_merges_raw_trees_preserving_sibling_keys() {
1337 let r = registry_with_options();
1343 let user = write_temp("deep-user", "[lsp.rust-analyzer]\ncheckOnSave = true\n");
1344 let proj = write_temp(
1345 "deep-proj",
1346 "[lsp.rust-analyzer.cargo]\nfeatures = [\"proj\"]\n",
1347 );
1348 let mut out = load_file(&r, &user, &["lsp"]);
1349 out.extend(load_file(&r, &proj, &["lsp"]));
1350 let check = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.checkOnSave")
1351 .and_then(|v| v.as_bool());
1352 assert_eq!(check, Some(true), "user's checkOnSave preserved");
1353 let features = lookup_dotted_path(&out.raw_tree, "lsp.rust-analyzer.cargo.features")
1354 .and_then(|v| v.as_array())
1355 .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>());
1356 assert_eq!(features, Some(vec!["proj"]), "project's features applied");
1357 }
1358
1359 #[test]
1360 fn extend_overrides_scalars_with_later_values() {
1361 let r = registry_with_options();
1364 let user = write_temp("scalar-user", "tabstop = 2\n");
1365 let proj = write_temp("scalar-proj", "tabstop = 8\n");
1366 let mut out = load_file(&r, &user, &[]);
1367 out.extend(load_file(&r, &proj, &[]));
1368 let ts = lookup_dotted_path(&out.raw_tree, "tabstop").unwrap();
1369 assert_eq!(ts.as_integer(), Some(8));
1370 }
1371
1372 #[test]
1373 fn lookup_dotted_path_returns_none_for_missing_segments() {
1374 let mut t = toml::Table::new();
1375 t.insert("a".into(), toml::Value::String("hello".into()));
1376 assert!(lookup_dotted_path(&t, "missing").is_none());
1377 assert!(lookup_dotted_path(&t, "a.deeper").is_none());
1379 assert_eq!(
1380 lookup_dotted_path(&t, "a").and_then(|v| v.as_str()),
1381 Some("hello"),
1382 );
1383 }
1384
1385 #[test]
1386 fn project_path_helper_lands_at_dot_lattice_config_toml() {
1387 let p = project_config_path(Path::new("/workspace/foo"));
1388 assert_eq!(p, PathBuf::from("/workspace/foo/.lattice/config.toml"));
1389 }
1390}