lex-config 0.14.0

Shared configuration loading for the lex toolchain
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
//! Shared configuration for the Lex toolchain.
//!
//! Defines [`LexConfig`] — the config struct consumed by all Lex applications.
//! Defaults are compiled into the struct via `#[config(default)]`. Loading and
//! layering is handled by [clapfig](https://docs.rs/clapfig) in the CLI.

use confique::Config;
use lex_babel::formats::lex::formatting_rules::FormattingRules;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

/// Canonical config file name used by the CLI and LSP.
pub const CONFIG_FILE_NAME: &str = ".lex.toml";

// ─────────────────────────── Labels (extension namespaces) ───────────────────────────

/// `[labels]` block in `.lex.toml` — declarations of extension
/// namespaces the workspace owner wants the host to load.
///
/// Loaded outside the main `LexConfig` confique chain because the
/// shape is a free-form map keyed by namespace name, not a
/// fixed-field struct. See [`load_labels_from_toml`].
///
/// ```toml
/// [labels]
/// acme = { tap = "acme" }                                       # tap shorthand
/// foolco = "gitlab:foolco/lex-labels#main"                      # bare URI
/// custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
/// ```
///
/// The reserved namespace name `lex` is rejected at load time —
/// `lex.*` is owned by the core and ships compiled-in.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LabelsConfig {
    /// Namespace name → spec. Order is sorted (BTreeMap) for
    /// deterministic loading and stable diagnostics.
    pub namespaces: BTreeMap<String, NamespaceSpec>,
}

/// One namespace declaration. Three on-disk shapes parse into the
/// same logical record:
///
/// - `acme = "github:acme/lex-labels"` — bare URI string.
/// - `acme = { tap = "acme" }` — tap shorthand, expands to
///   `github:acme/lex-labels`.
/// - `acme = { uri = "...", rev = "...", subdir = "..." }` — full
///   table form.
///
/// `tap` and `uri` are mutually exclusive on the table form;
/// having both is a load-time error (see [`NamespaceSpec::validate`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NamespaceSpec {
    /// Bare URI string form.
    Uri(String),
    /// Table form. One of `tap` / `uri` must be set; both is an
    /// error.
    Table(NamespaceTable),
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NamespaceTable {
    /// Tap-prefix shorthand. `tap = "acme"` expands to
    /// `github:acme/lex-labels`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tap: Option<String>,
    /// Explicit URI (`github:`, `gitlab:`, `https:`, `path:`,
    /// `git+ssh:`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uri: Option<String>,
    /// Branch / tag / SHA pin. Mutable refs (branches) honour the
    /// resolver's 24-hour cache TTL; tags and SHAs are cached
    /// indefinitely.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,
    /// Subdirectory inside the resolved repo containing the schema
    /// files. Defaults to repo root.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subdir: Option<String>,
}

impl NamespaceSpec {
    /// Resolve the spec into a single canonical URI string. Tap
    /// shorthand expands to `github:<tap>/lex-labels`; the table
    /// form's `rev` and `subdir` are appended via fragment + query
    /// (`uri#rev?subdir=...`) so the resolver can parse them
    /// uniformly.
    pub fn canonical_uri(&self) -> Result<String, LabelsConfigError> {
        match self {
            NamespaceSpec::Uri(s) => Ok(s.clone()),
            NamespaceSpec::Table(t) => {
                t.validate()?;
                let base = match (&t.tap, &t.uri) {
                    (Some(tap), None) => format!("github:{tap}/lex-labels"),
                    (None, Some(uri)) => uri.clone(),
                    (Some(_), Some(_)) => {
                        return Err(LabelsConfigError::TapAndUri);
                    }
                    (None, None) => {
                        return Err(LabelsConfigError::EmptyTable);
                    }
                };
                let mut out = base;
                if let Some(rev) = &t.rev {
                    if out.contains('#') {
                        // Both the URI and the table have a rev. The
                        // tap shorthand can't reach this branch (it
                        // never sets a fragment), so this is the
                        // user-with-explicit-uri case where they wrote
                        // `uri = "github:org/repo#main", rev = "v1"`.
                        // Either is meaningful but together is
                        // ambiguous — surface as an error rather than
                        // silently drop one.
                        return Err(LabelsConfigError::RevWithExplicitFragment {
                            uri: out,
                            rev: rev.clone(),
                        });
                    }
                    out.push('#');
                    out.push_str(rev);
                }
                if let Some(subdir) = &t.subdir {
                    out.push_str(if out.contains('?') { "&" } else { "?" });
                    out.push_str("subdir=");
                    out.push_str(subdir);
                }
                Ok(out)
            }
        }
    }
}

