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 pub schema: SchemaRulesConfig,
575}
576
577impl DiagnosticsRulesConfig {
578 pub fn lookup_by_code(&self, code: &str) -> Option<&RuleConfig> {
588 match code {
589 "missing-footnote" => Some(&self.missing_footnote),
590 "unused-footnote" => Some(&self.unused_footnote),
591 "table-inconsistent-columns" => Some(&self.table_inconsistent_columns),
592 "forbidden-label-prefix" => Some(&self.forbidden_label_prefix),
593 "unknown-lex-canonical" => Some(&self.unknown_lex_canonical),
594 "schema.unknown-label" => Some(&self.schema.unknown_label),
602 "schema.missing-param" => Some(&self.schema.missing_param),
603 "schema.param-type-mismatch" => Some(&self.schema.param_type_mismatch),
604 "schema.bad-attachment" => Some(&self.schema.bad_attachment),
605 "schema.body-shape-mismatch" => Some(&self.schema.body_shape_mismatch),
606 _ => None,
607 }
608 }
609}
610
611#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
616pub struct SchemaRulesConfig {
617 #[clapfig(value, default = "deny")]
621 pub unknown_label: RuleConfig,
622 #[clapfig(value, default = "deny")]
625 pub missing_param: RuleConfig,
626 #[clapfig(value, default = "deny")]
629 pub param_type_mismatch: RuleConfig,
630 #[clapfig(value, default = "deny")]
634 pub bad_attachment: RuleConfig,
635 #[clapfig(value, default = "deny")]
638 pub body_shape_mismatch: RuleConfig,
639}
640
641#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
644pub struct IncludesConfig {
645 pub root: Option<String>,
657 #[clapfig(default = 8)]
660 pub max_depth: usize,
661 #[clapfig(default = 1000)]
666 pub max_total_includes: usize,
667 #[clapfig(default = 10485760)]
672 pub max_file_size: u64,
673}
674
675#[derive(Debug, Clone)]
686pub struct LoadedLexConfig {
687 pub config: LexConfig,
688 pub extension_diagnostic_rules: BTreeMap<String, RuleConfig>,
693}
694
695impl LoadedLexConfig {
696 pub fn lookup_diagnostic_rule(&self, code: &str) -> Option<&RuleConfig> {
702 self.config
703 .diagnostics
704 .rules
705 .lookup_by_code(code)
706 .or_else(|| self.extension_diagnostic_rules.get(code))
707 }
708}
709
710pub const DIAGNOSTICS_RULES_PATH: &str = "diagnostics.rules";
719
720pub fn collect_extension_diagnostic_rules(
732 unknowns: Vec<clapfig::CollectedUnknown>,
733) -> BTreeMap<String, RuleConfig> {
734 let prefix = format!("{DIAGNOSTICS_RULES_PATH}.");
735 let mut out = BTreeMap::new();
736 for u in unknowns {
737 let Some(key) = u.path.strip_prefix(&prefix) else {
738 continue;
739 };
740 let Some(value) = u.value else { continue };
741 if let Ok(rule) = value.try_into() {
742 out.insert(key.to_string(), rule);
743 }
744 }
745 out
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 fn load_defaults() -> LexConfig {
753 let (config, _unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
754 .app_name("lex")
755 .no_env()
756 .search_paths(vec![])
757 .accept_dotted_extension_keys_in(
758 DIAGNOSTICS_RULES_PATH,
759 clapfig::UnknownKeyDecision::Collect,
760 )
761 .load_with_unknowns()
762 .expect("defaults to load");
763 config
764 }
765
766 #[test]
767 fn loads_default_config() {
768 let config = load_defaults();
769 assert_eq!(config.formatting.rules.session_blank_lines_before, 1);
770 assert!(config.inspect.ast.show_line_numbers);
771 assert_eq!(config.convert.pdf.size, PdfPageSize::LexEd);
772 }
773
774 fn load_from(toml_body: &str) -> LexConfig {
775 load_wrapper_from(toml_body).config
776 }
777
778 fn load_wrapper_from(toml_body: &str) -> LoadedLexConfig {
779 let dir = tempfile::tempdir().unwrap();
780 let path = dir.path().join(CONFIG_FILE_NAME);
781 std::fs::write(&path, toml_body).unwrap();
782 let (config, unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
783 .app_name("lex")
784 .file_name(CONFIG_FILE_NAME)
785 .no_env()
786 .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
787 .accept_dotted_extension_keys_in(
788 DIAGNOSTICS_RULES_PATH,
789 clapfig::UnknownKeyDecision::Collect,
790 )
791 .load_with_unknowns()
792 .expect("loads");
793 LoadedLexConfig {
794 config,
795 extension_diagnostic_rules: collect_extension_diagnostic_rules(unknowns),
796 }
797 }
798
799 #[test]
800 fn diagnostics_rules_defaults_in_place() {
801 let cfg = load_defaults();
802 assert_eq!(
803 cfg.diagnostics.rules.missing_footnote.severity(),
804 Severity::Deny
805 );
806 assert_eq!(
807 cfg.diagnostics.rules.unused_footnote.severity(),
808 Severity::Warn
809 );
810 assert_eq!(
811 cfg.diagnostics.rules.table_inconsistent_columns.severity(),
812 Severity::Warn
813 );
814 assert_eq!(
815 cfg.diagnostics.rules.forbidden_label_prefix.severity(),
816 Severity::Deny
817 );
818 assert_eq!(
819 cfg.diagnostics.rules.unknown_lex_canonical.severity(),
820 Severity::Deny
821 );
822 assert_eq!(cfg.diagnostics.rules.spellcheck.severity(), Severity::Warn);
823 assert_eq!(
824 cfg.diagnostics.rules.schema.unknown_label.severity(),
825 Severity::Deny
826 );
827 }
828
829 #[test]
830 fn diagnostics_rules_user_overrides_apply() {
831 let cfg = load_from(
832 r#"
833[diagnostics.rules]
834missing_footnote = "allow"
835table_inconsistent_columns = "deny"
836
837[diagnostics.rules.schema]
838unknown_label = "warn"
839"#,
840 );
841 assert_eq!(
842 cfg.diagnostics.rules.missing_footnote.severity(),
843 Severity::Allow
844 );
845 assert_eq!(
846 cfg.diagnostics.rules.table_inconsistent_columns.severity(),
847 Severity::Deny
848 );
849 assert_eq!(
850 cfg.diagnostics.rules.schema.unknown_label.severity(),
851 Severity::Warn
852 );
853 assert_eq!(
855 cfg.diagnostics.rules.forbidden_label_prefix.severity(),
856 Severity::Deny
857 );
858 }
859
860 #[test]
861 fn diagnostics_rules_accept_array_form() {
862 let cfg = load_from(
863 r#"
864[diagnostics.rules]
865missing_footnote = ["warn", { example_option = 42 }]
866"#,
867 );
868 let rule = &cfg.diagnostics.rules.missing_footnote;
869 assert_eq!(rule.severity(), Severity::Warn);
870 let opts = rule.options().expect("array form keeps options");
871 assert_eq!(opts.get("example_option"), Some(&toml::Value::Integer(42)));
872 }
873
874 #[test]
875 fn diagnostics_rules_extension_codes_land_in_side_channel() {
876 let loaded = load_wrapper_from(
882 r#"
883[diagnostics.rules]
884missing_footnote = "allow"
885"acme.task-due-date-missing" = "deny"
886"foolco.bar" = ["warn", { max = 80 }]
887"#,
888 );
889 assert_eq!(
890 loaded.config.diagnostics.rules.missing_footnote.severity(),
891 Severity::Allow
892 );
893 let acme = loaded
894 .extension_diagnostic_rules
895 .get("acme.task-due-date-missing")
896 .expect("extension code captured in side-channel");
897 assert_eq!(acme.severity(), Severity::Deny);
898 let foolco = loaded
899 .extension_diagnostic_rules
900 .get("foolco.bar")
901 .expect("array-form extension code captured");
902 assert_eq!(foolco.severity(), Severity::Warn);
903 assert_eq!(
904 foolco.options().and_then(|o| o.get("max")),
905 Some(&toml::Value::Integer(80))
906 );
907 }
908
909 #[test]
910 fn loaded_lookup_diagnostic_rule_consults_both_surfaces() {
911 let loaded = LoadedLexConfig {
915 config: LexConfig {
916 formatting: FormattingConfig {
917 rules: FormattingRulesConfig::default_for_tests(),
918 format_on_save: false,
919 },
920 inspect: InspectConfig::default_for_tests(),
921 convert: ConvertConfig::default_for_tests(),
922 diagnostics: DiagnosticsConfig {
923 rules: DiagnosticsRulesConfig {
924 missing_footnote: RuleConfig::Bare(Severity::Deny),
925 ..Default::default()
926 },
927 },
928 includes: IncludesConfig::default_for_tests(),
929 labels: BTreeMap::new(),
930 },
931 extension_diagnostic_rules: [
932 (
933 "missing-footnote".to_string(),
934 RuleConfig::Bare(Severity::Allow),
935 ),
936 ("acme.foo".to_string(), RuleConfig::Bare(Severity::Allow)),
937 ]
938 .into_iter()
939 .collect(),
940 };
941 assert_eq!(
943 loaded
944 .lookup_diagnostic_rule("missing-footnote")
945 .map(|r| r.severity()),
946 Some(Severity::Deny)
947 );
948 assert_eq!(
950 loaded
951 .lookup_diagnostic_rule("acme.foo")
952 .map(|r| r.severity()),
953 Some(Severity::Allow)
954 );
955 assert!(loaded.lookup_diagnostic_rule("acme.unknown").is_none());
956 }
957
958 fn load_expecting_error(toml_body: &str) -> clapfig::ClapfigError {
959 let dir = tempfile::tempdir().unwrap();
960 let path = dir.path().join(CONFIG_FILE_NAME);
961 std::fs::write(&path, toml_body).unwrap();
962 clapfig::Clapfig::schema_builder::<LexConfig>()
963 .app_name("lex")
964 .file_name(CONFIG_FILE_NAME)
965 .no_env()
966 .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
967 .accept_dotted_extension_keys_in(
968 DIAGNOSTICS_RULES_PATH,
969 clapfig::UnknownKeyDecision::Collect,
970 )
971 .load_with_unknowns()
972 .expect_err("typo must surface as an unknown-key error")
973 }
974
975 #[test]
976 fn diagnostics_rules_typo_in_builtin_errors() {
977 let err = load_expecting_error(
982 r#"
983[diagnostics.rules]
984missing_footote = "warn"
985"#,
986 );
987 let keys = err.unknown_keys().expect("UnknownKeys variant");
988 assert!(
989 keys.iter().any(|k| k.key.ends_with("missing_footote")),
990 "the misspelled key is reported: {keys:?}"
991 );
992 }
993
994 #[test]
995 fn diagnostics_rules_typo_inside_nested_section_errors() {
996 let err = load_expecting_error(
1005 r#"
1006[diagnostics.rules.schema]
1007unkown_label = "warn"
1008"#,
1009 );
1010 let keys = err.unknown_keys().expect("UnknownKeys variant");
1011 assert!(
1012 keys.iter().any(|k| k.key.ends_with("unkown_label")),
1013 "the misspelled nested key is reported: {keys:?}"
1014 );
1015 }
1016
1017 impl FormattingRulesConfig {
1024 fn default_for_tests() -> Self {
1025 FormattingRulesConfig {
1026 session_blank_lines_before: 1,
1027 session_blank_lines_after: 1,
1028 normalize_seq_markers: true,
1029 unordered_seq_marker: '-',
1030 max_blank_lines: 2,
1031 indent_string: " ".to_string(),
1032 preserve_trailing_blanks: false,
1033 normalize_verbatim_markers: true,
1034 }
1035 }
1036 }
1037
1038 impl InspectConfig {
1039 fn default_for_tests() -> Self {
1040 InspectConfig {
1041 ast: InspectAstConfig {
1042 include_all_properties: false,
1043 show_line_numbers: true,
1044 },
1045 nodemap: NodemapConfig {
1046 color_blocks: false,
1047 color_characters: false,
1048 show_summary: false,
1049 },
1050 }
1051 }
1052 }
1053
1054 impl ConvertConfig {
1055 fn default_for_tests() -> Self {
1056 ConvertConfig {
1057 pdf: PdfConfig {
1058 size: PdfPageSize::LexEd,
1059 },
1060 html: HtmlConfig {
1061 theme: "default".to_string(),
1062 custom_css: None,
1063 },
1064 }
1065 }
1066 }
1067
1068 impl IncludesConfig {
1069 fn default_for_tests() -> Self {
1070 IncludesConfig {
1071 root: None,
1072 max_depth: 8,
1073 max_total_includes: 1000,
1074 max_file_size: 10_485_760,
1075 }
1076 }
1077 }
1078
1079 #[test]
1080 fn labels_config_bare_uri_parses() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let path = dir.path().join(".lex.toml");
1083 std::fs::write(
1084 &path,
1085 r#"
1086[labels]
1087foolco = "gitlab:foolco/lex-labels#main"
1088"#,
1089 )
1090 .unwrap();
1091 let labels = load_labels_from_toml(&path).expect("loads");
1092 let spec = labels.namespaces.get("foolco").unwrap();
1093 assert_eq!(
1094 spec.canonical_uri().unwrap(),
1095 "gitlab:foolco/lex-labels#main"
1096 );
1097 }
1098
1099 #[test]
1100 fn labels_config_tap_shorthand_expands() {
1101 let dir = tempfile::tempdir().unwrap();
1102 let path = dir.path().join(".lex.toml");
1103 std::fs::write(
1104 &path,
1105 r#"
1106[labels]
1107acme = { tap = "acme" }
1108"#,
1109 )
1110 .unwrap();
1111 let labels = load_labels_from_toml(&path).unwrap();
1112 assert_eq!(
1113 labels
1114 .namespaces
1115 .get("acme")
1116 .unwrap()
1117 .canonical_uri()
1118 .unwrap(),
1119 "github:acme/lex-labels"
1120 );
1121 }
1122
1123 #[test]
1124 fn labels_config_expanded_table_with_rev_and_subdir() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let path = dir.path().join(".lex.toml");
1127 std::fs::write(
1128 &path,
1129 r#"
1130[labels]
1131custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
1132"#,
1133 )
1134 .unwrap();
1135 let labels = load_labels_from_toml(&path).unwrap();
1136 let uri = labels
1137 .namespaces
1138 .get("custom")
1139 .unwrap()
1140 .canonical_uri()
1141 .unwrap();
1142 assert!(uri.starts_with("github:org/repo"));
1143 assert!(uri.contains("v1"));
1144 assert!(uri.contains("subdir=labels/"));
1145 }
1146
1147 #[test]
1148 fn labels_config_reserved_lex_namespace_rejected() {
1149 let dir = tempfile::tempdir().unwrap();
1150 let path = dir.path().join(".lex.toml");
1151 std::fs::write(
1152 &path,
1153 r#"
1154[labels]
1155lex = "github:fake/lex-labels"
1156"#,
1157 )
1158 .unwrap();
1159 let err = load_labels_from_toml(&path).unwrap_err();
1160 assert!(matches!(err, LabelsConfigError::ReservedNamespace));
1161 }
1162
1163 #[test]
1164 fn labels_config_tap_and_uri_together_rejected() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let path = dir.path().join(".lex.toml");
1167 std::fs::write(
1168 &path,
1169 r#"
1170[labels]
1171acme = { tap = "acme", uri = "github:other/repo" }
1172"#,
1173 )
1174 .unwrap();
1175 let err = load_labels_from_toml(&path).unwrap_err();
1176 assert!(matches!(err, LabelsConfigError::TapAndUri));
1177 }
1178
1179 #[test]
1180 fn labels_config_empty_table_rejected() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = dir.path().join(".lex.toml");
1183 std::fs::write(
1184 &path,
1185 r#"
1186[labels]
1187acme = { rev = "v1" }
1188"#,
1189 )
1190 .unwrap();
1191 let err = load_labels_from_toml(&path).unwrap_err();
1192 assert!(matches!(err, LabelsConfigError::EmptyTable));
1193 }
1194
1195 #[test]
1196 fn labels_config_tap_with_via_git_encodes_query() {
1197 let dir = tempfile::tempdir().unwrap();
1198 let path = dir.path().join(".lex.toml");
1199 std::fs::write(
1200 &path,
1201 r#"
1202[labels]
1203bigorg = { tap = "bigorg", via = "git" }
1204"#,
1205 )
1206 .unwrap();
1207 let labels = load_labels_from_toml(&path).unwrap();
1208 assert_eq!(
1209 labels
1210 .namespaces
1211 .get("bigorg")
1212 .unwrap()
1213 .canonical_uri()
1214 .unwrap(),
1215 "github:bigorg/lex-labels?via=git"
1216 );
1217 }
1218
1219 #[test]
1220 fn labels_config_default_via_https_is_not_encoded() {
1221 let dir = tempfile::tempdir().unwrap();
1222 let path = dir.path().join(".lex.toml");
1223 std::fs::write(
1224 &path,
1225 r#"
1226[labels]
1227explicit_https = { tap = "acme", via = "https" }
1228implicit = { tap = "acme" }
1229"#,
1230 )
1231 .unwrap();
1232 let labels = load_labels_from_toml(&path).unwrap();
1233 let explicit = labels
1236 .namespaces
1237 .get("explicit_https")
1238 .unwrap()
1239 .canonical_uri()
1240 .unwrap();
1241 let implicit = labels
1242 .namespaces
1243 .get("implicit")
1244 .unwrap()
1245 .canonical_uri()
1246 .unwrap();
1247 assert_eq!(explicit, "github:acme/lex-labels");
1248 assert_eq!(implicit, "github:acme/lex-labels");
1249 }
1250
1251 #[test]
1252 fn labels_config_via_combines_with_subdir_and_rev() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let path = dir.path().join(".lex.toml");
1255 std::fs::write(
1256 &path,
1257 r#"
1258[labels]
1259foolco = { uri = "gitlab:foolco/lex-labels", rev = "v2.1.0", subdir = "labels/", via = "git" }
1260"#,
1261 )
1262 .unwrap();
1263 let labels = load_labels_from_toml(&path).unwrap();
1264 assert_eq!(
1265 labels
1266 .namespaces
1267 .get("foolco")
1268 .unwrap()
1269 .canonical_uri()
1270 .unwrap(),
1271 "gitlab:foolco/lex-labels#v2.1.0?subdir=labels/&via=git"
1272 );
1273 }
1274
1275 #[test]
1276 fn labels_config_via_on_https_uri_rejected() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let path = dir.path().join(".lex.toml");
1279 std::fs::write(
1280 &path,
1281 r#"
1282[labels]
1283weird = { uri = "https://example.com/labels.tar.gz", via = "git" }
1284"#,
1285 )
1286 .unwrap();
1287 let err = load_labels_from_toml(&path).unwrap_err();
1288 assert!(matches!(
1289 err,
1290 LabelsConfigError::ViaOnNonTemplateScheme { .. }
1291 ));
1292 }
1293
1294 #[test]
1295 fn labels_config_via_on_path_uri_rejected() {
1296 let dir = tempfile::tempdir().unwrap();
1297 let path = dir.path().join(".lex.toml");
1298 std::fs::write(
1299 &path,
1300 r#"
1301[labels]
1302local = { uri = "path:./labels", via = "git" }
1303"#,
1304 )
1305 .unwrap();
1306 let err = load_labels_from_toml(&path).unwrap_err();
1307 assert!(matches!(
1308 err,
1309 LabelsConfigError::ViaOnNonTemplateScheme { .. }
1310 ));
1311 }
1312
1313 #[test]
1314 fn labels_config_missing_block_yields_empty_config() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let path = dir.path().join(".lex.toml");
1317 std::fs::write(&path, "# no labels block\n").unwrap();
1318 let labels = load_labels_from_toml(&path).unwrap();
1319 assert!(labels.namespaces.is_empty());
1320 }
1321
1322 #[test]
1323 fn formatting_rules_config_converts_to_formatting_rules() {
1324 let config = load_defaults();
1325 let rules: FormattingRules = config.formatting.rules.into();
1326 assert_eq!(rules.session_blank_lines_before, 1);
1327 assert_eq!(rules.session_blank_lines_after, 1);
1328 assert!(rules.normalize_seq_markers);
1329 assert_eq!(rules.unordered_seq_marker, '-');
1330 assert_eq!(rules.max_blank_lines, 2);
1331 assert_eq!(rules.indent_string, " ");
1332 assert!(!rules.preserve_trailing_blanks);
1333 assert!(rules.normalize_verbatim_markers);
1334 }
1335}