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    /// A session reference (`[#2.1]`) points at a session identifier
574    /// that exists nowhere in the merged document. Opt-in: emitted only
575    /// by `check --references`. Intrinsic default: warn.
576    #[clapfig(value, default = "warn")]
577    pub missing_session_target: RuleConfig,
578    /// A definition reference (`[Title]`) has no matching definition in
579    /// the merged document. Opt-in (`check --references`). Intrinsic
580    /// default: warn — every `[...]` is a reference, so this can be
581    /// chatty in prose-heavy docs and is trivially silenced per-rule.
582    #[clapfig(value, default = "warn")]
583    pub missing_definition_target: RuleConfig,
584    /// An annotation reference (`[::label]`) points at a label that no
585    /// annotation declares. Opt-in (`check --references`). Intrinsic
586    /// default: warn.
587    #[clapfig(value, default = "warn")]
588    pub missing_annotation_target: RuleConfig,
589    /// A citation (`[@key]`) has no matching annotation label or
590    /// definition subject in the merged document. Opt-in
591    /// (`check --references`). Intrinsic default: warn.
592    #[clapfig(value, default = "warn")]
593    pub missing_citation_target: RuleConfig,
594    /// A URL reference (`[http://…]`, `[https://…]`, `[mailto:…]`) that
595    /// is not well-formed (embedded space, empty host, otherwise
596    /// unparseable).
597    /// Opt-in (`check --references`); well-formedness only — network
598    /// reachability is not checked. Intrinsic default: warn.
599    #[clapfig(value, default = "warn")]
600    pub malformed_url: RuleConfig,
601    /// A file-path reference — an inline `[./x.txt]` / `[../y]` / `[/abs]`
602    /// or a verbatim block's `src=` — points at a file that does not
603    /// exist on disk (or escapes the resolution root / is a
604    /// platform-absolute path). Opt-in (`check --references`).
605    /// `lex.include src=` is excluded — the base command validates it via
606    /// expansion. Intrinsic default: warn.
607    #[clapfig(value, default = "warn")]
608    pub missing_file_target: RuleConfig,
609    /// Schema-validation diagnostics for extension labels.
610    pub schema: SchemaRulesConfig,
611}
612
613impl DiagnosticsRulesConfig {
614    /// Look up a rule by its on-the-wire code (e.g. `"missing-footnote"`
615    /// or `"schema.unknown-label"`).
616    ///
617    /// Only consults named built-in fields. Extension-emitted codes
618    /// (`<namespace>.<code>`) live in a separate side-channel map
619    /// populated by the loader's `on_unknown_key` callback and stored
620    /// on [`LoadedLexConfig::extension_diagnostic_rules`]; use
621    /// [`LoadedLexConfig::lookup_diagnostic_rule`] to consult both
622    /// surfaces with a single call.
623    pub fn lookup_by_code(&self, code: &str) -> Option<&RuleConfig> {
624        match code {
625            "missing-footnote" => Some(&self.missing_footnote),
626            "unused-footnote" => Some(&self.unused_footnote),
627            "table-inconsistent-columns" => Some(&self.table_inconsistent_columns),
628            "forbidden-label-prefix" => Some(&self.forbidden_label_prefix),
629            "unknown-lex-canonical" => Some(&self.unknown_lex_canonical),
630            "missing-session-target" => Some(&self.missing_session_target),
631            "missing-definition-target" => Some(&self.missing_definition_target),
632            "missing-annotation-target" => Some(&self.missing_annotation_target),
633            "missing-citation-target" => Some(&self.missing_citation_target),
634            "malformed-url" => Some(&self.malformed_url),
635            "missing-file-target" => Some(&self.missing_file_target),
636            // `spellcheck` is intentionally absent: spellcheck
637            // diagnostics emit through a separate path (see
638            // `lex-analysis/src/spellcheck.rs`) and do not flow
639            // through `DiagnosticKind` / `apply_rules` today.
640            // Returning `Some(&self.spellcheck)` here would falsely
641            // suggest the rule is wired. Joining the registry is
642            // tracked alongside the spellcheck refactor.
643            "schema.unknown-label" => Some(&self.schema.unknown_label),
644            "schema.missing-param" => Some(&self.schema.missing_param),
645            "schema.param-type-mismatch" => Some(&self.schema.param_type_mismatch),
646            "schema.bad-attachment" => Some(&self.schema.bad_attachment),
647            "schema.body-shape-mismatch" => Some(&self.schema.body_shape_mismatch),
648            _ => None,
649        }
650    }
651}
652
653/// Schema-validation diagnostics. Each field maps to one of the
654/// schema pre-validation checks the analyser performs before
655/// dispatching to an extension handler. See the Extending Lex
656/// proposal (`comms/specs/proposals/extending-lex.lex`) §13.2.
657#[derive(Debug, Clone, Default, Schema, Serialize, Deserialize)]
658pub struct SchemaRulesConfig {
659    /// A label is invoked whose namespace is registered, but no
660    /// schema entry exists for the label itself. Typically a typo
661    /// or an out-of-version label. Intrinsic default: deny.
662    #[clapfig(value, default = "deny")]
663    pub unknown_label: RuleConfig,
664    /// A label invocation omits a parameter the schema marks as
665    /// required. Intrinsic default: deny.
666    #[clapfig(value, default = "deny")]
667    pub missing_param: RuleConfig,
668    /// A label parameter's value does not match the type the schema
669    /// declares. Intrinsic default: deny.
670    #[clapfig(value, default = "deny")]
671    pub param_type_mismatch: RuleConfig,
672    /// A label attaches to a container shape the schema disallows
673    /// (e.g. attaching a paragraph-only label to a session).
674    /// Intrinsic default: deny.
675    #[clapfig(value, default = "deny")]
676    pub bad_attachment: RuleConfig,
677    /// A label body's shape (`none` / `text` / `lex`) does not match
678    /// the schema's declared body kind. Intrinsic default: deny.
679    #[clapfig(value, default = "deny")]
680    pub body_shape_mismatch: RuleConfig,
681}
682
683/// Include-resolution options consumed by `lexd convert`, `lexd inspect`,
684/// and the LSP. `lexd format` never expands includes regardless.
685#[derive(Debug, Clone, Schema, Serialize, Deserialize)]
686pub struct IncludesConfig {
687    /// Resolution root for include path resolution.
688    ///
689    /// All include paths — relative or root-absolute (`/...`) — must
690    /// lexically normalize inside this directory. Outside-the-root paths
691    /// fail with a `RootEscape` error. (The resolver does not call
692    /// `std::fs::canonicalize`; symlink-aware canonicalization is the
693    /// loader's responsibility, not the resolver's.)
694    ///
695    /// When `None` (default), the CLI discovers the root by walking up
696    /// from the entry-point document to find the nearest `.lex.toml`,
697    /// falling back to the entry-point's own directory.
698    pub root: Option<String>,
699    /// Maximum include depth. Default 8. Hitting the limit is an error,
700    /// not a silent truncation.
701    #[clapfig(default = 8)]
702    pub max_depth: usize,
703    /// Maximum total include count across the document (DoS bound).
704    /// Default 1000. Caps fan-out — `max_depth` alone bounds chain
705    /// length but a doc with thousands of includes at depth 1 still
706    /// blows past it.
707    #[clapfig(default = 1000)]
708    pub max_total_includes: usize,
709    /// Maximum size of any single included file in bytes (DoS bound).
710    /// Default 10 MiB (10485760). Files larger than this are rejected
711    /// before any bytes hit memory. Used by `FsLoader`; the in-memory
712    /// `MemoryLoader` doesn't enforce a size limit.
713    #[clapfig(default = 10485760)]
714    pub max_file_size: u64,
715}
716
717/// Typed configuration plus the side-channel map of extension-emitted
718/// diagnostic rules. Returned by [`apply_extension_rules_callback`]-aware
719/// loaders.
720///
721/// The typed [`LexConfig`] holds built-in fields exactly. Extension
722/// diagnostic codes (`<namespace>.<code>` entries under
723/// `[diagnostics.rules]`) live in `extension_diagnostic_rules` so that
724/// dropping the previous `#[serde(flatten)]` catch-all restored typo
725/// detection on built-in field names while still accepting the open-
726/// ended extension key space.
727#[derive(Debug, Clone)]
728pub struct LoadedLexConfig {
729    pub config: LexConfig,
730    /// Side-channel map of extension-emitted diagnostic rules keyed by
731    /// their on-the-wire code (`<namespace>.<code>`). Populated from
732    /// `[diagnostics.rules]` entries accepted by the loader's
733    /// `on_unknown_key` callback.
734    pub extension_diagnostic_rules: BTreeMap<String, RuleConfig>,
735}
736
737impl LoadedLexConfig {
738    /// Look up a `[diagnostics.rules]` entry by its on-the-wire code,
739    /// consulting built-in fields first and the extension side-channel
740    /// second. Built-ins always win — a stray extension key that
741    /// happens to spell a built-in code does not override the typed
742    /// surface.
743    pub fn lookup_diagnostic_rule(&self, code: &str) -> Option<&RuleConfig> {
744        self.config
745            .diagnostics
746            .rules
747            .lookup_by_code(code)
748            .or_else(|| self.extension_diagnostic_rules.get(code))
749    }
750}
751
752/// Dotted-path prefix under which extension-emitted diagnostic codes
753/// live (`<namespace>.<code>` style keys, e.g.
754/// `"acme.task-due-date-missing" = "warn"`). Used with clapfig's
755/// [`accept_dotted_extension_keys_in`](clapfig::SchemaConfigBuilder::accept_dotted_extension_keys_in)
756/// helper: any unknown key in this subtree whose remainder contains a
757/// `.` is treated as an extension code and routed to the collected-
758/// unknowns list; bare typos at this level or typos inside
759/// `[diagnostics.rules.schema]` still fail strict-mode validation.
760pub const DIAGNOSTICS_RULES_PATH: &str = "diagnostics.rules";
761
762/// Drain clapfig's [`CollectedUnknown`](clapfig::CollectedUnknown) list
763/// into a [`BTreeMap<String, RuleConfig>`] suitable for
764/// [`LoadedLexConfig::extension_diagnostic_rules`].
765///
766/// Only paths under [`DIAGNOSTICS_RULES_PATH`] are kept; the dotted
767/// remainder becomes the map key (e.g.
768/// `diagnostics.rules.acme.task-due-date-missing` →
769/// `acme.task-due-date-missing`). Entries whose `value` is `None` or
770/// which fail to deserialize as a [`RuleConfig`] are silently dropped
771/// — the clapfig accept decision has already been made and the load
772/// succeeded; this finalization step is best-effort.
773pub fn collect_extension_diagnostic_rules(
774    unknowns: Vec<clapfig::CollectedUnknown>,
775) -> BTreeMap<String, RuleConfig> {
776    let prefix = format!("{DIAGNOSTICS_RULES_PATH}.");
777    let mut out = BTreeMap::new();
778    for u in unknowns {
779        let Some(key) = u.path.strip_prefix(&prefix) else {
780            continue;
781        };
782        let Some(value) = u.value else { continue };
783        if let Ok(rule) = value.try_into() {
784            out.insert(key.to_string(), rule);
785        }
786    }
787    out
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    fn load_defaults() -> LexConfig {
795        let (config, _unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
796            .app_name("lex")
797            .no_env()
798            .search_paths(vec![])
799            .accept_dotted_extension_keys_in(
800                DIAGNOSTICS_RULES_PATH,
801                clapfig::UnknownKeyDecision::Collect,
802            )
803            .load_with_unknowns()
804            .expect("defaults to load");
805        config
806    }
807
808    #[test]
809    fn loads_default_config() {
810        let config = load_defaults();
811        assert_eq!(config.formatting.rules.session_blank_lines_before, 1);
812        assert!(config.inspect.ast.show_line_numbers);
813        assert_eq!(config.convert.pdf.size, PdfPageSize::LexEd);
814    }
815
816    fn load_from(toml_body: &str) -> LexConfig {
817        load_wrapper_from(toml_body).config
818    }
819
820    fn load_wrapper_from(toml_body: &str) -> LoadedLexConfig {
821        let dir = tempfile::tempdir().unwrap();
822        let path = dir.path().join(CONFIG_FILE_NAME);
823        std::fs::write(&path, toml_body).unwrap();
824        let (config, unknowns) = clapfig::Clapfig::schema_builder::<LexConfig>()
825            .app_name("lex")
826            .file_name(CONFIG_FILE_NAME)
827            .no_env()
828            .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
829            .accept_dotted_extension_keys_in(
830                DIAGNOSTICS_RULES_PATH,
831                clapfig::UnknownKeyDecision::Collect,
832            )
833            .load_with_unknowns()
834            .expect("loads");
835        LoadedLexConfig {
836            config,
837            extension_diagnostic_rules: collect_extension_diagnostic_rules(unknowns),
838        }
839    }
840
841    #[test]
842    fn diagnostics_rules_defaults_in_place() {
843        let cfg = load_defaults();
844        assert_eq!(
845            cfg.diagnostics.rules.missing_footnote.severity(),
846            Severity::Deny
847        );
848        assert_eq!(
849            cfg.diagnostics.rules.unused_footnote.severity(),
850            Severity::Warn
851        );
852        assert_eq!(
853            cfg.diagnostics.rules.table_inconsistent_columns.severity(),
854            Severity::Warn
855        );
856        assert_eq!(
857            cfg.diagnostics.rules.forbidden_label_prefix.severity(),
858            Severity::Deny
859        );
860        assert_eq!(
861            cfg.diagnostics.rules.unknown_lex_canonical.severity(),
862            Severity::Deny
863        );
864        assert_eq!(cfg.diagnostics.rules.spellcheck.severity(), Severity::Warn);
865        assert_eq!(
866            cfg.diagnostics.rules.schema.unknown_label.severity(),
867            Severity::Deny
868        );
869    }
870
871    #[test]
872    fn diagnostics_rules_user_overrides_apply() {
873        let cfg = load_from(
874            r#"
875[diagnostics.rules]
876missing_footnote = "allow"
877table_inconsistent_columns = "deny"
878
879[diagnostics.rules.schema]
880unknown_label = "warn"
881"#,
882        );
883        assert_eq!(
884            cfg.diagnostics.rules.missing_footnote.severity(),
885            Severity::Allow
886        );
887        assert_eq!(
888            cfg.diagnostics.rules.table_inconsistent_columns.severity(),
889            Severity::Deny
890        );
891        assert_eq!(
892            cfg.diagnostics.rules.schema.unknown_label.severity(),
893            Severity::Warn
894        );
895        // Untouched rules retain their intrinsic defaults.
896        assert_eq!(
897            cfg.diagnostics.rules.forbidden_label_prefix.severity(),
898            Severity::Deny
899        );
900    }
901
902    #[test]
903    fn diagnostics_rules_accept_array_form() {
904        let cfg = load_from(
905            r#"
906[diagnostics.rules]
907missing_footnote = ["warn", { example_option = 42 }]
908"#,
909        );
910        let rule = &cfg.diagnostics.rules.missing_footnote;
911        assert_eq!(rule.severity(), Severity::Warn);
912        let opts = rule.options().expect("array form keeps options");
913        assert_eq!(opts.get("example_option"), Some(&toml::Value::Integer(42)));
914    }
915
916    #[test]
917    fn diagnostics_rules_extension_codes_land_in_side_channel() {
918        // Extension codes (`<namespace>.<code>`) under [diagnostics.rules]
919        // are no longer struct fields — they ride in the
920        // `LoadedLexConfig::extension_diagnostic_rules` side-channel
921        // populated by the loader's `on_unknown_key` callback. Built-in
922        // fields next to them keep their intrinsic typing.
923        let loaded = load_wrapper_from(
924            r#"
925[diagnostics.rules]
926missing_footnote = "allow"
927"acme.task-due-date-missing" = "deny"
928"foolco.bar" = ["warn", { max = 80 }]
929"#,
930        );
931        assert_eq!(
932            loaded.config.diagnostics.rules.missing_footnote.severity(),
933            Severity::Allow
934        );
935        let acme = loaded
936            .extension_diagnostic_rules
937            .get("acme.task-due-date-missing")
938            .expect("extension code captured in side-channel");
939        assert_eq!(acme.severity(), Severity::Deny);
940        let foolco = loaded
941            .extension_diagnostic_rules
942            .get("foolco.bar")
943            .expect("array-form extension code captured");
944        assert_eq!(foolco.severity(), Severity::Warn);
945        assert_eq!(
946            foolco.options().and_then(|o| o.get("max")),
947            Some(&toml::Value::Integer(80))
948        );
949    }
950
951    #[test]
952    fn loaded_lookup_diagnostic_rule_consults_both_surfaces() {
953        // Drift coverage: built-ins win over a same-named side-channel
954        // entry; an extension code only the side-channel knows about
955        // resolves through the second path.
956        let loaded = LoadedLexConfig {
957            config: LexConfig {
958                formatting: FormattingConfig {
959                    rules: FormattingRulesConfig::default_for_tests(),
960                    format_on_save: false,
961                },
962                inspect: InspectConfig::default_for_tests(),
963                convert: ConvertConfig::default_for_tests(),
964                diagnostics: DiagnosticsConfig {
965                    rules: DiagnosticsRulesConfig {
966                        missing_footnote: RuleConfig::Bare(Severity::Deny),
967                        ..Default::default()
968                    },
969                },
970                includes: IncludesConfig::default_for_tests(),
971                labels: BTreeMap::new(),
972            },
973            extension_diagnostic_rules: [
974                (
975                    "missing-footnote".to_string(),
976                    RuleConfig::Bare(Severity::Allow),
977                ),
978                ("acme.foo".to_string(), RuleConfig::Bare(Severity::Allow)),
979            ]
980            .into_iter()
981            .collect(),
982        };
983        // Built-in wins over the same-named side-channel entry.
984        assert_eq!(
985            loaded
986                .lookup_diagnostic_rule("missing-footnote")
987                .map(|r| r.severity()),
988            Some(Severity::Deny)
989        );
990        // Side-channel entries resolve through the second path.
991        assert_eq!(
992            loaded
993                .lookup_diagnostic_rule("acme.foo")
994                .map(|r| r.severity()),
995            Some(Severity::Allow)
996        );
997        assert!(loaded.lookup_diagnostic_rule("acme.unknown").is_none());
998    }
999
1000    fn load_expecting_error(toml_body: &str) -> clapfig::ClapfigError {
1001        let dir = tempfile::tempdir().unwrap();
1002        let path = dir.path().join(CONFIG_FILE_NAME);
1003        std::fs::write(&path, toml_body).unwrap();
1004        clapfig::Clapfig::schema_builder::<LexConfig>()
1005            .app_name("lex")
1006            .file_name(CONFIG_FILE_NAME)
1007            .no_env()
1008            .search_paths(vec![clapfig::SearchPath::Path(dir.path().to_path_buf())])
1009            .accept_dotted_extension_keys_in(
1010                DIAGNOSTICS_RULES_PATH,
1011                clapfig::UnknownKeyDecision::Collect,
1012            )
1013            .load_with_unknowns()
1014            .expect_err("typo must surface as an unknown-key error")
1015    }
1016
1017    #[test]
1018    fn diagnostics_rules_typo_in_builtin_errors() {
1019        // Headline behaviour change vs PR #658: dropping the
1020        // `#[serde(flatten)] extra` catch-all means typos in built-in
1021        // field names are rejected by clapfig's strict-mode validator
1022        // again — they no longer land silently in `extra`.
1023        let err = load_expecting_error(
1024            r#"
1025[diagnostics.rules]
1026missing_footote = "warn"
1027"#,
1028        );
1029        let keys = err.unknown_keys().expect("UnknownKeys variant");
1030        assert!(
1031            keys.iter().any(|k| k.key.ends_with("missing_footote")),
1032            "the misspelled key is reported: {keys:?}"
1033        );
1034    }
1035
1036    #[test]
1037    fn diagnostics_rules_typo_inside_nested_section_errors() {
1038        // `accept_dotted_extension_keys_in` must NOT accept a typo
1039        // inside a nested-built-in section (`[diagnostics.rules.schema]`)
1040        // just because the dotted path has more than two segments. The
1041        // clapfig-side predicate honours the schema's nested-field
1042        // shape — `schema` is a typed nested object, not an open-ended
1043        // extension namespace — so an unknown key under it stays a
1044        // strict-mode violation. This is the failure mode Gemini
1045        // flagged on the original PR #664 plan.
1046        let err = load_expecting_error(
1047            r#"
1048[diagnostics.rules.schema]
1049unkown_label = "warn"
1050"#,
1051        );
1052        let keys = err.unknown_keys().expect("UnknownKeys variant");
1053        assert!(
1054            keys.iter().any(|k| k.key.ends_with("unkown_label")),
1055            "the misspelled nested key is reported: {keys:?}"
1056        );
1057    }
1058
1059    // Per-config-struct `default_for_tests` helpers — the new macro
1060    // doesn't auto-`Default` like confique did, and we want one place
1061    // each struct can be constructed for unit tests that don't go
1062    // through the full clapfig pipeline. Production loads always run
1063    // through clapfig and pick up the `#[clapfig(default = ...)]`
1064    // annotations.
1065    impl FormattingRulesConfig {
1066        fn default_for_tests() -> Self {
1067            FormattingRulesConfig {
1068                session_blank_lines_before: 1,
1069                session_blank_lines_after: 1,
1070                normalize_seq_markers: true,
1071                unordered_seq_marker: '-',
1072                max_blank_lines: 2,
1073                indent_string: "    ".to_string(),
1074                preserve_trailing_blanks: false,
1075                normalize_verbatim_markers: true,
1076            }
1077        }
1078    }
1079
1080    impl InspectConfig {
1081        fn default_for_tests() -> Self {
1082            InspectConfig {
1083                ast: InspectAstConfig {
1084                    include_all_properties: false,
1085                    show_line_numbers: true,
1086                },
1087                nodemap: NodemapConfig {
1088                    color_blocks: false,
1089                    color_characters: false,
1090                    show_summary: false,
1091                },
1092            }
1093        }
1094    }
1095
1096    impl ConvertConfig {
1097        fn default_for_tests() -> Self {
1098            ConvertConfig {
1099                pdf: PdfConfig {
1100                    size: PdfPageSize::LexEd,
1101                },
1102                html: HtmlConfig {
1103                    theme: "default".to_string(),
1104                    custom_css: None,
1105                },
1106            }
1107        }
1108    }
1109
1110    impl IncludesConfig {
1111        fn default_for_tests() -> Self {
1112            IncludesConfig {
1113                root: None,
1114                max_depth: 8,
1115                max_total_includes: 1000,
1116                max_file_size: 10_485_760,
1117            }
1118        }
1119    }
1120
1121    #[test]
1122    fn labels_config_bare_uri_parses() {
1123        let dir = tempfile::tempdir().unwrap();
1124        let path = dir.path().join(".lex.toml");
1125        std::fs::write(
1126            &path,
1127            r#"
1128[labels]
1129foolco = "gitlab:foolco/lex-labels#main"
1130"#,
1131        )
1132        .unwrap();
1133        let labels = load_labels_from_toml(&path).expect("loads");
1134        let spec = labels.namespaces.get("foolco").unwrap();
1135        assert_eq!(
1136            spec.canonical_uri().unwrap(),
1137            "gitlab:foolco/lex-labels#main"
1138        );
1139    }
1140
1141    #[test]
1142    fn labels_config_tap_shorthand_expands() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let path = dir.path().join(".lex.toml");
1145        std::fs::write(
1146            &path,
1147            r#"
1148[labels]
1149acme = { tap = "acme" }
1150"#,
1151        )
1152        .unwrap();
1153        let labels = load_labels_from_toml(&path).unwrap();
1154        assert_eq!(
1155            labels
1156                .namespaces
1157                .get("acme")
1158                .unwrap()
1159                .canonical_uri()
1160                .unwrap(),
1161            "github:acme/lex-labels"
1162        );
1163    }
1164
1165    #[test]
1166    fn labels_config_expanded_table_with_rev_and_subdir() {
1167        let dir = tempfile::tempdir().unwrap();
1168        let path = dir.path().join(".lex.toml");
1169        std::fs::write(
1170            &path,
1171            r#"
1172[labels]
1173custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
1174"#,
1175        )
1176        .unwrap();
1177        let labels = load_labels_from_toml(&path).unwrap();
1178        let uri = labels
1179            .namespaces
1180            .get("custom")
1181            .unwrap()
1182            .canonical_uri()
1183            .unwrap();
1184        assert!(uri.starts_with("github:org/repo"));
1185        assert!(uri.contains("v1"));
1186        assert!(uri.contains("subdir=labels/"));
1187    }
1188
1189    #[test]
1190    fn labels_config_reserved_lex_namespace_rejected() {
1191        let dir = tempfile::tempdir().unwrap();
1192        let path = dir.path().join(".lex.toml");
1193        std::fs::write(
1194            &path,
1195            r#"
1196[labels]
1197lex = "github:fake/lex-labels"
1198"#,
1199        )
1200        .unwrap();
1201        let err = load_labels_from_toml(&path).unwrap_err();
1202        assert!(matches!(err, LabelsConfigError::ReservedNamespace));
1203    }
1204
1205    #[test]
1206    fn labels_config_tap_and_uri_together_rejected() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let path = dir.path().join(".lex.toml");
1209        std::fs::write(
1210            &path,
1211            r#"
1212[labels]
1213acme = { tap = "acme", uri = "github:other/repo" }
1214"#,
1215        )
1216        .unwrap();
1217        let err = load_labels_from_toml(&path).unwrap_err();
1218        assert!(matches!(err, LabelsConfigError::TapAndUri));
1219    }
1220
1221    #[test]
1222    fn labels_config_empty_table_rejected() {
1223        let dir = tempfile::tempdir().unwrap();
1224        let path = dir.path().join(".lex.toml");
1225        std::fs::write(
1226            &path,
1227            r#"
1228[labels]
1229acme = { rev = "v1" }
1230"#,
1231        )
1232        .unwrap();
1233        let err = load_labels_from_toml(&path).unwrap_err();
1234        assert!(matches!(err, LabelsConfigError::EmptyTable));
1235    }
1236
1237    #[test]
1238    fn labels_config_tap_with_via_git_encodes_query() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let path = dir.path().join(".lex.toml");
1241        std::fs::write(
1242            &path,
1243            r#"
1244[labels]
1245bigorg = { tap = "bigorg", via = "git" }
1246"#,
1247        )
1248        .unwrap();
1249        let labels = load_labels_from_toml(&path).unwrap();
1250        assert_eq!(
1251            labels
1252                .namespaces
1253                .get("bigorg")
1254                .unwrap()
1255                .canonical_uri()
1256                .unwrap(),
1257            "github:bigorg/lex-labels?via=git"
1258        );
1259    }
1260
1261    #[test]
1262    fn labels_config_default_via_https_is_not_encoded() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let path = dir.path().join(".lex.toml");
1265        std::fs::write(
1266            &path,
1267            r#"
1268[labels]
1269explicit_https = { tap = "acme", via = "https" }
1270implicit = { tap = "acme" }
1271"#,
1272        )
1273        .unwrap();
1274        let labels = load_labels_from_toml(&path).unwrap();
1275        // Both produce the bare template URI — encoding the implicit
1276        // default would needlessly diverge cache keys.
1277        let explicit = labels
1278            .namespaces
1279            .get("explicit_https")
1280            .unwrap()
1281            .canonical_uri()
1282            .unwrap();
1283        let implicit = labels
1284            .namespaces
1285            .get("implicit")
1286            .unwrap()
1287            .canonical_uri()
1288            .unwrap();
1289        assert_eq!(explicit, "github:acme/lex-labels");
1290        assert_eq!(implicit, "github:acme/lex-labels");
1291    }
1292
1293    #[test]
1294    fn labels_config_via_combines_with_subdir_and_rev() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let path = dir.path().join(".lex.toml");
1297        std::fs::write(
1298            &path,
1299            r#"
1300[labels]
1301foolco = { uri = "gitlab:foolco/lex-labels", rev = "v2.1.0", subdir = "labels/", via = "git" }
1302"#,
1303        )
1304        .unwrap();
1305        let labels = load_labels_from_toml(&path).unwrap();
1306        assert_eq!(
1307            labels
1308                .namespaces
1309                .get("foolco")
1310                .unwrap()
1311                .canonical_uri()
1312                .unwrap(),
1313            "gitlab:foolco/lex-labels#v2.1.0?subdir=labels/&via=git"
1314        );
1315    }
1316
1317    #[test]
1318    fn labels_config_via_on_https_uri_rejected() {
1319        let dir = tempfile::tempdir().unwrap();
1320        let path = dir.path().join(".lex.toml");
1321        std::fs::write(
1322            &path,
1323            r#"
1324[labels]
1325weird = { uri = "https://example.com/labels.tar.gz", via = "git" }
1326"#,
1327        )
1328        .unwrap();
1329        let err = load_labels_from_toml(&path).unwrap_err();
1330        assert!(matches!(
1331            err,
1332            LabelsConfigError::ViaOnNonTemplateScheme { .. }
1333        ));
1334    }
1335
1336    #[test]
1337    fn labels_config_via_on_path_uri_rejected() {
1338        let dir = tempfile::tempdir().unwrap();
1339        let path = dir.path().join(".lex.toml");
1340        std::fs::write(
1341            &path,
1342            r#"
1343[labels]
1344local = { uri = "path:./labels", via = "git" }
1345"#,
1346        )
1347        .unwrap();
1348        let err = load_labels_from_toml(&path).unwrap_err();
1349        assert!(matches!(
1350            err,
1351            LabelsConfigError::ViaOnNonTemplateScheme { .. }
1352        ));
1353    }
1354
1355    #[test]
1356    fn labels_config_missing_block_yields_empty_config() {
1357        let dir = tempfile::tempdir().unwrap();
1358        let path = dir.path().join(".lex.toml");
1359        std::fs::write(&path, "# no labels block\n").unwrap();
1360        let labels = load_labels_from_toml(&path).unwrap();
1361        assert!(labels.namespaces.is_empty());
1362    }
1363
1364    #[test]
1365    fn formatting_rules_config_converts_to_formatting_rules() {
1366        let config = load_defaults();
1367        let rules: FormattingRules = config.formatting.rules.into();
1368        assert_eq!(rules.session_blank_lines_before, 1);
1369        assert_eq!(rules.session_blank_lines_after, 1);
1370        assert!(rules.normalize_seq_markers);
1371        assert_eq!(rules.unordered_seq_marker, '-');
1372        assert_eq!(rules.max_blank_lines, 2);
1373        assert_eq!(rules.indent_string, "    ");
1374        assert!(!rules.preserve_trailing_blanks);
1375        assert!(rules.normalize_verbatim_markers);
1376    }
1377}