impl NamespaceTable {
    /// Validate mutual-exclusion + non-emptiness. Surfaces as a
    /// load-time error so a bad `lex.toml` fails fast with a clear
    /// message, not at first dispatch.
    pub fn validate(&self) -> Result<(), LabelsConfigError> {
        match (&self.tap, &self.uri) {
            (Some(_), Some(_)) => Err(LabelsConfigError::TapAndUri),
            (None, None) => Err(LabelsConfigError::EmptyTable),
            _ => Ok(()),
        }
    }
}

/// Errors emitted by [`load_labels_from_toml`] and
/// [`NamespaceSpec::canonical_uri`].
#[derive(Debug)]
#[non_exhaustive]
pub enum LabelsConfigError {
    /// Reading the toml file failed.
    Io {
        path: std::path::PathBuf,
        source: std::io::Error,
    },
    /// The toml body did not parse.
    Parse {
        path: std::path::PathBuf,
        message: String,
    },
    /// `[labels]` declared the reserved `lex` namespace. The `lex.*`
    /// label space is owned by the core and ships compiled-in;
    /// re-declaring it would silently shadow core built-ins.
    ReservedNamespace,
    /// Table form had both `tap` and `uri` set. They're mutually
    /// exclusive — pick one.
    TapAndUri,
    /// Table form had neither `tap` nor `uri` set.
    EmptyTable,
    /// Both the explicit `uri` (with a `#fragment`) and a `rev`
    /// field are set. Either is meaningful but together they're
    /// ambiguous — pick one.
    RevWithExplicitFragment { uri: String, rev: String },
}

impl std::fmt::Display for LabelsConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LabelsConfigError::Io { path, source } => {
                write!(f, "{}: io error reading labels config: {source}", path.display())
            }
            LabelsConfigError::Parse { path, message } => {
                write!(f, "{}: labels config parse error: {message}", path.display())
            }
            LabelsConfigError::ReservedNamespace => f.write_str(
                "namespace `lex` is reserved for core-defined labels and cannot be declared in [labels]",
            ),
            LabelsConfigError::TapAndUri => {
                f.write_str("namespace spec sets both `tap` and `uri`; they are mutually exclusive")
            }
            LabelsConfigError::EmptyTable => f.write_str(
                "namespace spec table needs one of `tap` or `uri` set",
            ),
            LabelsConfigError::RevWithExplicitFragment { uri, rev } => write!(
                f,
                "namespace spec sets both `rev = {rev:?}` and an explicit `#fragment` in uri `{uri}`; pick one"
            ),
        }
    }
}

impl std::error::Error for LabelsConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            LabelsConfigError::Io { source, .. } => Some(source),
            _ => None,
        }
    }
}

