use clapfig::Schema;
use lex_babel::formats::lex::formatting_rules::FormattingRules;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
mod rule_config;
pub use rule_config::{RuleConfig, RuleOptions, Severity};
pub const CONFIG_FILE_NAME: &str = ".lex.toml";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LabelsConfig {
pub namespaces: BTreeMap<String, NamespaceSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NamespaceSpec {
Uri(String),
Table(NamespaceTable),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NamespaceTable {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub via: Option<Via>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Via {
Https,
Git,
}
impl Via {
pub fn as_query_value(self) -> &'static str {
match self {
Via::Https => "https",
Via::Git => "git",
}
}
}
impl NamespaceSpec {
pub fn canonical_uri(&self) -> Result<String, LabelsConfigError> {
match self {
NamespaceSpec::Uri(s) => Ok(s.clone()),
NamespaceSpec::Table(t) => {
t.validate()?;
let base = match (&t.tap, &t.uri) {
(Some(tap), None) => format!("github:{tap}/lex-labels"),
(None, Some(uri)) => uri.clone(),
(Some(_), Some(_)) => {
return Err(LabelsConfigError::TapAndUri);
}
(None, None) => {
return Err(LabelsConfigError::EmptyTable);
}
};
let mut out = base;
if let Some(rev) = &t.rev {
if out.contains('#') {
return Err(LabelsConfigError::RevWithExplicitFragment {
uri: out,
rev: rev.clone(),
});
}
out.push('#');
out.push_str(rev);
}
if let Some(subdir) = &t.subdir {
out.push_str(if out.contains('?') { "&" } else { "?" });
out.push_str("subdir=");
out.push_str(subdir);
}
if t.via == Some(Via::Git) {
out.push_str(if out.contains('?') { "&" } else { "?" });
out.push_str("via=");
out.push_str(Via::Git.as_query_value());
}
Ok(out)
}
}
}
}
impl NamespaceTable {
pub fn validate(&self) -> Result<(), LabelsConfigError> {
match (&self.tap, &self.uri) {
(Some(_), Some(_)) => return Err(LabelsConfigError::TapAndUri),
(None, None) => return Err(LabelsConfigError::EmptyTable),
_ => {}
}
if self.via.is_some() {
let on_template =
self.tap.is_some() || self.uri.as_deref().is_some_and(is_template_scheme_uri);
if !on_template {
return Err(LabelsConfigError::ViaOnNonTemplateScheme {
uri: self.uri.clone().unwrap_or_default(),
});
}
}
Ok(())
}
}
fn is_template_scheme_uri(uri: &str) -> bool {
let Some((scheme, _)) = uri.split_once(':') else {
return false;
};
matches!(scheme.to_ascii_lowercase().as_str(), "github" | "gitlab")
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LabelsConfigError {
Io {
path: std::path::PathBuf,
source: std::io::Error,
},
Parse {
path: std::path::PathBuf,
message: String,
},
ReservedNamespace,
TapAndUri,
EmptyTable,
RevWithExplicitFragment { uri: String, rev: String },
ViaOnNonTemplateScheme { uri: String },
}
impl std::fmt::Display for LabelsConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LabelsConfigError::Io { path, source } => {
write!(f, "{}: io error reading labels config: {source}", path.display())
}
LabelsConfigError::Parse { path, message } => {
write!(f, "{}: labels config parse error: {message}", path.display())
}
LabelsConfigError::ReservedNamespace => f.write_str(
"namespace `lex` is reserved for core-defined labels and cannot be declared in [labels]",
),
LabelsConfigError::TapAndUri => {
f.write_str("namespace spec sets both `tap` and `uri`; they are mutually exclusive")
}
LabelsConfigError::EmptyTable => f.write_str(
"namespace spec table needs one of `tap` or `uri` set",
),
LabelsConfigError::RevWithExplicitFragment { uri, rev } => write!(
f,
"namespace spec sets both `rev = {rev:?}` and an explicit `#fragment` in uri `{uri}`; pick one"
),
LabelsConfigError::ViaOnNonTemplateScheme { uri } => write!(
f,
"`via` is only valid on `tap` shorthand or `github:` / `gitlab:` URIs; got `{uri}`"
),
}
}
}
impl std::error::Error for LabelsConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
LabelsConfigError::Io { source, .. } => Some(source),
_ => None,
}
}
}
pub fn load_labels_from_toml(path: impl AsRef<Path>) -> Result<LabelsConfig, LabelsConfigError> {
let path = path.as_ref();
let body = std::fs::read_to_string(path).map_err(|source| LabelsConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let root: toml::Value =
body.parse()
.map_err(|err: toml::de::Error| LabelsConfigError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
})?;
let Some(labels_value) = root.get("labels") else {
return Ok(LabelsConfig::default());
};
let mut config: LabelsConfig =
labels_value
.clone()
.try_into()
.map_err(|err: toml::de::Error| LabelsConfigError::Parse {
path: path.to_path_buf(),
message: err.to_string(),
})?;
if config.namespaces.contains_key("lex") {
return Err(LabelsConfigError::ReservedNamespace);
}
for spec in config.namespaces.values_mut() {
if let NamespaceSpec::Table(t) = spec {
t.validate()?;
}
}
Ok(config)
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct LexConfig {
pub formatting: FormattingConfig,
pub inspect: InspectConfig,
pub convert: ConvertConfig,
pub diagnostics: DiagnosticsConfig,
pub includes: IncludesConfig,
#[clapfig(value, optional)]
#[serde(default)]
pub labels: BTreeMap<String, NamespaceSpec>,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct FormattingConfig {
pub rules: FormattingRulesConfig,
#[clapfig(default = false)]
pub format_on_save: bool,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct FormattingRulesConfig {
#[clapfig(default = 1)]
pub session_blank_lines_before: usize,
#[clapfig(default = 1)]
pub session_blank_lines_after: usize,
#[clapfig(default = true)]
pub normalize_seq_markers: bool,
#[clapfig(value, default = "-")]
pub unordered_seq_marker: char,
#[clapfig(default = 2)]
pub max_blank_lines: usize,
#[clapfig(default = " ")]
pub indent_string: String,
#[clapfig(default = false)]
pub preserve_trailing_blanks: bool,
#[clapfig(default = true)]
pub normalize_verbatim_markers: bool,
}
impl From<FormattingRulesConfig> for FormattingRules {
fn from(config: FormattingRulesConfig) -> Self {
FormattingRules {
session_blank_lines_before: config.session_blank_lines_before,
session_blank_lines_after: config.session_blank_lines_after,
normalize_seq_markers: config.normalize_seq_markers,
unordered_seq_marker: config.unordered_seq_marker,
max_blank_lines: config.max_blank_lines,
indent_string: config.indent_string,
preserve_trailing_blanks: config.preserve_trailing_blanks,
normalize_verbatim_markers: config.normalize_verbatim_markers,
}
}
}
impl From<&FormattingRulesConfig> for FormattingRules {
fn from(config: &FormattingRulesConfig) -> Self {
FormattingRules {
session_blank_lines_before: config.session_blank_lines_before,
session_blank_lines_after: config.session_blank_lines_after,
normalize_seq_markers: config.normalize_seq_markers,
unordered_seq_marker: config.unordered_seq_marker,
max_blank_lines: config.max_blank_lines,
indent_string: config.indent_string.clone(),
preserve_trailing_blanks: config.preserve_trailing_blanks,
normalize_verbatim_markers: config.normalize_verbatim_markers,
}
}
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct InspectConfig {
pub ast: InspectAstConfig,
pub nodemap: NodemapConfig,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct InspectAstConfig {
#[clapfig(default = false)]
pub include_all_properties: bool,
#[clapfig(default = true)]
pub show_line_numbers: bool,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct NodemapConfig {
#[clapfig(default = false)]
pub color_blocks: bool,
#[clapfig(default = false)]
pub color_characters: bool,
#[clapfig(default = false)]
pub show_summary: bool,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct ConvertConfig {
pub pdf: PdfConfig,
pub html: HtmlConfig,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct PdfConfig {
#[clapfig(default = "lexed")]
pub size: PdfPageSize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Schema, Serialize, Deserialize)]
pub enum PdfPageSize {
#[serde(rename = "lexed")]
LexEd,
#[serde(rename = "mobile")]
Mobile,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct HtmlConfig {
#[clapfig(default = "default")]
pub theme: String,
pub custom_css: Option<String>,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct DiagnosticsConfig {
pub rules: DiagnosticsRulesConfig,
}
#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
pub struct DiagnosticsRulesConfig {
#[clapfig(value, default = "deny")]
pub missing_footnote: RuleConfig,
#[clapfig(value, default = "warn")]
pub unused_footnote: RuleConfig,
#[clapfig(value, default = "warn")]
pub table_inconsistent_columns: RuleConfig,
#[clapfig(value, default = "deny")]
pub forbidden_label_prefix: RuleConfig,
#[clapfig(value, default = "deny")]
pub unknown_lex_canonical: RuleConfig,
#[clapfig(value, default = "warn")]
pub spellcheck: RuleConfig,
pub schema: SchemaRulesConfig,
}
impl DiagnosticsRulesConfig {
pub fn lookup_by_code(&self, code: &str) -> Option<&RuleConfig> {
match code {
"missing-footnote" => Some(&self.missing_footnote),
"unused-footnote" => Some(&self.unused_footnote),
"table-inconsistent-columns" => Some(&self.table_inconsistent_columns),
"forbidden-label-prefix" => Some(&self.forbidden_label_prefix),
"unknown-lex-canonical" => Some(&self.unknown_lex_canonical),
"schema.unknown-label" => Some(&self.schema.unknown_label),
"schema.missing-param" => Some(&self.schema.missing_param),
"schema.param-type-mismatch" => Some(&self.schema.param_type_mismatch),
"schema.bad-attachment" => Some(&self.schema.bad_attachment),
"schema.body-shape-mismatch" => Some(&self.schema.body_shape_mismatch),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
pub struct SchemaRulesConfig {
#[clapfig(value, default = "deny")]
pub unknown_label: RuleConfig,
#[clapfig(value, default = "deny")]
pub missing_param: RuleConfig,
#[clapfig(value, default = "deny")]
pub param_type_mismatch: RuleConfig,
#[clapfig(value, default = "deny")]
pub bad_attachment: RuleConfig,
#[clapfig(value, default = "deny")]
pub body_shape_mismatch: RuleConfig,
}
#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
pub struct IncludesConfig {
pub root: Option<String>,
#[clapfig(default = 8)]
pub max_depth: usize,
#[clapfig(default = 1000)]
pub max_total_includes: usize,
#[clapfig(default = 10485760)]
pub max_file_size: u64,
}
#[derive(Debug, Clone)]
pub struct LoadedLexConfig {
pub config: LexConfig,
pub extension_diagnostic_rules: BTreeMap<String, RuleConfig>,
}
impl LoadedLexConfig {
pub fn lookup_diagnostic_rule(&self, code: &str) -> Option<&RuleConfig> {
self.config
.diagnostics
.rules
.lookup_by_code(code)
.or_else(|| self.extension_diagnostic_rules.get(code))
}
}
pub const DIAGNOSTICS_RULES_PATH: &str = "diagnostics.rules";
pub fn collect_extension_diagnostic_rules(
unknowns: Vec<clapfig::CollectedUnknown>,
) -> BTreeMap<String, RuleConfig> {
let prefix = format!("{DIAGNOSTICS_RULES_PATH}.");
let mut out = BTreeMap::new();
for u in unknowns {
let Some(key) = u.path.strip_prefix(&prefix) else {
continue;
};
let Some(value) = u.value else { continue };
if let Ok(rule) = value.try_into() {
out.insert(key.to_string(), rule);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn load_defaults() -> LexConfig {
let (config, _unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
.app_name("lex")
.no_env()
.search_paths(vec![])
.accept_dotted_extension_keys_in(
DIAGNOSTICS_RULES_PATH,
clapfig::UnknownKeyDecision::Collect,
)
.load_with_unknowns()
.expect("defaults to load");
config
}
#[test]
fn loads_default_config() {
let config = load_defaults();
assert_eq!(config.formatting.rules.session_blank_lines_before, 1);
assert!(config.inspect.ast.show_line_numbers);
assert_eq!(config.convert.pdf.size, PdfPageSize::LexEd);
}
fn load_from(toml_body: &str) -> LexConfig {
load_wrapper_from(toml_body).config
}
fn load_wrapper_from(toml_body: &str) -> LoadedLexConfig {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, toml_body).unwrap();
let (config, unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
.app_name("lex")
.file_name(CONFIG_FILE_NAME)
.no_env()
.search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
.accept_dotted_extension_keys_in(
DIAGNOSTICS_RULES_PATH,
clapfig::UnknownKeyDecision::Collect,
)
.load_with_unknowns()
.expect("loads");
LoadedLexConfig {
config,
extension_diagnostic_rules: collect_extension_diagnostic_rules(unknowns),
}
}
#[test]
fn diagnostics_rules_defaults_in_place() {
let cfg = load_defaults();
assert_eq!(
cfg.diagnostics.rules.missing_footnote.severity(),
Severity::Deny
);
assert_eq!(
cfg.diagnostics.rules.unused_footnote.severity(),
Severity::Warn
);
assert_eq!(
cfg.diagnostics.rules.table_inconsistent_columns.severity(),
Severity::Warn
);
assert_eq!(
cfg.diagnostics.rules.forbidden_label_prefix.severity(),
Severity::Deny
);
assert_eq!(
cfg.diagnostics.rules.unknown_lex_canonical.severity(),
Severity::Deny
);
assert_eq!(cfg.diagnostics.rules.spellcheck.severity(), Severity::Warn);
assert_eq!(
cfg.diagnostics.rules.schema.unknown_label.severity(),
Severity::Deny
);
}
#[test]
fn diagnostics_rules_user_overrides_apply() {
let cfg = load_from(
r#"
[diagnostics.rules]
missing_footnote = "allow"
table_inconsistent_columns = "deny"
[diagnostics.rules.schema]
unknown_label = "warn"
"#,
);
assert_eq!(
cfg.diagnostics.rules.missing_footnote.severity(),
Severity::Allow
);
assert_eq!(
cfg.diagnostics.rules.table_inconsistent_columns.severity(),
Severity::Deny
);
assert_eq!(
cfg.diagnostics.rules.schema.unknown_label.severity(),
Severity::Warn
);
assert_eq!(
cfg.diagnostics.rules.forbidden_label_prefix.severity(),
Severity::Deny
);
}
#[test]
fn diagnostics_rules_accept_array_form() {
let cfg = load_from(
r#"
[diagnostics.rules]
missing_footnote = ["warn", { example_option = 42 }]
"#,
);
let rule = &cfg.diagnostics.rules.missing_footnote;
assert_eq!(rule.severity(), Severity::Warn);
let opts = rule.options().expect("array form keeps options");
assert_eq!(opts.get("example_option"), Some(&toml::Value::Integer(42)));
}
#[test]
fn diagnostics_rules_extension_codes_land_in_side_channel() {
let loaded = load_wrapper_from(
r#"
[diagnostics.rules]
missing_footnote = "allow"
"acme.task-due-date-missing" = "deny"
"foolco.bar" = ["warn", { max = 80 }]
"#,
);
assert_eq!(
loaded.config.diagnostics.rules.missing_footnote.severity(),
Severity::Allow
);
let acme = loaded
.extension_diagnostic_rules
.get("acme.task-due-date-missing")
.expect("extension code captured in side-channel");
assert_eq!(acme.severity(), Severity::Deny);
let foolco = loaded
.extension_diagnostic_rules
.get("foolco.bar")
.expect("array-form extension code captured");
assert_eq!(foolco.severity(), Severity::Warn);
assert_eq!(
foolco.options().and_then(|o| o.get("max")),
Some(&toml::Value::Integer(80))
);
}
#[test]
fn loaded_lookup_diagnostic_rule_consults_both_surfaces() {
let loaded = LoadedLexConfig {
config: LexConfig {
formatting: FormattingConfig {
rules: FormattingRulesConfig::default_for_tests(),
format_on_save: false,
},
inspect: InspectConfig::default_for_tests(),
convert: ConvertConfig::default_for_tests(),
diagnostics: DiagnosticsConfig {
rules: DiagnosticsRulesConfig {
missing_footnote: RuleConfig::Bare(Severity::Deny),
..Default::default()
},
},
includes: IncludesConfig::default_for_tests(),
labels: BTreeMap::new(),
},
extension_diagnostic_rules: [
(
"missing-footnote".to_string(),
RuleConfig::Bare(Severity::Allow),
),
("acme.foo".to_string(), RuleConfig::Bare(Severity::Allow)),
]
.into_iter()
.collect(),
};
assert_eq!(
loaded
.lookup_diagnostic_rule("missing-footnote")
.map(|r| r.severity()),
Some(Severity::Deny)
);
assert_eq!(
loaded
.lookup_diagnostic_rule("acme.foo")
.map(|r| r.severity()),
Some(Severity::Allow)
);
assert!(loaded.lookup_diagnostic_rule("acme.unknown").is_none());
}
fn load_expecting_error(toml_body: &str) -> clapfig::ClapfigError {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, toml_body).unwrap();
clapfig::Clapfig::schema_builder::<LexConfig>()
.app_name("lex")
.file_name(CONFIG_FILE_NAME)
.no_env()
.search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
.accept_dotted_extension_keys_in(
DIAGNOSTICS_RULES_PATH,
clapfig::UnknownKeyDecision::Collect,
)
.load_with_unknowns()
.expect_err("typo must surface as an unknown-key error")
}
#[test]
fn diagnostics_rules_typo_in_builtin_errors() {
let err = load_expecting_error(
r#"
[diagnostics.rules]
missing_footote = "warn"
"#,
);
let keys = err.unknown_keys().expect("UnknownKeys variant");
assert!(
keys.iter().any(|k| k.key.ends_with("missing_footote")),
"the misspelled key is reported: {keys:?}"
);
}
#[test]
fn diagnostics_rules_typo_inside_nested_section_errors() {
let err = load_expecting_error(
r#"
[diagnostics.rules.schema]
unkown_label = "warn"
"#,
);
let keys = err.unknown_keys().expect("UnknownKeys variant");
assert!(
keys.iter().any(|k| k.key.ends_with("unkown_label")),
"the misspelled nested key is reported: {keys:?}"
);
}
impl FormattingRulesConfig {
fn default_for_tests() -> Self {
FormattingRulesConfig {
session_blank_lines_before: 1,
session_blank_lines_after: 1,
normalize_seq_markers: true,
unordered_seq_marker: '-',
max_blank_lines: 2,
indent_string: " ".to_string(),
preserve_trailing_blanks: false,
normalize_verbatim_markers: true,
}
}
}
impl InspectConfig {
fn default_for_tests() -> Self {
InspectConfig {
ast: InspectAstConfig {
include_all_properties: false,
show_line_numbers: true,
},
nodemap: NodemapConfig {
color_blocks: false,
color_characters: false,
show_summary: false,
},
}
}
}
impl ConvertConfig {
fn default_for_tests() -> Self {
ConvertConfig {
pdf: PdfConfig {
size: PdfPageSize::LexEd,
},
html: HtmlConfig {
theme: "default".to_string(),
custom_css: None,
},
}
}
}
impl IncludesConfig {
fn default_for_tests() -> Self {
IncludesConfig {
root: None,
max_depth: 8,
max_total_includes: 1000,
max_file_size: 10_485_760,
}
}
}
#[test]
fn labels_config_bare_uri_parses() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
foolco = "gitlab:foolco/lex-labels#main"
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).expect("loads");
let spec = labels.namespaces.get("foolco").unwrap();
assert_eq!(
spec.canonical_uri().unwrap(),
"gitlab:foolco/lex-labels#main"
);
}
#[test]
fn labels_config_tap_shorthand_expands() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
acme = { tap = "acme" }
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).unwrap();
assert_eq!(
labels
.namespaces
.get("acme")
.unwrap()
.canonical_uri()
.unwrap(),
"github:acme/lex-labels"
);
}
#[test]
fn labels_config_expanded_table_with_rev_and_subdir() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).unwrap();
let uri = labels
.namespaces
.get("custom")
.unwrap()
.canonical_uri()
.unwrap();
assert!(uri.starts_with("github:org/repo"));
assert!(uri.contains("v1"));
assert!(uri.contains("subdir=labels/"));
}
#[test]
fn labels_config_reserved_lex_namespace_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
lex = "github:fake/lex-labels"
"#,
)
.unwrap();
let err = load_labels_from_toml(&path).unwrap_err();
assert!(matches!(err, LabelsConfigError::ReservedNamespace));
}
#[test]
fn labels_config_tap_and_uri_together_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
acme = { tap = "acme", uri = "github:other/repo" }
"#,
)
.unwrap();
let err = load_labels_from_toml(&path).unwrap_err();
assert!(matches!(err, LabelsConfigError::TapAndUri));
}
#[test]
fn labels_config_empty_table_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
acme = { rev = "v1" }
"#,
)
.unwrap();
let err = load_labels_from_toml(&path).unwrap_err();
assert!(matches!(err, LabelsConfigError::EmptyTable));
}
#[test]
fn labels_config_tap_with_via_git_encodes_query() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
bigorg = { tap = "bigorg", via = "git" }
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).unwrap();
assert_eq!(
labels
.namespaces
.get("bigorg")
.unwrap()
.canonical_uri()
.unwrap(),
"github:bigorg/lex-labels?via=git"
);
}
#[test]
fn labels_config_default_via_https_is_not_encoded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
explicit_https = { tap = "acme", via = "https" }
implicit = { tap = "acme" }
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).unwrap();
let explicit = labels
.namespaces
.get("explicit_https")
.unwrap()
.canonical_uri()
.unwrap();
let implicit = labels
.namespaces
.get("implicit")
.unwrap()
.canonical_uri()
.unwrap();
assert_eq!(explicit, "github:acme/lex-labels");
assert_eq!(implicit, "github:acme/lex-labels");
}
#[test]
fn labels_config_via_combines_with_subdir_and_rev() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
foolco = { uri = "gitlab:foolco/lex-labels", rev = "v2.1.0", subdir = "labels/", via = "git" }
"#,
)
.unwrap();
let labels = load_labels_from_toml(&path).unwrap();
assert_eq!(
labels
.namespaces
.get("foolco")
.unwrap()
.canonical_uri()
.unwrap(),
"gitlab:foolco/lex-labels#v2.1.0?subdir=labels/&via=git"
);
}
#[test]
fn labels_config_via_on_https_uri_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
weird = { uri = "https://example.com/labels.tar.gz", via = "git" }
"#,
)
.unwrap();
let err = load_labels_from_toml(&path).unwrap_err();
assert!(matches!(
err,
LabelsConfigError::ViaOnNonTemplateScheme { .. }
));
}
#[test]
fn labels_config_via_on_path_uri_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(
&path,
r#"
[labels]
local = { uri = "path:./labels", via = "git" }
"#,
)
.unwrap();
let err = load_labels_from_toml(&path).unwrap_err();
assert!(matches!(
err,
LabelsConfigError::ViaOnNonTemplateScheme { .. }
));
}
#[test]
fn labels_config_missing_block_yields_empty_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".lex.toml");
std::fs::write(&path, "# no labels block\n").unwrap();
let labels = load_labels_from_toml(&path).unwrap();
assert!(labels.namespaces.is_empty());
}
#[test]
fn formatting_rules_config_converts_to_formatting_rules() {
let config = load_defaults();
let rules: FormattingRules = config.formatting.rules.into();
assert_eq!(rules.session_blank_lines_before, 1);
assert_eq!(rules.session_blank_lines_after, 1);
assert!(rules.normalize_seq_markers);
assert_eq!(rules.unordered_seq_marker, '-');
assert_eq!(rules.max_blank_lines, 2);
assert_eq!(rules.indent_string, " ");
assert!(!rules.preserve_trailing_blanks);
assert!(rules.normalize_verbatim_markers);
}
}