1use clapfig::Schema;
8use lex_babel::formats::lex::formatting_rules::FormattingRules;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::Path;
12
13mod rule_config;
14pub use rule_config::{RuleConfig, RuleOptions, Severity};
15
16pub const CONFIG_FILE_NAME: &str = ".lex.toml";
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct LabelsConfig {
41 pub namespaces: BTreeMap<String, NamespaceSpec>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(untagged)]
59pub enum NamespaceSpec {
60 Uri(String),
62 Table(NamespaceTable),
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct NamespaceTable {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub tap: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub uri: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub rev: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub subdir: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub via: Option<Via>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum Via {
105 Https,
107 Git,
110}
111
112impl Via {
113 pub fn as_query_value(self) -> &'static str {
117 match self {
118 Via::Https => "https",
119 Via::Git => "git",
120 }
121 }
122}
123
124impl NamespaceSpec {
125 pub fn canonical_uri(&self) -> Result<String, LabelsConfigError> {
135 match self {
136 NamespaceSpec::Uri(s) => Ok(s.clone()),
137 NamespaceSpec::Table(t) => {
138 t.validate()?;
139 let base = match (&t.tap, &t.uri) {
140 (Some(tap), None) => format!("github:{tap}/lex-labels"),
141 (None, Some(uri)) => uri.clone(),
142 (Some(_), Some(_)) => {
143 return Err(LabelsConfigError::TapAndUri);
144 }
145 (None, None) => {
146 return Err(LabelsConfigError::EmptyTable);
147 }
148 };
149 let mut out = base;
150 if let Some(rev) = &t.rev {
151 if out.contains('#') {
152 return Err(LabelsConfigError::RevWithExplicitFragment {
161 uri: out,
162 rev: rev.clone(),
163 });
164 }
165 out.push('#');
166 out.push_str(rev);
167 }
168 if let Some(subdir) = &t.subdir {
169 out.push_str(if out.contains('?') { "&" } else { "?" });
170 out.push_str("subdir=");
171 out.push_str(subdir);
172 }
173 if t.via == Some(Via::Git) {
177 out.push_str(if out.contains('?') { "&" } else { "?" });
178 out.push_str("via=");
179 out.push_str(Via::Git.as_query_value());
180 }
181 Ok(out)
182 }
183 }
184 }
185}
186
187impl NamespaceTable {
188 pub fn validate(&self) -> Result<(), LabelsConfigError> {
195 match (&self.tap, &self.uri) {
196 (Some(_), Some(_)) => return Err(LabelsConfigError::TapAndUri),
197 (None, None) => return Err(LabelsConfigError::EmptyTable),
198 _ => {}
199 }
200 if self.via.is_some() {
201 let on_template =
202 self.tap.is_some() || self.uri.as_deref().is_some_and(is_template_scheme_uri);
203 if !on_template {
204 return Err(LabelsConfigError::ViaOnNonTemplateScheme {
205 uri: self.uri.clone().unwrap_or_default(),
206 });
207 }
208 }
209 Ok(())
210 }
211}
212
213fn is_template_scheme_uri(uri: &str) -> bool {
217 let Some((scheme, _)) = uri.split_once(':') else {
218 return false;
219 };
220 matches!(scheme.to_ascii_lowercase().as_str(), "github" | "gitlab")
221}
222
223#[derive(Debug)]
226#[non_exhaustive]
227pub enum LabelsConfigError {
228 Io {
230 path: std::path::PathBuf,
231 source: std::io::Error,
232 },
233 Parse {
235 path: std::path::PathBuf,
236 message: String,
237 },
238 ReservedNamespace,
242 TapAndUri,
245 EmptyTable,
247 RevWithExplicitFragment { uri: String, rev: String },
251 ViaOnNonTemplateScheme { uri: String },
256}
257
258impl std::fmt::Display for LabelsConfigError {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 match self {
261 LabelsConfigError::Io { path, source } => {
262 write!(f, "{}: io error reading labels config: {source}", path.display())
263 }
264 LabelsConfigError::Parse { path, message } => {
265 write!(f, "{}: labels config parse error: {message}", path.display())
266 }
267 LabelsConfigError::ReservedNamespace => f.write_str(
268 "namespace `lex` is reserved for core-defined labels and cannot be declared in [labels]",
269 ),
270 LabelsConfigError::TapAndUri => {
271 f.write_str("namespace spec sets both `tap` and `uri`; they are mutually exclusive")
272 }
273 LabelsConfigError::EmptyTable => f.write_str(
274 "namespace spec table needs one of `tap` or `uri` set",
275 ),
276 LabelsConfigError::RevWithExplicitFragment { uri, rev } => write!(
277 f,
278 "namespace spec sets both `rev = {rev:?}` and an explicit `#fragment` in uri `{uri}`; pick one"
279 ),
280 LabelsConfigError::ViaOnNonTemplateScheme { uri } => write!(
281 f,
282 "`via` is only valid on `tap` shorthand or `github:` / `gitlab:` URIs; got `{uri}`"
283 ),
284 }
285 }
286}
287
288impl std::error::Error for LabelsConfigError {
289 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
290 match self {
291 LabelsConfigError::Io { source, .. } => Some(source),
292 _ => None,
293 }
294 }
295}
296
297pub fn load_labels_from_toml(path: impl AsRef<Path>) -> Result<LabelsConfig, LabelsConfigError> {
306 let path = path.as_ref();
307 let body = std::fs::read_to_string(path).map_err(|source| LabelsConfigError::Io {
308 path: path.to_path_buf(),
309 source,
310 })?;
311
312 let root: toml::Value =
316 body.parse()
317 .map_err(|err: toml::de::Error| LabelsConfigError::Parse {
318 path: path.to_path_buf(),
319 message: err.to_string(),
320 })?;
321 let Some(labels_value) = root.get("labels") else {
322 return Ok(LabelsConfig::default());
323 };
324 let mut config: LabelsConfig =
325 labels_value
326 .clone()
327 .try_into()
328 .map_err(|err: toml::de::Error| LabelsConfigError::Parse {
329 path: path.to_path_buf(),
330 message: err.to_string(),
331 })?;
332
333 if config.namespaces.contains_key("lex") {
334 return Err(LabelsConfigError::ReservedNamespace);
335 }
336 for spec in config.namespaces.values_mut() {
337 if let NamespaceSpec::Table(t) = spec {
338 t.validate()?;
339 }
340 }
341 Ok(config)
342}
343
344#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
346pub struct LexConfig {
347 pub formatting: FormattingConfig,
349 pub inspect: InspectConfig,
351 pub convert: ConvertConfig,
353 pub diagnostics: DiagnosticsConfig,
355 pub includes: IncludesConfig,
357 #[clapfig(value, optional)]
367 #[serde(default)]
368 pub labels: BTreeMap<String, NamespaceSpec>,
369}
370
371#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
373pub struct FormattingConfig {
374 pub rules: FormattingRulesConfig,
376 #[clapfig(default = false)]
378 pub format_on_save: bool,
379}
380
381#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
383pub struct FormattingRulesConfig {
384 #[clapfig(default = 1)]
386 pub session_blank_lines_before: usize,
387 #[clapfig(default = 1)]
389 pub session_blank_lines_after: usize,
390 #[clapfig(default = true)]
392 pub normalize_seq_markers: bool,
393 #[clapfig(value, default = "-")]
398 pub unordered_seq_marker: char,
399 #[clapfig(default = 2)]
401 pub max_blank_lines: usize,
402 #[clapfig(default = " ")]
404 pub indent_string: String,
405 #[clapfig(default = false)]
407 pub preserve_trailing_blanks: bool,
408 #[clapfig(default = true)]
410 pub normalize_verbatim_markers: bool,
411}
412
413impl From<FormattingRulesConfig> for FormattingRules {
414 fn from(config: FormattingRulesConfig) -> Self {
415 FormattingRules {
416 session_blank_lines_before: config.session_blank_lines_before,
417 session_blank_lines_after: config.session_blank_lines_after,
418 normalize_seq_markers: config.normalize_seq_markers,
419 unordered_seq_marker: config.unordered_seq_marker,
420 max_blank_lines: config.max_blank_lines,
421 indent_string: config.indent_string,
422 preserve_trailing_blanks: config.preserve_trailing_blanks,
423 normalize_verbatim_markers: config.normalize_verbatim_markers,
424 }
425 }
426}
427
428impl From<&FormattingRulesConfig> for FormattingRules {
429 fn from(config: &FormattingRulesConfig) -> Self {
430 FormattingRules {
431 session_blank_lines_before: config.session_blank_lines_before,
432 session_blank_lines_after: config.session_blank_lines_after,
433 normalize_seq_markers: config.normalize_seq_markers,
434 unordered_seq_marker: config.unordered_seq_marker,
435 max_blank_lines: config.max_blank_lines,
436 indent_string: config.indent_string.clone(),
437 preserve_trailing_blanks: config.preserve_trailing_blanks,
438 normalize_verbatim_markers: config.normalize_verbatim_markers,
439 }
440 }
441}
442
443#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
445pub struct InspectConfig {
446 pub ast: InspectAstConfig,
448 pub nodemap: NodemapConfig,
450}
451
452#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
453pub struct InspectAstConfig {
454 #[clapfig(default = false)]
456 pub include_all_properties: bool,
457 #[clapfig(default = true)]
459 pub show_line_numbers: bool,
460}
461
462#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
463pub struct NodemapConfig {
464 #[clapfig(default = false)]
466 pub color_blocks: bool,
467 #[clapfig(default = false)]
469 pub color_characters: bool,
470 #[clapfig(default = false)]
472 pub show_summary: bool,
473}
474
475#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
477pub struct ConvertConfig {
478 pub pdf: PdfConfig,
480 pub html: HtmlConfig,
482}
483
484#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
485pub struct PdfConfig {
486 #[clapfig(default = "lexed")]
488 pub size: PdfPageSize,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Schema, Serialize, Deserialize)]
492pub enum PdfPageSize {
493 #[serde(rename = "lexed")]
494 LexEd,
495 #[serde(rename = "mobile")]
496 Mobile,
497}
498
499#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
500pub struct HtmlConfig {
501 #[clapfig(default = "default")]
503 pub theme: String,
504 pub custom_css: Option<String>,
506}
507
508#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
510pub struct DiagnosticsConfig {
511 pub rules: DiagnosticsRulesConfig,
517}
518
519#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
543pub struct DiagnosticsRulesConfig {
544 #[clapfig(value, default = "deny")]
547 pub missing_footnote: RuleConfig,
548 #[clapfig(value, default = "warn")]
551 pub unused_footnote: RuleConfig,
552 #[clapfig(value, default = "warn")]
555 pub table_inconsistent_columns: RuleConfig,
556 #[clapfig(value, default = "deny")]
559 pub forbidden_label_prefix: RuleConfig,
560 #[clapfig(value, default = "deny")]
564 pub unknown_lex_canonical: RuleConfig,
565 #[clapfig(value, default = "warn")]
572 pub spellcheck: RuleConfig,
573 #[clapfig(value, default = "warn")]
577 pub missing_session_target: RuleConfig,
578 #[clapfig(value, default = "warn")]
583 pub missing_definition_target: RuleConfig,
584 #[clapfig(value, default = "warn")]
588 pub missing_annotation_target: RuleConfig,
589 #[clapfig(value, default = "warn")]
593 pub missing_citation_target: RuleConfig,
594 #[clapfig(value, default = "warn")]
600 pub malformed_url: RuleConfig,
601 #[clapfig(value, default = "warn")]
608 pub missing_file_target: RuleConfig,
609 pub schema: SchemaRulesConfig,
611}
612
613impl DiagnosticsRulesConfig {
614 pub fn lookup_by_code(&self, code: &str) -> Option<&RuleConfig> {
624 match code {
625 "missing-footnote" => Some(&self.missing_footnote),
626 "unused-footnote" => Some(&self.unused_footnote),
627 "table-inconsistent-columns" => Some(&self.table_inconsistent_columns),
628 "forbidden-label-prefix" => Some(&self.forbidden_label_prefix),
629 "unknown-lex-canonical" => Some(&self.unknown_lex_canonical),
630 "missing-session-target" => Some(&self.missing_session_target),
631 "missing-definition-target" => Some(&self.missing_definition_target),
632 "missing-annotation-target" => Some(&self.missing_annotation_target),
633 "missing-citation-target" => Some(&self.missing_citation_target),
634 "malformed-url" => Some(&self.malformed_url),
635 "missing-file-target" => Some(&self.missing_file_target),
636 "schema.unknown-label" => Some(&self.schema.unknown_label),
644 "schema.missing-param" => Some(&self.schema.missing_param),
645 "schema.param-type-mismatch" => Some(&self.schema.param_type_mismatch),
646 "schema.bad-attachment" => Some(&self.schema.bad_attachment),
647 "schema.body-shape-mismatch" => Some(&self.schema.body_shape_mismatch),
648 _ => None,
649 }
650 }
651}
652
653#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
658pub struct SchemaRulesConfig {
659 #[clapfig(value, default = "deny")]
663 pub unknown_label: RuleConfig,
664 #[clapfig(value, default = "deny")]
667 pub missing_param: RuleConfig,
668 #[clapfig(value, default = "deny")]
671 pub param_type_mismatch: RuleConfig,
672 #[clapfig(value, default = "deny")]
676 pub bad_attachment: RuleConfig,
677 #[clapfig(value, default = "deny")]
680 pub body_shape_mismatch: RuleConfig,
681}
682
683#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
686pub struct IncludesConfig {
687 pub root: Option<String>,
699 #[clapfig(default = 8)]
702 pub max_depth: usize,
703 #[clapfig(default = 1000)]
708 pub max_total_includes: usize,
709 #[clapfig(default = 10485760)]
714 pub max_file_size: u64,
715}
716
717#[derive(Debug, Clone)]
728pub struct LoadedLexConfig {
729 pub config: LexConfig,
730 pub extension_diagnostic_rules: BTreeMap<String, RuleConfig>,
735}
736
737impl LoadedLexConfig {
738 pub fn lookup_diagnostic_rule(&self, code: &str) -> Option<&RuleConfig> {
744 self.config
745 .diagnostics
746 .rules
747 .lookup_by_code(code)
748 .or_else(|| self.extension_diagnostic_rules.get(code))
749 }
750}
751
752pub const DIAGNOSTICS_RULES_PATH: &str = "diagnostics.rules";
761
762pub fn collect_extension_diagnostic_rules(
774 unknowns: Vec<clapfig::CollectedUnknown>,
775) -> BTreeMap<String, RuleConfig> {
776 let prefix = format!("{DIAGNOSTICS_RULES_PATH}.");
777 let mut out = BTreeMap::new();
778 for u in unknowns {
779 let Some(key) = u.path.strip_prefix(&prefix) else {
780 continue;
781 };
782 let Some(value) = u.value else { continue };
783 if let Ok(rule) = value.try_into() {
784 out.insert(key.to_string(), rule);
785 }
786 }
787 out
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793
794 fn load_defaults() -> LexConfig {
795 let (config, _unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
796 .app_name("lex")
797 .no_env()
798 .search_paths(vec![])
799 .accept_dotted_extension_keys_in(
800 DIAGNOSTICS_RULES_PATH,
801 clapfig::UnknownKeyDecision::Collect,
802 )
803 .load_with_unknowns()
804 .expect("defaults to load");
805 config
806 }
807
808 #[test]
809 fn loads_default_config() {
810 let config = load_defaults();
811 assert_eq!(config.formatting.rules.session_blank_lines_before, 1);
812 assert!(config.inspect.ast.show_line_numbers);
813 assert_eq!(config.convert.pdf.size, PdfPageSize::LexEd);
814 }
815
816 fn load_from(toml_body: &str) -> LexConfig {
817 load_wrapper_from(toml_body).config
818 }
819
820 fn load_wrapper_from(toml_body: &str) -> LoadedLexConfig {
821 let dir = tempfile::tempdir().unwrap();
822 let path = dir.path().join(CONFIG_FILE_NAME);
823 std::fs::write(&path, toml_body).unwrap();
824 let (config, unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
825 .app_name("lex")
826 .file_name(CONFIG_FILE_NAME)
827 .no_env()
828 .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
829 .accept_dotted_extension_keys_in(
830 DIAGNOSTICS_RULES_PATH,
831 clapfig::UnknownKeyDecision::Collect,
832 )
833 .load_with_unknowns()
834 .expect("loads");
835 LoadedLexConfig {
836 config,
837 extension_diagnostic_rules: collect_extension_diagnostic_rules(unknowns),
838 }
839 }
840
841 #[test]
842 fn diagnostics_rules_defaults_in_place() {
843 let cfg = load_defaults();
844 assert_eq!(
845 cfg.diagnostics.rules.missing_footnote.severity(),
846 Severity::Deny
847 );
848 assert_eq!(
849 cfg.diagnostics.rules.unused_footnote.severity(),
850 Severity::Warn
851 );
852 assert_eq!(
853 cfg.diagnostics.rules.table_inconsistent_columns.severity(),
854 Severity::Warn
855 );
856 assert_eq!(
857 cfg.diagnostics.rules.forbidden_label_prefix.severity(),
858 Severity::Deny
859 );
860 assert_eq!(
861 cfg.diagnostics.rules.unknown_lex_canonical.severity(),
862 Severity::Deny
863 );
864 assert_eq!(cfg.diagnostics.rules.spellcheck.severity(), Severity::Warn);
865 assert_eq!(
866 cfg.diagnostics.rules.schema.unknown_label.severity(),
867 Severity::Deny
868 );
869 }
870
871 #[test]
872 fn diagnostics_rules_user_overrides_apply() {
873 let cfg = load_from(
874 r#"
875[diagnostics.rules]
876missing_footnote = "allow"
877table_inconsistent_columns = "deny"
878
879[diagnostics.rules.schema]
880unknown_label = "warn"
881"#,
882 );
883 assert_eq!(
884 cfg.diagnostics.rules.missing_footnote.severity(),
885 Severity::Allow
886 );
887 assert_eq!(
888 cfg.diagnostics.rules.table_inconsistent_columns.severity(),
889 Severity::Deny
890 );
891 assert_eq!(
892 cfg.diagnostics.rules.schema.unknown_label.severity(),
893 Severity::Warn
894 );
895 assert_eq!(
897 cfg.diagnostics.rules.forbidden_label_prefix.severity(),
898 Severity::Deny
899 );
900 }
901
902 #[test]
903 fn diagnostics_rules_accept_array_form() {
904 let cfg = load_from(
905 r#"
906[diagnostics.rules]
907missing_footnote = ["warn", { example_option = 42 }]
908"#,
909 );
910 let rule = &cfg.diagnostics.rules.missing_footnote;
911 assert_eq!(rule.severity(), Severity::Warn);
912 let opts = rule.options().expect("array form keeps options");
913 assert_eq!(opts.get("example_option"), Some(&toml::Value::Integer(42)));
914 }
915
916 #[test]
917 fn diagnostics_rules_extension_codes_land_in_side_channel() {
918 let loaded = load_wrapper_from(
924 r#"
925[diagnostics.rules]
926missing_footnote = "allow"
927"acme.task-due-date-missing" = "deny"
928"foolco.bar" = ["warn", { max = 80 }]
929"#,
930 );
931 assert_eq!(
932 loaded.config.diagnostics.rules.missing_footnote.severity(),
933 Severity::Allow
934 );
935 let acme = loaded
936 .extension_diagnostic_rules
937 .get("acme.task-due-date-missing")
938 .expect("extension code captured in side-channel");
939 assert_eq!(acme.severity(), Severity::Deny);
940 let foolco = loaded
941 .extension_diagnostic_rules
942 .get("foolco.bar")
943 .expect("array-form extension code captured");
944 assert_eq!(foolco.severity(), Severity::Warn);
945 assert_eq!(
946 foolco.options().and_then(|o| o.get("max")),
947 Some(&toml::Value::Integer(80))
948 );
949 }
950
951 #[test]
952 fn loaded_lookup_diagnostic_rule_consults_both_surfaces() {
953 let loaded = LoadedLexConfig {
957 config: LexConfig {
958 formatting: FormattingConfig {
959 rules: FormattingRulesConfig::default_for_tests(),
960 format_on_save: false,
961 },
962 inspect: InspectConfig::default_for_tests(),
963 convert: ConvertConfig::default_for_tests(),
964 diagnostics: DiagnosticsConfig {
965 rules: DiagnosticsRulesConfig {
966 missing_footnote: RuleConfig::Bare(Severity::Deny),
967 ..Default::default()
968 },
969 },
970 includes: IncludesConfig::default_for_tests(),
971 labels: BTreeMap::new(),
972 },
973 extension_diagnostic_rules: [
974 (
975 "missing-footnote".to_string(),
976 RuleConfig::Bare(Severity::Allow),
977 ),
978 ("acme.foo".to_string(), RuleConfig::Bare(Severity::Allow)),
979 ]
980 .into_iter()
981 .collect(),
982 };
983 assert_eq!(
985 loaded
986 .lookup_diagnostic_rule("missing-footnote")
987 .map(|r| r.severity()),
988 Some(Severity::Deny)
989 );
990 assert_eq!(
992 loaded
993 .lookup_diagnostic_rule("acme.foo")
994 .map(|r| r.severity()),
995 Some(Severity::Allow)
996 );
997 assert!(loaded.lookup_diagnostic_rule("acme.unknown").is_none());
998 }
999
1000 fn load_expecting_error(toml_body: &str) -> clapfig::ClapfigError {
1001 let dir = tempfile::tempdir().unwrap();
1002 let path = dir.path().join(CONFIG_FILE_NAME);
1003 std::fs::write(&path, toml_body).unwrap();
1004 clapfig::Clapfig::schema_builder::<LexConfig>()
1005 .app_name("lex")
1006 .file_name(CONFIG_FILE_NAME)
1007 .no_env()
1008 .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
1009 .accept_dotted_extension_keys_in(
1010 DIAGNOSTICS_RULES_PATH,
1011 clapfig::UnknownKeyDecision::Collect,
1012 )
1013 .load_with_unknowns()
1014 .expect_err("typo must surface as an unknown-key error")
1015 }
1016
1017 #[test]
1018 fn diagnostics_rules_typo_in_builtin_errors() {
1019 let err = load_expecting_error(
1024 r#"
1025[diagnostics.rules]
1026missing_footote = "warn"
1027"#,
1028 );
1029 let keys = err.unknown_keys().expect("UnknownKeys variant");
1030 assert!(
1031 keys.iter().any(|k| k.key.ends_with("missing_footote")),
1032 "the misspelled key is reported: {keys:?}"
1033 );
1034 }
1035
1036 #[test]
1037 fn diagnostics_rules_typo_inside_nested_section_errors() {
1038 let err = load_expecting_error(
1047 r#"
1048[diagnostics.rules.schema]
1049unkown_label = "warn"
1050"#,
1051 );
1052 let keys = err.unknown_keys().expect("UnknownKeys variant");
1053 assert!(
1054 keys.iter().any(|k| k.key.ends_with("unkown_label")),
1055 "the misspelled nested key is reported: {keys:?}"
1056 );
1057 }
1058
1059 impl FormattingRulesConfig {
1066 fn default_for_tests() -> Self {
1067 FormattingRulesConfig {
1068 session_blank_lines_before: 1,
1069 session_blank_lines_after: 1,
1070 normalize_seq_markers: true,
1071 unordered_seq_marker: '-',
1072 max_blank_lines: 2,
1073 indent_string: " ".to_string(),
1074 preserve_trailing_blanks: false,
1075 normalize_verbatim_markers: true,
1076 }
1077 }
1078 }
1079
1080 impl InspectConfig {
1081 fn default_for_tests() -> Self {
1082 InspectConfig {
1083 ast: InspectAstConfig {
1084 include_all_properties: false,
1085 show_line_numbers: true,
1086 },
1087 nodemap: NodemapConfig {
1088 color_blocks: false,
1089 color_characters: false,
1090 show_summary: false,
1091 },
1092 }
1093 }
1094 }
1095
1096 impl ConvertConfig {
1097 fn default_for_tests() -> Self {
1098 ConvertConfig {
1099 pdf: PdfConfig {
1100 size: PdfPageSize::LexEd,
1101 },
1102 html: HtmlConfig {
1103 theme: "default".to_string(),
1104 custom_css: None,
1105 },
1106 }
1107 }
1108 }
1109
1110 impl IncludesConfig {
1111 fn default_for_tests() -> Self {
1112 IncludesConfig {
1113 root: None,
1114 max_depth: 8,
1115 max_total_includes: 1000,
1116 max_file_size: 10_485_760,
1117 }
1118 }
1119 }
1120
1121 #[test]
1122 fn labels_config_bare_uri_parses() {
1123 let dir = tempfile::tempdir().unwrap();
1124 let path = dir.path().join(".lex.toml");
1125 std::fs::write(
1126 &path,
1127 r#"
1128[labels]
1129foolco = "gitlab:foolco/lex-labels#main"
1130"#,
1131 )
1132 .unwrap();
1133 let labels = load_labels_from_toml(&path).expect("loads");
1134 let spec = labels.namespaces.get("foolco").unwrap();
1135 assert_eq!(
1136 spec.canonical_uri().unwrap(),
1137 "gitlab:foolco/lex-labels#main"
1138 );
1139 }
1140
1141 #[test]
1142 fn labels_config_tap_shorthand_expands() {
1143 let dir = tempfile::tempdir().unwrap();
1144 let path = dir.path().join(".lex.toml");
1145 std::fs::write(
1146 &path,
1147 r#"
1148[labels]
1149acme = { tap = "acme" }
1150"#,
1151 )
1152 .unwrap();
1153 let labels = load_labels_from_toml(&path).unwrap();
1154 assert_eq!(
1155 labels
1156 .namespaces
1157 .get("acme")
1158 .unwrap()
1159 .canonical_uri()
1160 .unwrap(),
1161 "github:acme/lex-labels"
1162 );
1163 }
1164
1165 #[test]
1166 fn labels_config_expanded_table_with_rev_and_subdir() {
1167 let dir = tempfile::tempdir().unwrap();
1168 let path = dir.path().join(".lex.toml");
1169 std::fs::write(
1170 &path,
1171 r#"
1172[labels]
1173custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
1174"#,
1175 )
1176 .unwrap();
1177 let labels = load_labels_from_toml(&path).unwrap();
1178 let uri = labels
1179 .namespaces
1180 .get("custom")
1181 .unwrap()
1182 .canonical_uri()
1183 .unwrap();
1184 assert!(uri.starts_with("github:org/repo"));
1185 assert!(uri.contains("v1"));
1186 assert!(uri.contains("subdir=labels/"));
1187 }
1188
1189 #[test]
1190 fn labels_config_reserved_lex_namespace_rejected() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let path = dir.path().join(".lex.toml");
1193 std::fs::write(
1194 &path,
1195 r#"
1196[labels]
1197lex = "github:fake/lex-labels"
1198"#,
1199 )
1200 .unwrap();
1201 let err = load_labels_from_toml(&path).unwrap_err();
1202 assert!(matches!(err, LabelsConfigError::ReservedNamespace));
1203 }
1204
1205 #[test]
1206 fn labels_config_tap_and_uri_together_rejected() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let path = dir.path().join(".lex.toml");
1209 std::fs::write(
1210 &path,
1211 r#"
1212[labels]
1213acme = { tap = "acme", uri = "github:other/repo" }
1214"#,
1215 )
1216 .unwrap();
1217 let err = load_labels_from_toml(&path).unwrap_err();
1218 assert!(matches!(err, LabelsConfigError::TapAndUri));
1219 }
1220
1221 #[test]
1222 fn labels_config_empty_table_rejected() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let path = dir.path().join(".lex.toml");
1225 std::fs::write(
1226 &path,
1227 r#"
1228[labels]
1229acme = { rev = "v1" }
1230"#,
1231 )
1232 .unwrap();
1233 let err = load_labels_from_toml(&path).unwrap_err();
1234 assert!(matches!(err, LabelsConfigError::EmptyTable));
1235 }
1236
1237 #[test]
1238 fn labels_config_tap_with_via_git_encodes_query() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let path = dir.path().join(".lex.toml");
1241 std::fs::write(
1242 &path,
1243 r#"
1244[labels]
1245bigorg = { tap = "bigorg", via = "git" }
1246"#,
1247 )
1248 .unwrap();
1249 let labels = load_labels_from_toml(&path).unwrap();
1250 assert_eq!(
1251 labels
1252 .namespaces
1253 .get("bigorg")
1254 .unwrap()
1255 .canonical_uri()
1256 .unwrap(),
1257 "github:bigorg/lex-labels?via=git"
1258 );
1259 }
1260
1261 #[test]
1262 fn labels_config_default_via_https_is_not_encoded() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let path = dir.path().join(".lex.toml");
1265 std::fs::write(
1266 &path,
1267 r#"
1268[labels]
1269explicit_https = { tap = "acme", via = "https" }
1270implicit = { tap = "acme" }
1271"#,
1272 )
1273 .unwrap();
1274 let labels = load_labels_from_toml(&path).unwrap();
1275 let explicit = labels
1278 .namespaces
1279 .get("explicit_https")
1280 .unwrap()
1281 .canonical_uri()
1282 .unwrap();
1283 let implicit = labels
1284 .namespaces
1285 .get("implicit")
1286 .unwrap()
1287 .canonical_uri()
1288 .unwrap();
1289 assert_eq!(explicit, "github:acme/lex-labels");
1290 assert_eq!(implicit, "github:acme/lex-labels");
1291 }
1292
1293 #[test]
1294 fn labels_config_via_combines_with_subdir_and_rev() {
1295 let dir = tempfile::tempdir().unwrap();
1296 let path = dir.path().join(".lex.toml");
1297 std::fs::write(
1298 &path,
1299 r#"
1300[labels]
1301foolco = { uri = "gitlab:foolco/lex-labels", rev = "v2.1.0", subdir = "labels/", via = "git" }
1302"#,
1303 )
1304 .unwrap();
1305 let labels = load_labels_from_toml(&path).unwrap();
1306 assert_eq!(
1307 labels
1308 .namespaces
1309 .get("foolco")
1310 .unwrap()
1311 .canonical_uri()
1312 .unwrap(),
1313 "gitlab:foolco/lex-labels#v2.1.0?subdir=labels/&via=git"
1314 );
1315 }
1316
1317 #[test]
1318 fn labels_config_via_on_https_uri_rejected() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let path = dir.path().join(".lex.toml");
1321 std::fs::write(
1322 &path,
1323 r#"
1324[labels]
1325weird = { uri = "https://example.com/labels.tar.gz", via = "git" }
1326"#,
1327 )
1328 .unwrap();
1329 let err = load_labels_from_toml(&path).unwrap_err();
1330 assert!(matches!(
1331 err,
1332 LabelsConfigError::ViaOnNonTemplateScheme { .. }
1333 ));
1334 }
1335
1336 #[test]
1337 fn labels_config_via_on_path_uri_rejected() {
1338 let dir = tempfile::tempdir().unwrap();
1339 let path = dir.path().join(".lex.toml");
1340 std::fs::write(
1341 &path,
1342 r#"
1343[labels]
1344local = { uri = "path:./labels", via = "git" }
1345"#,
1346 )
1347 .unwrap();
1348 let err = load_labels_from_toml(&path).unwrap_err();
1349 assert!(matches!(
1350 err,
1351 LabelsConfigError::ViaOnNonTemplateScheme { .. }
1352 ));
1353 }
1354
1355 #[test]
1356 fn labels_config_missing_block_yields_empty_config() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let path = dir.path().join(".lex.toml");
1359 std::fs::write(&path, "# no labels block\n").unwrap();
1360 let labels = load_labels_from_toml(&path).unwrap();
1361 assert!(labels.namespaces.is_empty());
1362 }
1363
1364 #[test]
1365 fn formatting_rules_config_converts_to_formatting_rules() {
1366 let config = load_defaults();
1367 let rules: FormattingRules = config.formatting.rules.into();
1368 assert_eq!(rules.session_blank_lines_before, 1);
1369 assert_eq!(rules.session_blank_lines_after, 1);
1370 assert!(rules.normalize_seq_markers);
1371 assert_eq!(rules.unordered_seq_marker, '-');
1372 assert_eq!(rules.max_blank_lines, 2);
1373 assert_eq!(rules.indent_string, " ");
1374 assert!(!rules.preserve_trailing_blanks);
1375 assert!(rules.normalize_verbatim_markers);
1376 }
1377}