/// Load the `[labels]` block from a `.lex.toml` at `path`. Returns
/// an empty config if the file exists but has no `[labels]` block;
/// `Io::NotFound` is propagated to the caller (the CLI usually
/// treats it as "no labels configured" and continues).
///
/// Validates the reserved-key rule (`lex` is forbidden) and each
/// spec's table-form invariants. Bad config fails the load instead
/// of letting it surface at dispatch time.
pub fn load_labels_from_toml(path: impl AsRef<Path>) -> Result<LabelsConfig, LabelsConfigError> {
    let path = path.as_ref();
    let body = std::fs::read_to_string(path).map_err(|source| LabelsConfigError::Io {
        path: path.to_path_buf(),
        source,
    })?;

    // We only read the `[labels]` table; the rest of the file is
    // confique's territory. A `toml::Value` parse + manual lookup
    // keeps us from reaching for a separate top-level struct.
    let root: toml::Value =
        body.parse()
            .map_err(|err: toml::de::Error| LabelsConfigError::Parse {
                path: path.to_path_buf(),
                message: err.to_string(),
            })?;
    let Some(labels_value) = root.get("labels") else {
        return Ok(LabelsConfig::default());
    };
    let mut config: LabelsConfig =
        labels_value
            .clone()
            .try_into()
            .map_err(|err: toml::de::Error| LabelsConfigError::Parse {
                path: path.to_path_buf(),
                message: err.to_string(),
            })?;

    if config.namespaces.contains_key("lex") {
        return Err(LabelsConfigError::ReservedNamespace);
    }
    for spec in config.namespaces.values_mut() {
        if let NamespaceSpec::Table(t) = spec {
            t.validate()?;
        }
    }
    Ok(config)
}

/// Top-level configuration consumed by Lex applications.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct LexConfig {
    /// Formatting rules.
    #[config(nested)]
    pub formatting: FormattingConfig,
    /// Inspect output options.
    #[config(nested)]
    pub inspect: InspectConfig,
    /// Format-specific conversion options.
    #[config(nested)]
    pub convert: ConvertConfig,
    /// Diagnostics options.
    #[config(nested)]
    pub diagnostics: DiagnosticsConfig,
    /// Include-resolution options.
    #[config(nested)]
    pub includes: IncludesConfig,
    /// Extension-namespace declarations. The map shape is
    /// free-form (each key is a namespace name; the value is a
    /// `NamespaceSpec`), so the field is a leaf rather than a
    /// nested confique struct — confique sees an opaque
    /// `BTreeMap<String, NamespaceSpec>`. The `lexd labels`
    /// subcommand and the boot helper read individual entries via
    /// [`load_labels_from_toml`] for richer error messages
    /// (reserved-namespace check, table-form validation, …).
    /// Declaring the field here is what makes clapfig's strict
    /// unknown-keys check accept `[labels]` blocks in `.lex.toml`.
    #[config(default = {})]
    pub labels: BTreeMap<String, NamespaceSpec>,
}

/// Formatting-related configuration groups.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct FormattingConfig {
    /// Formatting rules for lex output.
    #[config(nested)]
    pub rules: FormattingRulesConfig,
    /// Automatically format documents on save (consumed by editors).
    #[config(default = false)]
    pub format_on_save: bool,
}

/// Mirrors the knobs exposed by the Lex formatter.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct FormattingRulesConfig {
    /// Number of blank lines inserted before a session title.
    #[config(default = 1)]
    pub session_blank_lines_before: usize,
    /// Number of blank lines inserted after a session title.
    #[config(default = 1)]
    pub session_blank_lines_after: usize,
    /// Normalize list markers to predictable markers.
    #[config(default = true)]
    pub normalize_seq_markers: bool,
    /// Character for unordered list items when normalization is enabled.
    #[config(default = "-")]
    pub unordered_seq_marker: char,
    /// Maximum consecutive blank lines kept in output.
    #[config(default = 2)]
    pub max_blank_lines: usize,
    /// Whitespace string for each indentation level.
    #[config(default = "    ")]
    pub indent_string: String,
    /// Preserve trailing blank lines at the end of a document.
    #[config(default = false)]
    pub preserve_trailing_blanks: bool,
    /// Normalize verbatim fences back to canonical :: form.
    #[config(default = true)]
    pub normalize_verbatim_markers: bool,
}

impl From<FormattingRulesConfig> for FormattingRules {
    fn from(config: FormattingRulesConfig) -> Self {
        FormattingRules {
            session_blank_lines_before: config.session_blank_lines_before,
            session_blank_lines_after: config.session_blank_lines_after,
            normalize_seq_markers: config.normalize_seq_markers,
            unordered_seq_marker: config.unordered_seq_marker,
            max_blank_lines: config.max_blank_lines,
            indent_string: config.indent_string,
            preserve_trailing_blanks: config.preserve_trailing_blanks,
            normalize_verbatim_markers: config.normalize_verbatim_markers,
        }
    }
}

