Skip to main content

lex_config/
lib.rs

1//! Shared configuration for the Lex toolchain.
2//!
3//! Defines [`LexConfig`] — the config struct consumed by all Lex applications.
4//! Defaults are compiled into the struct via `#[clapfig(default)]`. Loading and
5//! layering is handled by [clapfig](https://docs.rs/clapfig) in the CLI.
6
7use 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
16/// Canonical config file name used by the CLI and LSP.
17pub const CONFIG_FILE_NAME: &str = ".lex.toml";
18
19// ─────────────────────────── Labels (extension namespaces) ───────────────────────────
20
21/// `[labels]` block in `.lex.toml` — declarations of extension
22/// namespaces the workspace owner wants the host to load.
23///
24/// Loaded outside the main `LexConfig` confique chain because the
25/// shape is a free-form map keyed by namespace name, not a
26/// fixed-field struct. See [`load_labels_from_toml`].
27///
28/// ```toml
29/// [labels]
30/// acme = { tap = "acme" }                                       # tap shorthand
31/// foolco = "gitlab:foolco/lex-labels#main"                      # bare URI
32/// custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
33/// bigorg = { tap = "bigorg", via = "git" }                       # private repo, git clone
34/// ```
35///
36/// The reserved namespace name `lex` is rejected at load time —
37/// `lex.*` is owned by the core and ships compiled-in.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct LabelsConfig {
41    /// Namespace name → spec. Order is sorted (BTreeMap) for
42    /// deterministic loading and stable diagnostics.
43    pub namespaces: BTreeMap<String, NamespaceSpec>,
44}
45
46/// One namespace declaration. Three on-disk shapes parse into the
47/// same logical record:
48///
49/// - `acme = "github:acme/lex-labels"` — bare URI string.
50/// - `acme = { tap = "acme" }` — tap shorthand, expands to
51///   `github:acme/lex-labels`.
52/// - `acme = { uri = "...", rev = "...", subdir = "..." }` — full
53///   table form.
54///
55/// `tap` and `uri` are mutually exclusive on the table form;
56/// having both is a load-time error (see [`NamespaceSpec::validate`]).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(untagged)]
59pub enum NamespaceSpec {
60    /// Bare URI string form.
61    Uri(String),
62    /// Table form. One of `tap` / `uri` must be set; both is an
63    /// error.
64    Table(NamespaceTable),
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct NamespaceTable {
70    /// Tap-prefix shorthand. `tap = "acme"` expands to
71    /// `github:acme/lex-labels`.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub tap: Option<String>,
74    /// Explicit URI (`github:`, `gitlab:`, `https:`, `path:`,
75    /// `git+ssh:`).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub uri: Option<String>,
78    /// Branch / tag / SHA pin. Mutable refs (branches) honour the
79    /// resolver's 24-hour cache TTL; tags and SHAs are cached
80    /// indefinitely.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub rev: Option<String>,
83    /// Subdirectory inside the resolved repo containing the schema
84    /// files. Defaults to repo root.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub subdir: Option<String>,
87    /// Transport selector for `github:` / `gitlab:` URL templates.
88    /// `"https"` (default) uses the forge's tarball/archive API over
89    /// public HTTPS; `"git"` uses a `git clone`, inheriting the user's
90    /// git credential setup for private repos (SSH agent, OS keychain,
91    /// gh CLI, etc.). Only valid when the spec resolves to a template
92    /// scheme (`tap`, or `uri` starting with `github:` / `gitlab:`);
93    /// declaring it on a non-template URI is a load-time error.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub via: Option<Via>,
96}
97
98/// Transport selector for URL-template namespace declarations
99/// (`github:`, `gitlab:`). The default, when unset, is `Https` — the
100/// public tarball/archive path — to match the original (pre-`via`)
101/// behaviour.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum Via {
105    /// Public HTTPS tarball/archive API. No auth.
106    Https,
107    /// `git clone` of the underlying repo. Inherits the user's
108    /// existing git credentials.
109    Git,
110}
111
112impl Via {
113    /// The query-string value the resolver's URI parser sees, e.g.
114    /// `"git"` or `"https"`. Lowercased to match
115    /// [`crate::NamespaceSpec::canonical_uri`] output.
116    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    /// Resolve the spec into a single canonical URI string. Tap
126    /// shorthand expands to `github:<tap>/lex-labels`; the table
127    /// form's `rev`, `subdir`, and `via` are appended via fragment +
128    /// query (`uri#rev?subdir=...&via=git`) so the resolver can parse
129    /// them uniformly. `via = "https"` (the default) is intentionally
130    /// not encoded — omitting it keeps cache keys stable for the
131    /// existing tap-shorthand form (a `.lex.toml` change from
132    /// implicit-default to explicit-`https` should not invalidate
133    /// caches).
134    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                        // Both the URI and the table have a rev. The
153                        // tap shorthand can't reach this branch (it
154                        // never sets a fragment), so this is the
155                        // user-with-explicit-uri case where they wrote
156                        // `uri = "github:org/repo#main", rev = "v1"`.
157                        // Either is meaningful but together is
158                        // ambiguous — surface as an error rather than
159                        // silently drop one.
160                        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                // Only `Git` is encoded — `Https` is the default and
174                // omitting it keeps cache keys stable for existing
175                // configs that never declared `via`.
176                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    /// Validate mutual-exclusion + non-emptiness, plus that `via` is
189    /// only declared on URL-template-shaped specs (`tap`, or `uri`
190    /// using `github:` / `gitlab:`). `via` on a `path:` / `https:` /
191    /// `git+ssh:` / `git:` URI is meaningless — the transport is
192    /// already fully determined — so reject it at load time rather
193    /// than letting it silently no-op.
194    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
213/// True when `uri` starts with a URL-template scheme (`github:` or
214/// `gitlab:`). Used by [`NamespaceTable::validate`] to gate the `via`
215/// knob.
216fn 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/// Errors emitted by [`load_labels_from_toml`] and
224/// [`NamespaceSpec::canonical_uri`].
225#[derive(Debug)]
226#[non_exhaustive]
227pub enum LabelsConfigError {
228    /// Reading the toml file failed.
229    Io {
230        path: std::path::PathBuf,
231        source: std::io::Error,
232    },
233    /// The toml body did not parse.
234    Parse {
235        path: std::path::PathBuf,
236        message: String,
237    },
238    /// `[labels]` declared the reserved `lex` namespace. The `lex.*`
239    /// label space is owned by the core and ships compiled-in;
240    /// re-declaring it would silently shadow core built-ins.
241    ReservedNamespace,
242    /// Table form had both `tap` and `uri` set. They're mutually
243    /// exclusive — pick one.
244    TapAndUri,
245    /// Table form had neither `tap` nor `uri` set.
246    EmptyTable,
247    /// Both the explicit `uri` (with a `#fragment`) and a `rev`
248    /// field are set. Either is meaningful but together they're
249    /// ambiguous — pick one.
250    RevWithExplicitFragment { uri: String, rev: String },
251    /// `via` was declared on a spec whose URI scheme is not a URL
252    /// template (`github:` / `gitlab:`). The transport is already
253    /// fully determined by the scheme, so `via` would silently no-op
254    /// — reject it instead.
255    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
297/// Load the `[labels]` block from a `.lex.toml` at `path`. Returns
298/// an empty config if the file exists but has no `[labels]` block;
299/// `Io::NotFound` is propagated to the caller (the CLI usually
300/// treats it as "no labels configured" and continues).
301///
302/// Validates the reserved-key rule (`lex` is forbidden) and each
303/// spec's table-form invariants. Bad config fails the load instead
304/// of letting it surface at dispatch time.
305pub 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    // We only read the `[labels]` table; the rest of the file is
313    // confique's territory. A `toml::Value` parse + manual lookup
314    // keeps us from reaching for a separate top-level struct.
315    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/// Top-level configuration consumed by Lex applications.
345#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
346pub struct LexConfig {
347    /// Formatting rules.
348    pub formatting: FormattingConfig,
349    /// Inspect output options.
350    pub inspect: InspectConfig,
351    /// Format-specific conversion options.
352    pub convert: ConvertConfig,
353    /// Diagnostics options.
354    pub diagnostics: DiagnosticsConfig,
355    /// Include-resolution options.
356    pub includes: IncludesConfig,
357    /// Extension-namespace declarations. The map shape is
358    /// free-form (each key is a namespace name; the value is a
359    /// sum-typed `NamespaceSpec`), so the field is declared as a
360    /// `LeafType::Value` escape hatch via `#[clapfig(value)]` —
361    /// clapfig accepts any TOML at this key and serde does the
362    /// shape-validation on the deserialize side. The `lexd labels`
363    /// subcommand and the boot helper read individual entries via
364    /// [`load_labels_from_toml`] for richer error messages
365    /// (reserved-namespace check, table-form validation, …).
366    #[clapfig(value, optional)]
367    #[serde(default)]
368    pub labels: BTreeMap<String, NamespaceSpec>,
369}
370
371/// Formatting-related configuration groups.
372#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
373pub struct FormattingConfig {
374    /// Formatting rules for lex output.
375    pub rules: FormattingRulesConfig,
376    /// Automatically format documents on save (consumed by editors).
377    #[clapfig(default = false)]
378    pub format_on_save: bool,
379}
380
381/// Mirrors the knobs exposed by the Lex formatter.
382#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
383pub struct FormattingRulesConfig {
384    /// Number of blank lines inserted before a session title.
385    #[clapfig(default = 1)]
386    pub session_blank_lines_before: usize,
387    /// Number of blank lines inserted after a session title.
388    #[clapfig(default = 1)]
389    pub session_blank_lines_after: usize,
390    /// Normalize list markers to predictable markers.
391    #[clapfig(default = true)]
392    pub normalize_seq_markers: bool,
393    /// Character for unordered list items when normalization is enabled.
394    /// `char` isn't in clapfig's native leaf-type vocabulary, so this is
395    /// a `LeafType::Value` escape hatch — serde converts the single-char
396    /// TOML string on deserialize.
397    #[clapfig(value, default = "-")]
398    pub unordered_seq_marker: char,
399    /// Maximum consecutive blank lines kept in output.
400    #[clapfig(default = 2)]
401    pub max_blank_lines: usize,
402    /// Whitespace string for each indentation level.
403    #[clapfig(default = "    ")]
404    pub indent_string: String,
405    /// Preserve trailing blank lines at the end of a document.
406    #[clapfig(default = false)]
407    pub preserve_trailing_blanks: bool,
408    /// Normalize verbatim fences back to canonical :: form.
409    #[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/// Controls AST-related inspect output.
444#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
445pub struct InspectConfig {
446    /// AST visualization options.
447    pub ast: InspectAstConfig,
448    /// Nodemap visualization options.
449    pub nodemap: NodemapConfig,
450}
451
452#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
453pub struct InspectAstConfig {
454    /// Include annotations, titles, markers, and other metadata in AST visualizations.
455    #[clapfig(default = false)]
456    pub include_all_properties: bool,
457    /// Show line numbers next to AST entries.
458    #[clapfig(default = true)]
459    pub show_line_numbers: bool,
460}
461
462#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
463pub struct NodemapConfig {
464    /// Render ANSI-colored blocks instead of Base2048 glyphs.
465    #[clapfig(default = false)]
466    pub color_blocks: bool,
467    /// Render Base2048 glyphs but color them with ANSI codes.
468    #[clapfig(default = false)]
469    pub color_characters: bool,
470    /// Append high-level summary statistics under the node map output.
471    #[clapfig(default = false)]
472    pub show_summary: bool,
473}
474
475/// Format-specific conversion knobs.
476#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
477pub struct ConvertConfig {
478    /// PDF export options.
479    pub pdf: PdfConfig,
480    /// HTML export options.
481    pub html: HtmlConfig,
482}
483
484#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
485pub struct PdfConfig {
486    /// Page profile used when exporting to PDF ("lexed" or "mobile").
487    #[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    /// Theme for HTML export.
502    #[clapfig(default = "default")]
503    pub theme: String,
504    /// Optional path to a custom CSS file to append after the baseline CSS.
505    pub custom_css: Option<String>,
506}
507
508/// Diagnostics options.
509#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
510pub struct DiagnosticsConfig {
511    /// Per-rule severity overrides. Each entry takes either a bare
512    /// severity string (`"allow"`, `"warn"`, `"deny"`) or an array
513    /// form (`["warn", { option = value }]`) carrying rule-specific
514    /// options — see [`RuleConfig`]. The defaults shown next to each
515    /// rule are the intrinsic defaults; uncomment a line to override.
516    pub rules: DiagnosticsRulesConfig,
517}
518
519/// Per-rule severity for diagnostics.
520///
521/// One field per built-in diagnostic code. Each field's doc comment is
522/// the description that surfaces in `lexd config gen` output, so
523/// authoring conventions for these doc comments matter: write them as
524/// user-facing prose, lead with what triggers the diagnostic, finish
525/// with the intrinsic default.
526///
527/// `RuleConfig` is a serde-untagged sum (`"warn"` *or*
528/// `["warn", { max = 80 }]`), so every field carries `#[clapfig(value)]`
529/// — the leaf is a `LeafType::Value` escape hatch in the schema, and
530/// serde does the shape validation on deserialize. clapfig's typo
531/// detection on the *field names themselves* still applies: a
532/// misspelled built-in like `missing_footote` is rejected as an unknown
533/// key. Extension-emitted codes (`<namespace>.<code>`) ride in via
534/// `on_unknown_key` and a side-channel populated by the loader — they
535/// are not fields on this struct.
536///
537/// `Default` returns every field at [`Severity::Warn`] regardless of
538/// each rule's *intrinsic* default. Real production loads run through
539/// clapfig and honour the `#[clapfig(default = "...")]` annotations.
540/// `Default` is here so tests and ad-hoc embedders can construct an
541/// instance without going through the clapfig pipeline.
542#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
543pub struct DiagnosticsRulesConfig {
544    /// A footnote reference like `[42]` has no corresponding
545    /// definition in the document. Intrinsic default: deny.
546    #[clapfig(value, default = "deny")]
547    pub missing_footnote: RuleConfig,
548    /// A footnote definition appears in the document but no
549    /// reference points at it. Intrinsic default: warn.
550    #[clapfig(value, default = "warn")]
551    pub unused_footnote: RuleConfig,
552    /// A table row has a different number of columns than the
553    /// table's header row. Intrinsic default: warn.
554    #[clapfig(value, default = "warn")]
555    pub table_inconsistent_columns: RuleConfig,
556    /// A label uses the reserved `doc.*` prefix, which is no longer
557    /// valid under the current label policy. Intrinsic default: deny.
558    #[clapfig(value, default = "deny")]
559    pub forbidden_label_prefix: RuleConfig,
560    /// A `lex.*` literal references a canonical that the toolchain
561    /// does not recognise — typically a typo or a label written for
562    /// a future core schema. Intrinsic default: deny.
563    #[clapfig(value, default = "deny")]
564    pub unknown_lex_canonical: RuleConfig,
565    /// Spellcheck diagnostics. Intrinsic default: warn. **Currently
566    /// not consulted at emission time** — spellcheck emits through a
567    /// separate path in `lex-analysis::spellcheck`. The field is
568    /// present so the value lives in the user-facing config schema;
569    /// runtime wiring lands when spellcheck moves over to the
570    /// `AnalysisDiagnostic` / `DiagnosticKind` pipeline.
571    #[clapfig(value, default = "warn")]
572    pub spellcheck: RuleConfig,
573    /// Schema-validation diagnostics for extension labels.
574    pub schema: SchemaRulesConfig,
575}
576
577impl DiagnosticsRulesConfig {
578    /// Look up a rule by its on-the-wire code (e.g. `"missing-footnote"`
579    /// or `"schema.unknown-label"`).
580    ///
581    /// Only consults named built-in fields. Extension-emitted codes
582    /// (`<namespace>.<code>`) live in a separate side-channel map
583    /// populated by the loader's `on_unknown_key` callback and stored
584    /// on [`LoadedLexConfig::extension_diagnostic_rules`]; use
585    /// [`LoadedLexConfig::lookup_diagnostic_rule`] to consult both
586    /// surfaces with a single call.
587    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            // `spellcheck` is intentionally absent: spellcheck
595            // diagnostics emit through a separate path (see
596            // `lex-analysis/src/spellcheck.rs`) and do not flow
597            // through `DiagnosticKind` / `apply_rules` today.
598            // Returning `Some(&self.spellcheck)` here would falsely
599            // suggest the rule is wired. Joining the registry is
600            // tracked alongside the spellcheck refactor.
601            "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/// Schema-validation diagnostics. Each field maps to one of the
612/// schema pre-validation checks the analyser performs before
613/// dispatching to an extension handler. See the Extending Lex
614/// proposal (`comms/specs/proposals/extending-lex.lex`) §13.2.
615#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
616pub struct SchemaRulesConfig {
617    /// A label is invoked whose namespace is registered, but no
618    /// schema entry exists for the label itself. Typically a typo
619    /// or an out-of-version label. Intrinsic default: deny.
620    #[clapfig(value, default = "deny")]
621    pub unknown_label: RuleConfig,
622    /// A label invocation omits a parameter the schema marks as
623    /// required. Intrinsic default: deny.
624    #[clapfig(value, default = "deny")]
625    pub missing_param: RuleConfig,
626    /// A label parameter's value does not match the type the schema
627    /// declares. Intrinsic default: deny.
628    #[clapfig(value, default = "deny")]
629    pub param_type_mismatch: RuleConfig,
630    /// A label attaches to a container shape the schema disallows
631    /// (e.g. attaching a paragraph-only label to a session).
632    /// Intrinsic default: deny.
633    #[clapfig(value, default = "deny")]
634    pub bad_attachment: RuleConfig,
635    /// A label body's shape (`none` / `text` / `lex`) does not match
636    /// the schema's declared body kind. Intrinsic default: deny.
637    #[clapfig(value, default = "deny")]
638    pub body_shape_mismatch: RuleConfig,
639}
640
641/// Include-resolution options consumed by `lexd convert`, `lexd inspect`,
642/// and the LSP. `lexd format` never expands includes regardless.
643#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
644pub struct IncludesConfig {
645    /// Resolution root for include path resolution.
646    ///
647    /// All include paths — relative or root-absolute (`/...`) — must
648    /// lexically normalize inside this directory. Outside-the-root paths
649    /// fail with a `RootEscape` error. (The resolver does not call
650    /// `std::fs::canonicalize`; symlink-aware canonicalization is the
651    /// loader's responsibility, not the resolver's.)
652    ///
653    /// When `None` (default), the CLI discovers the root by walking up
654    /// from the entry-point document to find the nearest `.lex.toml`,
655    /// falling back to the entry-point's own directory.
656    pub root: Option<String>,
657    /// Maximum include depth. Default 8. Hitting the limit is an error,
658    /// not a silent truncation.
659    #[clapfig(default = 8)]
660    pub max_depth: usize,
661    /// Maximum total include count across the document (DoS bound).
662    /// Default 1000. Caps fan-out — `max_depth` alone bounds chain
663    /// length but a doc with thousands of includes at depth 1 still
664    /// blows past it.
665    #[clapfig(default = 1000)]
666    pub max_total_includes: usize,
667    /// Maximum size of any single included file in bytes (DoS bound).
668    /// Default 10 MiB (10485760). Files larger than this are rejected
669    /// before any bytes hit memory. Used by `FsLoader`; the in-memory
670    /// `MemoryLoader` doesn't enforce a size limit.
671    #[clapfig(default = 10485760)]
672    pub max_file_size: u64,
673}
674
675/// Typed configuration plus the side-channel map of extension-emitted
676/// diagnostic rules. Returned by [`apply_extension_rules_callback`]-aware
677/// loaders.
678///
679/// The typed [`LexConfig`] holds built-in fields exactly. Extension
680/// diagnostic codes (`<namespace>.<code>` entries under
681/// `[diagnostics.rules]`) live in `extension_diagnostic_rules` so that
682/// dropping the previous `#[serde(flatten)]` catch-all restored typo
683/// detection on built-in field names while still accepting the open-
684/// ended extension key space.
685#[derive(Debug, Clone)]
686pub struct LoadedLexConfig {
687    pub config: LexConfig,
688    /// Side-channel map of extension-emitted diagnostic rules keyed by
689    /// their on-the-wire code (`<namespace>.<code>`). Populated from
690    /// `[diagnostics.rules]` entries accepted by the loader's
691    /// `on_unknown_key` callback.
692    pub extension_diagnostic_rules: BTreeMap<String, RuleConfig>,
693}
694
695impl LoadedLexConfig {
696    /// Look up a `[diagnostics.rules]` entry by its on-the-wire code,
697    /// consulting built-in fields first and the extension side-channel
698    /// second. Built-ins always win — a stray extension key that
699    /// happens to spell a built-in code does not override the typed
700    /// surface.
701    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
710/// Dotted-path prefix under which extension-emitted diagnostic codes
711/// live (`<namespace>.<code>` style keys, e.g.
712/// `"acme.task-due-date-missing" = "warn"`). Used with clapfig's
713/// [`accept_dotted_extension_keys_in`](clapfig::SchemaConfigBuilder::accept_dotted_extension_keys_in)
714/// helper: any unknown key in this subtree whose remainder contains a
715/// `.` is treated as an extension code and routed to the collected-
716/// unknowns list; bare typos at this level or typos inside
717/// `[diagnostics.rules.schema]` still fail strict-mode validation.
718pub const DIAGNOSTICS_RULES_PATH: &str = "diagnostics.rules";
719
720/// Drain clapfig's [`CollectedUnknown`](clapfig::CollectedUnknown) list
721/// into a [`BTreeMap<String, RuleConfig>`] suitable for
722/// [`LoadedLexConfig::extension_diagnostic_rules`].
723///
724/// Only paths under [`DIAGNOSTICS_RULES_PATH`] are kept; the dotted
725/// remainder becomes the map key (e.g.
726/// `diagnostics.rules.acme.task-due-date-missing` →
727/// `acme.task-due-date-missing`). Entries whose `value` is `None` or
728/// which fail to deserialize as a [`RuleConfig`] are silently dropped
729/// — the clapfig accept decision has already been made and the load
730/// succeeded; this finalization step is best-effort.
731pub 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        // Untouched rules retain their intrinsic defaults.
854        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        // Extension codes (`<namespace>.<code>`) under [diagnostics.rules]
877        // are no longer struct fields — they ride in the
878        // `LoadedLexConfig::extension_diagnostic_rules` side-channel
879        // populated by the loader's `on_unknown_key` callback. Built-in
880        // fields next to them keep their intrinsic typing.
881        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        // Drift coverage: built-ins win over a same-named side-channel
912        // entry; an extension code only the side-channel knows about
913        // resolves through the second path.
914        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        // Built-in wins over the same-named side-channel entry.
942        assert_eq!(
943            loaded
944                .lookup_diagnostic_rule("missing-footnote")
945                .map(|r| r.severity()),
946            Some(Severity::Deny)
947        );
948        // Side-channel entries resolve through the second path.
949        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        // Headline behaviour change vs PR #658: dropping the
978        // `#[serde(flatten)] extra` catch-all means typos in built-in
979        // field names are rejected by clapfig's strict-mode validator
980        // again — they no longer land silently in `extra`.
981        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        // `accept_dotted_extension_keys_in` must NOT accept a typo
997        // inside a nested-built-in section (`[diagnostics.rules.schema]`)
998        // just because the dotted path has more than two segments. The
999        // clapfig-side predicate honours the schema's nested-field
1000        // shape — `schema` is a typed nested object, not an open-ended
1001        // extension namespace — so an unknown key under it stays a
1002        // strict-mode violation. This is the failure mode Gemini
1003        // flagged on the original PR #664 plan.
1004        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    // Per-config-struct `default_for_tests` helpers — the new macro
1018    // doesn't auto-`Default` like confique did, and we want one place
1019    // each struct can be constructed for unit tests that don't go
1020    // through the full clapfig pipeline. Production loads always run
1021    // through clapfig and pick up the `#[clapfig(default = ...)]`
1022    // annotations.
1023    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        // Both produce the bare template URI — encoding the implicit
1234        // default would needlessly diverge cache keys.
1235        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}