use confique::Config;
use lex_babel::formats::lex::formatting_rules::FormattingRules;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
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>,
}
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);
}
Ok(out)
}
}
}
}
impl NamespaceTable {
pub fn validate(&self) -> Result<(), LabelsConfigError> {
match (&self.tap, &self.uri) {
(Some(_), Some(_)) => Err(LabelsConfigError::TapAndUri),
(None, None) => Err(LabelsConfigError::EmptyTable),
_ => Ok(()),
}
}
}
#[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 },
}
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"
),
}
}
}
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, Config, Serialize, Deserialize)]
pub struct LexConfig {
#[config(nested)]
pub formatting: FormattingConfig,
#[config(nested)]
pub inspect: InspectConfig,
#[config(nested)]
pub convert: ConvertConfig,
#[config(nested)]
pub diagnostics: DiagnosticsConfig,
#[config(nested)]
pub includes: IncludesConfig,
#[config(default = {})]
pub labels: BTreeMap<String, NamespaceSpec>,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct FormattingConfig {
#[config(nested)]
pub rules: FormattingRulesConfig,
#[config(default = false)]
pub format_on_save: bool,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct FormattingRulesConfig {
#[config(default = 1)]
pub session_blank_lines_before: usize,
#[config(default = 1)]
pub session_blank_lines_after: usize,
#[config(default = true)]
pub normalize_seq_markers: bool,
#[config(default = "-")]
pub unordered_seq_marker: char,
#[config(default = 2)]
pub max_blank_lines: usize,
#[config(default = " ")]
pub indent_string: String,
#[config(default = false)]
pub preserve_trailing_blanks: bool,
#[config(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, Config, Serialize, Deserialize)]
pub struct InspectConfig {
#[config(nested)]
pub ast: InspectAstConfig,
#[config(nested)]
pub nodemap: NodemapConfig,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct InspectAstConfig {
#[config(default = false)]
pub include_all_properties: bool,
#[config(default = true)]
pub show_line_numbers: bool,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct NodemapConfig {
#[config(default = false)]
pub color_blocks: bool,
#[config(default = false)]
pub color_characters: bool,
#[config(default = false)]
pub show_summary: bool,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct ConvertConfig {
#[config(nested)]
pub pdf: PdfConfig,
#[config(nested)]
pub html: HtmlConfig,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct PdfConfig {
#[config(default = "lexed")]
pub size: PdfPageSize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PdfPageSize {
#[serde(rename = "lexed")]
LexEd,
#[serde(rename = "mobile")]
Mobile,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct HtmlConfig {
#[config(default = "default")]
pub theme: String,
pub custom_css: Option<String>,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct DiagnosticsConfig {
#[config(default = true)]
pub spellcheck: bool,
}
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct IncludesConfig {
pub root: Option<String>,
#[config(default = 8)]
pub max_depth: usize,
#[config(default = 1000)]
pub max_total_includes: usize,
#[config(default = 10485760)]
pub max_file_size: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn load_defaults() -> LexConfig {
clapfig::Clapfig::builder::<LexConfig>()
.app_name("lex")
.no_env()
.search_paths(vec![])
.load()
.expect("defaults to load")
}
#[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);
}
#[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_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);
}
}