impl From<&FormattingRulesConfig> for FormattingRules {
    fn from(config: &FormattingRulesConfig) -> Self {
        FormattingRules {
            session_blank_lines_before: config.session_blank_lines_before,
            session_blank_lines_after: config.session_blank_lines_after,
            normalize_seq_markers: config.normalize_seq_markers,
            unordered_seq_marker: config.unordered_seq_marker,
            max_blank_lines: config.max_blank_lines,
            indent_string: config.indent_string.clone(),
            preserve_trailing_blanks: config.preserve_trailing_blanks,
            normalize_verbatim_markers: config.normalize_verbatim_markers,
        }
    }
}

/// Controls AST-related inspect output.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct InspectConfig {
    /// AST visualization options.
    #[config(nested)]
    pub ast: InspectAstConfig,
    /// Nodemap visualization options.
    #[config(nested)]
    pub nodemap: NodemapConfig,
}

#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct InspectAstConfig {
    /// Include annotations, titles, markers, and other metadata in AST visualizations.
    #[config(default = false)]
    pub include_all_properties: bool,
    /// Show line numbers next to AST entries.
    #[config(default = true)]
    pub show_line_numbers: bool,
}

#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct NodemapConfig {
    /// Render ANSI-colored blocks instead of Base2048 glyphs.
    #[config(default = false)]
    pub color_blocks: bool,
    /// Render Base2048 glyphs but color them with ANSI codes.
    #[config(default = false)]
    pub color_characters: bool,
    /// Append high-level summary statistics under the node map output.
    #[config(default = false)]
    pub show_summary: bool,
}

/// Format-specific conversion knobs.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct ConvertConfig {
    /// PDF export options.
    #[config(nested)]
    pub pdf: PdfConfig,
    /// HTML export options.
    #[config(nested)]
    pub html: HtmlConfig,
}

#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct PdfConfig {
    /// Page profile used when exporting to PDF ("lexed" or "mobile").
    #[config(default = "lexed")]
    pub size: PdfPageSize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PdfPageSize {
    #[serde(rename = "lexed")]
    LexEd,
    #[serde(rename = "mobile")]
    Mobile,
}

#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct HtmlConfig {
    /// Theme for HTML export.
    #[config(default = "default")]
    pub theme: String,
    /// Optional path to a custom CSS file to append after the baseline CSS.
    pub custom_css: Option<String>,
}

/// Diagnostics options.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct DiagnosticsConfig {
    /// Enable spellcheck diagnostics.
    #[config(default = true)]
    pub spellcheck: bool,
}

/// Include-resolution options consumed by `lexd convert`, `lexd inspect`,
/// and the LSP. `lexd format` never expands includes regardless.
#[derive(Debug, Clone, Config, Serialize, Deserialize)]
pub struct IncludesConfig {
    /// Resolution root for include path resolution.
    ///
    /// All include paths — relative or root-absolute (`/...`) — must
    /// lexically normalize inside this directory. Outside-the-root paths
    /// fail with a `RootEscape` error. (The resolver does not call
    /// `std::fs::canonicalize`; symlink-aware canonicalization is the
    /// loader's responsibility, not the resolver's.)
    ///
    /// When `None` (default), the CLI discovers the root by walking up
    /// from the entry-point document to find the nearest `.lex.toml`,
    /// falling back to the entry-point's own directory.
    pub root: Option<String>,
    /// Maximum include depth. Default 8. Hitting the limit is an error,
    /// not a silent truncation.
    #[config(default = 8)]
    pub max_depth: usize,
    /// Maximum total include count across the document (DoS bound).
    /// Default 1000. Caps fan-out — `max_depth` alone bounds chain
    /// length but a doc with thousands of includes at depth 1 still
    /// blows past it.
    #[config(default = 1000)]
    pub max_total_includes: usize,
    /// Maximum size of any single included file in bytes (DoS bound).
    /// Default 10 MiB (10485760). Files larger than this are rejected
    /// before any bytes hit memory. Used by `FsLoader`; the in-memory
    /// `MemoryLoader` doesn't enforce a size limit.
    #[config(default = 10485760)]
    pub max_file_size: u64,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn load_defaults() -> LexConfig {
        clapfig::Clapfig::builder::<LexConfig>()
            .app_name("lex")
            .no_env()
            .search_paths(vec![])
            .load()
            .expect("defaults to load")
    }

    #[test]
    fn loads_default_config() {
        let config = load_defaults();
        assert_eq!(config.formatting.rules.session_blank_lines_before, 1);
        assert!(config.inspect.ast.show_line_numbers);
        assert_eq!(config.convert.pdf.size, PdfPageSize::LexEd);
    }

    #[test]
    fn labels_config_bare_uri_parses() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
foolco = "gitlab:foolco/lex-labels#main"
"#,
        )
        .unwrap();
        let labels = load_labels_from_toml(&path).expect("loads");
        let spec = labels.namespaces.get("foolco").unwrap();
        assert_eq!(
            spec.canonical_uri().unwrap(),
            "gitlab:foolco/lex-labels#main"
        );
    }

    #[test]
    fn labels_config_tap_shorthand_expands() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
acme = { tap = "acme" }
"#,
        )
        .unwrap();
        let labels = load_labels_from_toml(&path).unwrap();
        assert_eq!(
            labels
                .namespaces
                .get("acme")
                .unwrap()
                .canonical_uri()
                .unwrap(),
            "github:acme/lex-labels"
        );
    }

    #[test]
    fn labels_config_expanded_table_with_rev_and_subdir() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
custom = { uri = "github:org/repo", rev = "v1", subdir = "labels/" }
"#,
        )
        .unwrap();
        let labels = load_labels_from_toml(&path).unwrap();
        let uri = labels
            .namespaces
            .get("custom")
            .unwrap()
            .canonical_uri()
            .unwrap();
        assert!(uri.starts_with("github:org/repo"));
        assert!(uri.contains("v1"));
        assert!(uri.contains("subdir=labels/"));
    }

    #[test]
    fn labels_config_reserved_lex_namespace_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
lex = "github:fake/lex-labels"
"#,
        )
        .unwrap();
        let err = load_labels_from_toml(&path).unwrap_err();
        assert!(matches!(err, LabelsConfigError::ReservedNamespace));
    }

    #[test]
    fn labels_config_tap_and_uri_together_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
acme = { tap = "acme", uri = "github:other/repo" }
"#,
        )
        .unwrap();
        let err = load_labels_from_toml(&path).unwrap_err();
        assert!(matches!(err, LabelsConfigError::TapAndUri));
    }

    #[test]
    fn labels_config_empty_table_rejected() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(
            &path,
            r#"
[labels]
acme = { rev = "v1" }
"#,
        )
        .unwrap();
        let err = load_labels_from_toml(&path).unwrap_err();
        assert!(matches!(err, LabelsConfigError::EmptyTable));
    }

    #[test]
    fn labels_config_missing_block_yields_empty_config() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".lex.toml");
        std::fs::write(&path, "# no labels block\n").unwrap();
        let labels = load_labels_from_toml(&path).unwrap();
        assert!(labels.namespaces.is_empty());
    }

    #[test]
    fn formatting_rules_config_converts_to_formatting_rules() {
        let config = load_defaults();
        let rules: FormattingRules = config.formatting.rules.into();
        assert_eq!(rules.session_blank_lines_before, 1);
        assert_eq!(rules.session_blank_lines_after, 1);
        assert!(rules.normalize_seq_markers);
        assert_eq!(rules.unordered_seq_marker, '-');
        assert_eq!(rules.max_blank_lines, 2);
        assert_eq!(rules.indent_string, "    ");
        assert!(!rules.preserve_trailing_blanks);
        assert!(rules.normalize_verbatim_markers);
    }
}