rumdl 0.2.60

A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)
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
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
use indexmap::IndexSet;
use std::collections::BTreeMap;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};

use super::file_source::{ConfigFileSource, FsConfigFiles};
use super::flavor::ConfigLoaded;
use super::flavor::ConfigValidated;
use super::parsers;
use super::registry::RuleRegistry;
use super::source_tracking::{
    ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue,
};
use super::types::{
    Config, ConfigError, ConfigOrigin, DiscoveredConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES,
    RUMDL_CONFIG_FILES, RuleConfig, WITHHELD,
};
use super::validation::validate_config_sourced_internal;
use crate::utils::upward_walk::UpwardWalk;

/// Maximum depth for extends chains to prevent runaway recursion
const MAX_EXTENDS_DEPTH: usize = 10;

/// Cheap pre-filter for whether a `pyproject.toml` declares rumdl config.
///
/// Matches the flat section header `[tool.rumdl]` as well as dotted sections
/// like `[tool.rumdl.MD013]` or `[tool.rumdl.rules.MD007]` (which are valid on
/// their own, without a flat header). Requiring the leading `[` avoids matching
/// a bare `tool.rumdl` in prose or dependency names; a literal `[tool.rumdl...`
/// inside a comment or string would still match, but the subsequent parse
/// handles that gracefully.
fn pyproject_declares_rumdl_config(content: &str) -> bool {
    content.contains("[tool.rumdl]") || content.contains("[tool.rumdl.")
}

/// True if `b` may start a `$VAR` identifier (`[A-Za-z_]`).
fn is_var_name_start(b: u8) -> bool {
    b == b'_' || b.is_ascii_alphabetic()
}

/// True if `b` may continue a `$VAR` identifier (`[A-Za-z0-9_]`).
fn is_var_name_continue(b: u8) -> bool {
    b == b'_' || b.is_ascii_alphanumeric()
}

/// True if `name` is a non-empty valid environment-variable identifier.
fn is_valid_var_name(name: &str) -> bool {
    let bytes = name.as_bytes();
    !bytes.is_empty() && is_var_name_start(bytes[0]) && bytes[1..].iter().all(|&b| is_var_name_continue(b))
}

/// Expand `$VAR` and `${VAR}` references in `input` using `lookup`.
///
/// Grammar (frozen; documented in `docs/global-settings.md`):
/// - `$NAME` / `${NAME}` with `NAME = [A-Za-z_][A-Za-z0-9_]*` expands to the variable's
///   value; the longest valid identifier is matched (`$FOO_BAR` is one name).
/// - `$$` is a literal `$` (escape), so `$$VAR` -> `$VAR` and `$${VAR}` -> `${VAR}` (no
///   expansion of the escaped form).
/// - Any other `$` is left literal: `$` before a non-identifier char (`$5`, trailing `$`),
///   an empty `${}`, an unterminated `${VAR`, or a `${...}` whose body is not a valid
///   identifier (e.g. nested `${A${B}}`) - the whole `${...}` span up to the first `}` is
///   emitted literally.
/// - Replacement values are inserted literally and are NOT re-scanned (single left-to-right
///   pass): if `A="$B"`, then `$A` expands to the literal string `$B`.
///
/// Returns `Err(name)` on the first well-formed reference to an undefined variable. All
/// special characters (`$`, `{`, `}`, identifier chars) are ASCII, so byte scanning never
/// splits a multibyte UTF-8 sequence; non-ASCII bytes are copied verbatim as literals.
fn expand_env_vars(input: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, String> {
    let bytes = input.as_bytes();
    let mut out = String::with_capacity(input.len());
    let mut i = 0;

    while i < bytes.len() {
        if bytes[i] != b'$' {
            // Copy the maximal run of non-`$` bytes as a slice (preserves UTF-8).
            let start = i;
            while i < bytes.len() && bytes[i] != b'$' {
                i += 1;
            }
            out.push_str(&input[start..i]);
            continue;
        }

        match bytes.get(i + 1).copied() {
            // `$$` -> literal `$`.
            Some(b'$') => {
                out.push('$');
                i += 2;
            }
            // `${...}` braced form.
            Some(b'{') => {
                if let Some(rel) = input[i + 2..].find('}') {
                    let close = i + 2 + rel;
                    let name = &input[i + 2..close];
                    if is_valid_var_name(name) {
                        match lookup(name) {
                            Some(value) => out.push_str(&value),
                            None => return Err(name.to_string()),
                        }
                    } else {
                        // Empty / invalid / nested body -> whole `${...}` span is literal.
                        out.push_str(&input[i..=close]);
                    }
                    i = close + 1;
                } else {
                    // No closing `}` -> leave the `$` literal and resume at `{`.
                    out.push('$');
                    i += 1;
                }
            }
            // `$NAME` bare form.
            Some(b) if is_var_name_start(b) => {
                let start = i + 1;
                let mut j = start;
                while j < bytes.len() && is_var_name_continue(bytes[j]) {
                    j += 1;
                }
                let name = &input[start..j];
                match lookup(name) {
                    Some(value) => out.push_str(&value),
                    None => return Err(name.to_string()),
                }
                i = j;
            }
            // `$` before a non-identifier char or at end of input -> literal `$`.
            _ => {
                out.push('$');
                i += 1;
            }
        }
    }

    Ok(out)
}

/// An `extends` value as the user wrote it, so the file it reaches can be named
/// without disclosing what the value expanded to. See [`ConfigOrigin`] for why
/// that matters.
///
/// Nothing here needs the environment variables the value substitutes: the only
/// forms expanded are `$NAME` and `${NAME}`, both of which stand verbatim in the
/// written value, so naming the reference already names them.
struct ExtendsRef {
    /// The value exactly as written in the declaring config file, and `None`
    /// when that file was itself reached through `extends`. An `extends` value is
    /// a line of the file that wrote it, so a file whose unknown keys and invalid
    /// values are withheld does not get to have this one line quoted instead.
    written: Option<String>,
    /// The short name of the config file that declared this `extends`, so a
    /// chain of substituted paths never surfaces at any depth.
    from: String,
}

impl ExtendsRef {
    /// The reference as a message about the file it reaches should name it.
    fn describe(&self) -> String {
        format!("{} (referenced from {})", self.short(), self.from)
    }

    /// The reference alone, for a message that only has to say which file it
    /// means. See [`ConfigOrigin::short_name`].
    ///
    /// A withheld reference names nothing, so two of them in one chain read
    /// alike. What locates the problem is still there: the file that declared it,
    /// which whoever hit the error can open.
    fn short(&self) -> String {
        match &self.written {
            Some(written) => format!("'{written}'"),
            None => WITHHELD.to_string(),
        }
    }
}

/// The `extends` chain walked so far.
///
/// `visited` holds canonicalized paths, which is what cycle detection needs.
/// `names` holds how each file was reached, which is what a message about the
/// chain may show: a path in it can hold expanded environment variables.
#[derive(Default)]
struct ExtendsChain {
    visited: IndexSet<PathBuf>,
    names: Vec<String>,
}

impl ExtendsChain {
    fn contains(&self, canonical: &Path) -> bool {
        self.visited.contains(canonical)
    }

    fn len(&self) -> usize {
        self.visited.len()
    }

    fn push(&mut self, canonical: PathBuf, name: String) {
        self.visited.insert(canonical);
        self.names.push(name);
    }

    fn names(&self) -> Vec<String> {
        self.names.clone()
    }
}

/// Resolve an `extends` value against the config file that declares it, and
/// describe the reference for any message about the file it reaches.
///
/// - `$VAR` / `${VAR}`: expanded from the environment first (see [`expand_env_vars`])
/// - `~/` prefix: expanded to home directory
/// - Relative paths: resolved against the config file's parent directory
/// - Absolute paths: used as-is
///
/// `declared_by` is the origin of the file holding the value, which decides
/// whether the value may be quoted: it is that file's text like any other.
/// `source` supplies the environment and home directory the value is resolved
/// against.
fn resolve_extends(
    extends_value: &str,
    config_file_path: &Path,
    from: &str,
    declared_by: ConfigOrigin<'_>,
    source: &dyn ConfigFileSource,
) -> Result<(PathBuf, ExtendsRef), ConfigError> {
    let expanded = expand_env_vars(extends_value, |key| source.env_var(key)).map_err(|var| {
        // The variable name is written in the same value, so it is quotable
        // exactly when the value is. Withholding it where the value is withheld
        // also keeps a set and an unset variable from telling different amounts.
        ConfigError::ExtendsUndefinedVar {
            var: if declared_by.may_quote_contents() {
                format!("${var}")
            } else {
                WITHHELD.to_string()
            },
            from: from.to_string(),
        }
    })?;

    let reference = ExtendsRef {
        written: declared_by.may_quote_contents().then(|| extends_value.to_string()),
        from: from.to_string(),
    };

    Ok((
        resolve_expanded_extends_path(&expanded, config_file_path, source.home_dir().as_deref()),
        reference,
    ))
}

/// Turn an already-expanded `extends` value into a path. `home` is what `~/`
/// expands to; without one the prefix is kept literal.
fn resolve_expanded_extends_path(expanded: &str, config_file_path: &Path, home: Option<&Path>) -> PathBuf {
    if let Some(suffix) = expanded.strip_prefix("~/") {
        match home {
            Some(home) => home.join(suffix),
            None => PathBuf::from(expanded),
        }
    } else {
        let path = PathBuf::from(expanded);
        if path.is_absolute() {
            path
        } else {
            // Resolve relative to config file's directory
            let config_dir = config_file_path.parent().unwrap_or(Path::new("."));
            config_dir.join(expanded)
        }
    }
}

/// Determine ConfigSource from a config filename.
fn source_from_filename(filename: &str) -> ConfigSource {
    if filename == "pyproject.toml" {
        ConfigSource::PyprojectToml
    } else {
        ConfigSource::ProjectConfig
    }
}

/// The rumdl-native config files that actually exist in `dir`, in precedence order.
///
/// Walks `RUMDL_CONFIG_FILES` (the single source of truth for discovery) joined onto
/// `dir`, so `.config/rumdl.toml` is recognised at the same level as `.rumdl.toml`.
/// `pyproject.toml` counts only when it declares `[tool.rumdl]`. markdownlint configs
/// are intentionally excluded: they are a separate fallback tier, not a same-tool
/// collision, and projects routinely keep one around while migrating.
pub(crate) fn rumdl_configs_in_dir(dir: &Path) -> Vec<PathBuf> {
    RUMDL_CONFIG_FILES
        .iter()
        .map(|name| dir.join(name))
        .filter(|path| {
            if !path.exists() {
                return false;
            }
            if path.file_name().and_then(|n| n.to_str()) == Some("pyproject.toml") {
                std::fs::read_to_string(path).is_ok_and(|content| pyproject_declares_rumdl_config(&content))
            } else {
                true
            }
        })
        .collect()
}

/// Collect project configuration candidates nearest-first.
///
/// Rumdl-native files precede markdownlint files within each directory. The
/// walk includes `workspace_root`, when supplied, and excludes `home_dir`: a
/// config in the home directory is user configuration rather than project
/// configuration. Returning every candidate lets adapters recover from a
/// malformed higher-precedence file without reimplementing discovery policy.
pub(crate) fn collect_project_config_candidates(
    search_dir: &Path,
    workspace_root: Option<&Path>,
    home_dir: Option<&Path>,
) -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    let walk = UpwardWalk::new(search_dir).stop_below(home_dir.map(Path::to_path_buf));
    let walk = match workspace_root {
        Some(root) => walk.stop_at(root),
        None => walk,
    };

    for current_dir in walk {
        candidates.extend(rumdl_configs_in_dir(&current_dir));
        candidates.extend(
            MARKDOWNLINT_CONFIG_FILES
                .iter()
                .map(|name| current_dir.join(name))
                .filter(|path| path.exists()),
        );
    }

    candidates
}

/// A directory holding more than one rumdl-native config file.
///
/// `winner` is the file discovery uses (highest precedence); `shadowed` are the
/// silently-ignored siblings. Having both `.rumdl.toml` and `rumdl.toml` (or either
/// plus a `[tool.rumdl]` in `pyproject.toml`) in one directory is redundant by
/// construction and a common footgun: editing the shadowed file appears to do
/// nothing. Resolution is unchanged (the dot file still wins, matching Ruff); this
/// type only lets callers surface the collision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShadowedConfigs {
    pub dir: PathBuf,
    pub winner: PathBuf,
    pub shadowed: Vec<PathBuf>,
}

/// Detect rumdl-native config files that shadow each other in `dir`.
///
/// Returns `None` unless two or more rumdl-native configs coexist at this directory
/// level (markdownlint files and configs in other directories never count). The
/// highest-precedence file is the `winner`; the rest are silently `shadowed`.
pub(crate) fn detect_shadowed_configs(dir: &Path) -> Option<ShadowedConfigs> {
    let mut configs = rumdl_configs_in_dir(dir);
    if configs.len() < 2 {
        return None;
    }
    let winner = configs.remove(0);
    Some(ShadowedConfigs {
        dir: dir.to_path_buf(),
        winner,
        shadowed: configs,
    })
}

/// Format a shadowed-config collision as a single user-facing warning line.
///
/// The directory is named once; the winner and shadowed files are shown relative
/// to it (e.g. `.rumdl.toml`, `.config/rumdl.toml`) rather than repeating the full
/// directory in every path. Paths are normalized to forward slashes on Windows for
/// stable, copy-pasteable output; non-UTF-8 components degrade lossily rather than
/// panicking.
pub(crate) fn format_shadow_warning(shadow: &ShadowedConfigs) -> String {
    let norm = |s: String| if cfg!(windows) { s.replace('\\', "/") } else { s };
    let rel = |path: &Path| {
        let relative = path.strip_prefix(&shadow.dir).unwrap_or(path);
        norm(relative.to_string_lossy().into_owned())
    };
    let shadowed = shadow.shadowed.iter().map(|p| rel(p)).collect::<Vec<_>>().join(", ");
    format!(
        "multiple rumdl config files in {}: using {}, ignoring {}",
        norm(shadow.dir.to_string_lossy().into_owned()),
        rel(&shadow.winner),
        shadowed,
    )
}

/// Load a config file (and any base configs it extends) into a SourcedConfig.
///
/// This function handles the recursive `extends` chain:
/// 1. Parse the config file into a fragment
/// 2. If the fragment has `extends`, recursively load the base config first
/// 3. Merge the base config, then merge this fragment on top
///
/// `origin` says how this file was reached, and every message about it is
/// phrased through that: a file the recursion reached is named by the `extends`
/// value that names it, never by the path that value expanded to. The resolved
/// path is still recorded in [`SourcedConfig::loaded_files`], which is not a
/// message about the file but the answer to a question someone asked about the
/// configuration. See [`ConfigOrigin`].
fn load_config_with_extends(
    sourced_config: &mut SourcedConfig<ConfigLoaded>,
    config_file_path: &Path,
    chain: &mut ExtendsChain,
    chain_source: ConfigSource,
    origin: ConfigOrigin<'_>,
    source: &dyn ConfigFileSource,
) -> Result<(), ConfigError> {
    // Canonicalize the path for circular reference detection
    let canonical = source.canonicalize(config_file_path);

    let path_str = config_file_path.display().to_string();
    let described = origin.display_name(&path_str);
    let short = origin.short_name(&path_str);

    // Check for circular references
    if chain.contains(&canonical) {
        return Err(ConfigError::CircularExtends {
            path: described,
            chain: chain.names(),
        });
    }

    // Check depth limit
    if chain.len() >= MAX_EXTENDS_DEPTH {
        return Err(ConfigError::ExtendsDepthExceeded {
            path: described,
            max_depth: MAX_EXTENDS_DEPTH,
        });
    }

    // Mark as visited
    chain.push(canonical, short.clone());

    let filename = config_file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");

    // Read and parse the config file
    let content = source
        .read_to_string(config_file_path)
        .map_err(|e| ConfigError::IoError {
            source: e,
            path: described.clone(),
        })?;

    let fragment = if filename == "pyproject.toml" {
        match parsers::parse_pyproject_toml(&content, &path_str, chain_source, origin)? {
            Some(f) => f,
            None => return Ok(()), // No [tool.rumdl] section
        }
    } else {
        parsers::parse_rumdl_toml(&content, &path_str, chain_source, origin)?
    };

    // If this fragment has `extends`, load the base config first
    if let Some(ref extends_value) = fragment.extends {
        let (base_path, reference) = resolve_extends(extends_value, config_file_path, &short, origin, source)?;
        let base_described = reference.describe();
        let base_short = reference.short();

        if !source.exists(&base_path) {
            return Err(ConfigError::ExtendsNotFound {
                path: base_short,
                from: short,
            });
        }

        log::debug!(
            "[rumdl-config] Config {} extends {}, loading base first",
            path_str,
            base_path.display()
        );

        // Recursively load the base config
        load_config_with_extends(
            sourced_config,
            &base_path,
            chain,
            chain_source,
            ConfigOrigin::Extends {
                described_as: &base_described,
                short_name: &base_short,
            },
            source,
        )?;
    }

    // Merge this fragment on top (base config was already merged if present)
    // Strip the `extends` field since it's been consumed
    let mut fragment_for_merge = fragment;
    fragment_for_merge.extends = None;
    sourced_config.merge(fragment_for_merge);
    sourced_config.loaded_files.push(path_str);

    Ok(())
}

impl SourcedConfig<ConfigLoaded> {
    /// Merges another SourcedConfigFragment into this SourcedConfig.
    /// Uses source precedence to determine which values take effect.
    pub(super) fn merge(&mut self, fragment: SourcedConfigFragment) {
        // Merge global config. Enable/disable use replace semantics (child
        // config overrides parent, matching Ruff's `select`/`ignore`);
        // extend-enable/extend-disable use union semantics (additive across
        // config levels).
        self.global.enable.merge_from(fragment.global.enable);
        self.global.disable.merge_from(fragment.global.disable);
        self.global
            .extend_enable
            .merge_union_from(fragment.global.extend_enable);
        self.global
            .extend_disable
            .merge_union_from(fragment.global.extend_disable);

        // Conflict resolution: Enable overrides disable
        // Remove any rules from disable that appear in enable
        self.global
            .disable
            .value
            .retain(|rule| !self.global.enable.value.contains(rule));

        // Whether a message about the include patterns may quote them travels with
        // the patterns: the file supplying the winning list supplies what may be
        // said about it, in either direction.
        if self.global.include.merge_from(fragment.global.include) {
            self.global.include_withheld = fragment.global.include_withheld;
        }
        self.global.exclude.merge_from(fragment.global.exclude);
        self.global
            .respect_gitignore
            .merge_from(fragment.global.respect_gitignore);
        self.global.line_length.merge_from(fragment.global.line_length);
        self.global.fixable.merge_from(fragment.global.fixable);
        self.global.unfixable.merge_from(fragment.global.unfixable);
        self.global.flavor.merge_from(fragment.global.flavor);
        self.global.force_exclude.merge_from(fragment.global.force_exclude);
        self.global.editorconfig.merge_from(fragment.global.editorconfig);

        // Merge output_format if present
        if let Some(output_format_fragment) = fragment.global.output_format {
            if let Some(ref mut output_format) = self.global.output_format {
                output_format.merge_from(output_format_fragment);
            } else {
                self.global.output_format = Some(output_format_fragment);
            }
        }

        // Merge cache_dir if present
        if let Some(cache_dir_fragment) = fragment.global.cache_dir {
            if let Some(ref mut cache_dir) = self.global.cache_dir {
                cache_dir.merge_from(cache_dir_fragment);
            } else {
                self.global.cache_dir = Some(cache_dir_fragment);
            }
        }

        // Merge cache if not default (only override when explicitly set)
        if fragment.global.cache.source != ConfigSource::Default {
            self.global.cache.merge_from(fragment.global.cache);
        }

        self.per_file_ignores.merge_from(fragment.per_file_ignores);
        self.per_file_flavor.merge_from(fragment.per_file_flavor);
        self.code_block_tools.merge_from(fragment.code_block_tools);

        // Merge rule configs
        for (rule_name, rule_fragment) in fragment.rules {
            let norm_rule_name = rule_name.to_ascii_uppercase(); // Normalize to uppercase for case-insensitivity
            let rule_entry = self.rules.entry(norm_rule_name).or_default();

            // Merge severity if present in fragment
            if let Some(severity_fragment) = rule_fragment.severity {
                if let Some(ref mut existing_severity) = rule_entry.severity {
                    existing_severity.merge_from(severity_fragment);
                } else {
                    rule_entry.severity = Some(severity_fragment);
                }
            }

            // Merge values. Whether a value may be quoted back travels with the
            // value: a file that takes a key over also takes over what may be
            // said about it, in either direction.
            for (key, sourced_value_fragment) in rule_fragment.values {
                let sv_entry = rule_entry
                    .values
                    .entry(key.clone())
                    .or_insert_with(|| SourcedValue::new(sourced_value_fragment.value.clone(), ConfigSource::Default));
                if sv_entry.merge_from(sourced_value_fragment) {
                    if rule_fragment.withheld_keys.contains(&key) {
                        rule_entry.withheld_keys.insert(key);
                    } else {
                        rule_entry.withheld_keys.remove(&key);
                    }
                }
            }
        }

        // Merge unknown_keys from fragment
        // A file reached twice through two `extends` chains has the same problem
        // both times; the user wants to hear about it once.
        for warning in fragment.load_warnings {
            if !self.discovery_warnings.contains(&warning) {
                self.discovery_warnings.push(warning);
            }
        }

        for (section, key, file_path) in fragment.unknown_keys {
            // Deduplicate: only add if not already present
            if !self.unknown_keys.iter().any(|(s, k, _)| s == &section && k == &key) {
                self.unknown_keys.push((section, key, file_path));
            }
        }
    }

    /// Load and merge configurations from files and CLI overrides.
    pub fn load(config_path: Option<&str>, cli_overrides: Option<&SourcedGlobalConfig>) -> Result<Self, ConfigError> {
        Self::load_with_discovery(config_path, cli_overrides, false)
    }

    /// Finds project root by walking up from start_dir looking for .git directory.
    /// Falls back to start_dir if no .git found.
    fn find_project_root_from(start_dir: &Path) -> std::path::PathBuf {
        UpwardWalk::new(start_dir)
            .find(|dir| dir.join(".git").exists())
            .unwrap_or_else(|| {
                log::debug!(
                    "[rumdl-config] No .git found, using config location as project root: {}",
                    start_dir.display()
                );
                start_dir.to_path_buf()
            })
    }

    /// Resolve the home-directory boundary used to stop project-config discovery.
    ///
    /// `home_override` wins (supplied by tests); otherwise the real home is resolved on
    /// native builds via `etcetera`. Wasm has no home/project walk to bound, so it
    /// returns `None` there.
    fn resolve_home_boundary(home_override: Option<&Path>) -> Option<std::path::PathBuf> {
        home_override.map(Path::to_path_buf).or_else(|| {
            #[cfg(feature = "native")]
            {
                use etcetera::{BaseStrategy, choose_base_strategy};
                choose_base_strategy().ok().map(|s| s.home_dir().to_path_buf())
            }
            #[cfg(not(feature = "native"))]
            {
                None
            }
        })
    }

    /// Where an upward config walk begins.
    ///
    /// `start_override` is the directory a caller chose as its scope; the CLI has
    /// none and uses the process working directory, the directory the user typed
    /// the command in.
    fn resolve_discovery_start(start_override: Option<&Path>) -> Option<std::path::PathBuf> {
        if let Some(dir) = start_override {
            return Some(dir.to_path_buf());
        }
        match std::env::current_dir() {
            Ok(dir) => Some(dir),
            Err(e) => {
                log::debug!("[rumdl-config] Failed to get current directory: {e}");
                None
            }
        }
    }

    /// Discover configuration file by traversing up the directory tree.
    /// Returns the first configuration file found.
    /// Discovers config file and returns both the config path and project root.
    /// Returns: (config_file_path, project_root_path)
    /// Project root is the directory containing .git, or config parent as fallback.
    ///
    /// The walk stops at the home directory: a config file located in `$HOME`
    /// itself is user-level, not a project config, and must reach the loader only
    /// through the user-config fallback (`load_user_config`) so the platform
    /// user-config directory keeps precedence over `~/.rumdl.toml`. The start
    /// directory is exempt from that boundary: it is an explicitly chosen project
    /// context, so its configs apply even when it *is* `$HOME` (pre-commit.ci sets
    /// `HOME` to the git checkout, and `pyproject.toml` has no user-config
    /// fallback). `home_override` supplies the boundary for tests; production
    /// resolves the real home directory.
    fn discover_config_upward(
        start_override: Option<&Path>,
        home_override: Option<&Path>,
    ) -> Option<(std::path::PathBuf, std::path::PathBuf, Option<ShadowedConfigs>)> {
        let start_dir = Self::resolve_discovery_start(start_override)?;

        // `rumdl_configs_in_dir` is the single source of truth for "which rumdl
        // configs live here", shared with the LSP and the shadow detector, so the
        // winner and the silently-shadowed siblings are computed identically.
        let (config_path, config_dir, shadow) = UpwardWalk::new(&start_dir)
            .stop_below(Self::resolve_home_boundary(home_override))
            .always_yield_start()
            .stop_at_git_root()
            .find_map(|dir| {
                rumdl_configs_in_dir(&dir).into_iter().next().map(|winner| {
                    log::debug!("[rumdl-config] Found config file: {}", winner.display());
                    let shadow = detect_shadowed_configs(&dir);
                    (winner, dir, shadow)
                })
            })?;

        // Determine project root by walking up from the config location.
        let project_root = Self::find_project_root_from(&config_dir);
        Some((config_path, project_root, shadow))
    }

    /// Discover markdownlint configuration file by traversing up the directory tree.
    /// Similar to discover_config_upward but for .markdownlint.yaml/json files, and
    /// bounded at the home directory for the same reason: a markdownlint config in
    /// `$HOME` is user-level, not a project config. The start directory is exempt
    /// from the boundary just like rumdl config discovery, and markdownlint files
    /// have no user-config fallback at all, so without the exemption a config in a
    /// checkout that is itself `$HOME` would be ignored entirely.
    fn discover_markdownlint_config_upward(
        start_override: Option<&Path>,
        home_override: Option<&Path>,
    ) -> Option<std::path::PathBuf> {
        let start_dir = Self::resolve_discovery_start(start_override)?;

        UpwardWalk::new(&start_dir)
            .stop_below(Self::resolve_home_boundary(home_override))
            .always_yield_start()
            .stop_at_git_root()
            .find_map(|dir| {
                MARKDOWNLINT_CONFIG_FILES
                    .iter()
                    .map(|name| dir.join(name))
                    .find(|path| path.exists())
            })
    }

    /// Internal implementation that accepts config directory for testing
    fn user_configuration_path_impl(config_dir: &Path) -> Option<std::path::PathBuf> {
        let config_dir = config_dir.join("rumdl");

        // Check for config files in precedence order (same as project discovery)
        const USER_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml"];

        log::debug!(
            "[rumdl-config] Checking for user configuration in: {}",
            config_dir.display()
        );

        for filename in USER_CONFIG_FILES {
            let config_path = config_dir.join(filename);

            if config_path.exists() {
                // For pyproject.toml, verify it contains [tool.rumdl] section
                if *filename == "pyproject.toml" {
                    if let Ok(content) = std::fs::read_to_string(&config_path) {
                        if pyproject_declares_rumdl_config(&content) {
                            log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
                            return Some(config_path);
                        }
                        log::debug!("[rumdl-config] Found user pyproject.toml but no [tool.rumdl] section");
                        continue;
                    }
                } else {
                    log::debug!("[rumdl-config] Found user configuration at: {}", config_path.display());
                    return Some(config_path);
                }
            }
        }

        log::debug!(
            "[rumdl-config] No user configuration found in: {}",
            config_dir.display()
        );
        None
    }

    /// Discover user-level configuration file from platform-specific config directory.
    /// Returns the first configuration file found in the user config directory.
    #[cfg(feature = "native")]
    fn user_configuration_path() -> Option<std::path::PathBuf> {
        use etcetera::{BaseStrategy, choose_base_strategy};

        match choose_base_strategy() {
            Ok(strategy) => {
                let config_dir = strategy.config_dir();
                Self::user_configuration_path_impl(&config_dir)
            }
            Err(e) => {
                log::debug!("[rumdl-config] Failed to determine user config directory: {e}");
                None
            }
        }
    }

    /// Stub for WASM builds - user config not supported
    #[cfg(not(feature = "native"))]
    fn user_configuration_path() -> Option<std::path::PathBuf> {
        None
    }

    /// Internal implementation that accepts the home directory for testing.
    ///
    /// Probes `<home>/.rumdl.toml` then `<home>/rumdl.toml`, returning the first match.
    ///
    /// `pyproject.toml` is intentionally **not** searched in `$HOME`, even though
    /// `user_configuration_path_impl` does check it inside the platform config dir.
    /// The asymmetry is deliberate: a `pyproject.toml` directly in `$HOME` almost
    /// always belongs to unrelated python tooling (poetry/uv/pip's user-level config),
    /// and silently picking it up as a rumdl config would surprise users. The
    /// platform config dir (`~/.config/rumdl/`) is rumdl-scoped, so the same
    /// concern doesn't apply there.
    fn home_configuration_path_impl(home_dir: &Path) -> Option<std::path::PathBuf> {
        const HOME_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml"];

        log::debug!(
            "[rumdl-config] Checking for home-directory configuration in: {}",
            home_dir.display()
        );

        for filename in HOME_CONFIG_FILES {
            let config_path = home_dir.join(filename);
            if config_path.exists() {
                log::debug!(
                    "[rumdl-config] Found home-directory configuration at: {}",
                    config_path.display()
                );
                return Some(config_path);
            }
        }

        log::debug!(
            "[rumdl-config] No home-directory configuration found in: {}",
            home_dir.display()
        );
        None
    }

    /// Discover a home-directory configuration file (`~/.rumdl.toml` or `~/rumdl.toml`).
    ///
    /// This is a final fallback after the platform user-config directory
    /// (`user_configuration_path`). It honors the classic Unix dotfile convention so
    /// users who keep tool config in `$HOME` rather than `$XDG_CONFIG_HOME` are picked up.
    #[cfg(feature = "native")]
    fn home_configuration_path() -> Option<std::path::PathBuf> {
        use etcetera::{BaseStrategy, choose_base_strategy};

        match choose_base_strategy() {
            Ok(strategy) => Self::home_configuration_path_impl(strategy.home_dir()),
            Err(e) => {
                log::debug!("[rumdl-config] Failed to determine home directory: {e}");
                None
            }
        }
    }

    /// Stub for WASM builds - home config not supported
    #[cfg(not(feature = "native"))]
    fn home_configuration_path() -> Option<std::path::PathBuf> {
        None
    }

    /// Load an explicit config file (standalone, no user config merging)
    fn load_explicit_config(sourced_config: &mut Self, path: &str) -> Result<(), ConfigError> {
        let path_obj = Path::new(path);
        let filename = path_obj.file_name().and_then(|name| name.to_str()).unwrap_or("");
        let path_str = path.to_string();

        log::debug!("[rumdl-config] Loading explicit config file: {filename}");

        // Find project root by walking up from config location looking for .git
        if let Some(config_parent) = path_obj.parent() {
            let project_root = Self::find_project_root_from(config_parent);
            log::debug!(
                "[rumdl-config] Project root (from explicit config): {}",
                project_root.display()
            );
            sourced_config.project_root = Some(project_root);
        }

        // Known markdownlint config files
        const MARKDOWNLINT_FILENAMES: &[&str] = &[
            ".markdownlint-cli2.jsonc",
            ".markdownlint-cli2.yaml",
            ".markdownlint-cli2.yml",
            ".markdownlint.json",
            ".markdownlint.yaml",
            ".markdownlint.yml",
        ];

        if filename == "pyproject.toml" || filename == ".rumdl.toml" || filename == "rumdl.toml" {
            // Use extends-aware loading for rumdl TOML configs
            let mut chain = ExtendsChain::default();
            let chain_source = source_from_filename(filename);
            load_config_with_extends(
                sourced_config,
                path_obj,
                &mut chain,
                chain_source,
                ConfigOrigin::Direct,
                &FsConfigFiles,
            )?;
        } else if MARKDOWNLINT_FILENAMES.contains(&filename)
            || path_str.ends_with(".json")
            || path_str.ends_with(".jsonc")
            || path_str.ends_with(".yaml")
            || path_str.ends_with(".yml")
        {
            // Parse as markdownlint config (JSON/YAML) - no extends support
            let fragment = parsers::load_from_markdownlint(&path_str)?;
            sourced_config.merge(fragment);
            sourced_config.loaded_files.push(path_str);
        } else {
            // Try TOML with extends support
            let mut chain = ExtendsChain::default();
            let chain_source = source_from_filename(filename);
            load_config_with_extends(
                sourced_config,
                path_obj,
                &mut chain,
                chain_source,
                ConfigOrigin::Direct,
                &FsConfigFiles,
            )?;
        }

        Ok(())
    }

    /// Load and merge user-level configuration into this `SourcedConfig`.
    ///
    /// Discovers the user config file in this order, taking the first match:
    /// 1. Platform user-config directory, resolved via `etcetera::choose_base_strategy`
    ///    (the CLI/XDG convention): `~/.config` on Linux and macOS, `%APPDATA%` on
    ///    Windows. Note macOS uses the XDG-style `~/.config`, not the GUI-app location
    ///    `~/Library/Application Support`. Override with `user_config_dir` for tests.
    /// 2. Home-directory dotfile (`~/.rumdl.toml`, then `~/rumdl.toml`). Override with
    ///    `home_dir` for tests. Honors the classic Unix dotfile convention.
    ///
    /// Resolves any `extends` chain and merges each fragment with
    /// `ConfigSource::UserConfig` precedence.
    ///
    /// Called in two contexts:
    /// - When no project config is found: provides user defaults as the sole base
    /// - When a markdownlint project config is found: provides rumdl-specific
    ///   defaults that the markdownlint format cannot express; the markdownlint
    ///   fragment is merged on top and wins on any overlapping key
    fn load_user_config(
        sourced_config: &mut Self,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<(), ConfigError> {
        let user_config_path = if let Some(dir) = user_config_dir {
            Self::user_configuration_path_impl(dir)
        } else {
            Self::user_configuration_path()
        };

        let user_config_path = user_config_path.or_else(|| match home_dir {
            Some(home) => Self::home_configuration_path_impl(home),
            None => Self::home_configuration_path(),
        });

        if let Some(user_config_path) = user_config_path {
            let path_str = user_config_path.display().to_string();

            log::debug!("[rumdl-config] Loading user config: {path_str}");

            // User config fallback also supports extends chains.
            // Use a uniform source across the chain so child overrides are determined by chain order.
            let mut chain = ExtendsChain::default();
            load_config_with_extends(
                sourced_config,
                &user_config_path,
                &mut chain,
                ConfigSource::UserConfig,
                ConfigOrigin::Direct,
                &FsConfigFiles,
            )?;
        } else {
            log::debug!("[rumdl-config] No user configuration file found");
        }

        Ok(())
    }

    /// Load a project config file that discovery found, as opposed to one the user
    /// named explicitly.
    ///
    /// The two are not interchangeable. An explicit config is standalone by design
    /// (`load_explicit_config`), and so is a discovered rumdl-native config: a
    /// project's ruleset has to be reproducible on any machine. A discovered
    /// *markdownlint* config is the exception. That format cannot express
    /// rumdl-specific settings (flavor, cache, per-file ignores), so the user config
    /// is loaded first as a base and the markdownlint fragment merged on top. The
    /// fragment carries `ConfigSource::ProjectConfig` (precedence 3) against the
    /// base's `ConfigSource::UserConfig` (1), so project settings still win on every
    /// overlapping key.
    ///
    /// Both the CLI (`load_with_discovery_impl`) and the LSP (`load_discovered`,
    /// via `RumdlLanguageServer::resolve_config_for_file`) load discovered files
    /// through here, so a discovered config resolves the same way in an editor as
    /// it does on the command line.
    fn load_discovered_config(
        sourced_config: &mut Self,
        config_file: &Path,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<(), DiscoveredConfigError> {
        let filename = config_file.file_name().and_then(|name| name.to_str()).unwrap_or("");

        if MARKDOWNLINT_CONFIG_FILES.contains(&filename) {
            Self::load_user_config(sourced_config, user_config_dir, home_dir)
                .map_err(DiscoveredConfigError::UserConfig)?;

            let path_str = config_file.display().to_string();
            let fragment = parsers::load_from_markdownlint(&path_str).map_err(DiscoveredConfigError::ProjectConfig)?;
            sourced_config.merge(fragment);
            sourced_config.loaded_files.push(path_str);
        } else {
            let mut chain = ExtendsChain::default();
            let chain_source = source_from_filename(filename);
            load_config_with_extends(
                sourced_config,
                config_file,
                &mut chain,
                chain_source,
                ConfigOrigin::Direct,
                &FsConfigFiles,
            )
            .map_err(DiscoveredConfigError::ProjectConfig)?;
        }

        Ok(())
    }

    /// Load a config file that the caller discovered by walking the tree itself.
    ///
    /// The LSP cannot use `load_with_discovery`: that walk starts at the process
    /// working directory, while the server resolves a config per document and stops
    /// at the workspace root. It finds the file with its own walk and hands it here,
    /// so the discovered-config rules in `load_discovered_config` still apply.
    ///
    /// `project_root` comes from the config file's own location, which is what
    /// per-file ignore globs are matched against.
    ///
    /// `user_config_dir` and `home_dir` override the platform user-config directory
    /// and the home directory; the server passes the home directory it already
    /// resolved for its walk boundary, and tests pass both.
    ///
    /// The error distinguishes an unusable discovered file from an unusable user
    /// config so a caller walking several candidates can tell "try the next one"
    /// from "nothing here will resolve correctly".
    pub fn load_discovered(
        config_file: &Path,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<Self, DiscoveredConfigError> {
        let mut sourced_config = SourcedConfig::default();

        if let Some(config_parent) = config_file.parent() {
            sourced_config.project_root = Some(Self::find_project_root_from(config_parent));
        }

        Self::load_discovered_config(&mut sourced_config, config_file, user_config_dir, home_dir)?;

        Ok(sourced_config)
    }

    /// Load the configuration that applies to a directory, as if the CLI had run there.
    ///
    /// Discovery normally walks up from the process working directory, which is the
    /// scope the user chose when they typed `rumdl check`. A language server has no
    /// such directory: the editor launches it from wherever it happens to be, which
    /// may sit in an unrelated project. The workspace root is the scope the user
    /// chose, so the server passes that here and resolves what `rumdl check` would
    /// resolve inside it.
    ///
    /// `user_config_dir` and `home_dir` override the platform user-config directory
    /// and the home-directory walk boundary; the server passes the home directory it
    /// already resolved for its per-file walk, and tests pass both.
    pub fn load_for_workspace(
        start_dir: &Path,
        config_path: Option<&str>,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<Self, ConfigError> {
        Self::load_with_discovery_from(Some(start_dir), config_path, None, false, user_config_dir, home_dir)
    }

    /// Internal implementation that accepts user config directory and home directory for testing
    #[doc(hidden)]
    pub fn load_with_discovery_impl(
        config_path: Option<&str>,
        cli_overrides: Option<&SourcedGlobalConfig>,
        skip_auto_discovery: bool,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<Self, ConfigError> {
        Self::load_with_discovery_from(
            None,
            config_path,
            cli_overrides,
            skip_auto_discovery,
            user_config_dir,
            home_dir,
        )
    }

    /// Shared body of every discovery-based load.
    ///
    /// `start_dir` is where the upward walk begins; `None` means the process
    /// working directory, which is what the CLI wants.
    fn load_with_discovery_from(
        start_dir: Option<&Path>,
        config_path: Option<&str>,
        cli_overrides: Option<&SourcedGlobalConfig>,
        skip_auto_discovery: bool,
        user_config_dir: Option<&Path>,
        home_dir: Option<&Path>,
    ) -> Result<Self, ConfigError> {
        use std::env;
        log::debug!("[rumdl-config] Current working directory: {:?}", env::current_dir());

        let mut sourced_config = SourcedConfig::default();

        // Ruff model: Project config is standalone, user config is fallback only
        //
        // Priority order:
        // 1. If explicit config path provided → use ONLY that (standalone)
        // 2. Else if project config discovered → use ONLY that (standalone)
        // 3. Else if user config exists → use it as fallback
        // 4. CLI overrides always apply last
        //
        // This ensures project configs are reproducible across machines and
        // CI/local runs behave identically.

        // Explicit config path always takes precedence
        if let Some(path) = config_path {
            // Explicit config path provided - use ONLY this config (standalone)
            log::debug!("[rumdl-config] Explicit config_path provided: {path:?}");
            Self::load_explicit_config(&mut sourced_config, path)?;
        } else if skip_auto_discovery {
            log::debug!("[rumdl-config] Skipping config discovery due to --no-config/--isolated flag");
            // No config loading, just apply CLI overrides at the end
        } else {
            // No explicit path - try auto-discovery
            log::debug!("[rumdl-config] No explicit config_path, searching default locations");

            // Try to discover project config first
            if let Some((config_file, project_root, shadow)) = Self::discover_config_upward(start_dir, home_dir) {
                // Project config found - use ONLY this (standalone, no user config).
                // Rumdl project configs can express all settings directly, so user config
                // is not needed and omitting it ensures CI and local runs are identical.
                log::debug!("[rumdl-config] Found project config: {}", config_file.display());
                log::debug!("[rumdl-config] Project root: {}", project_root.display());

                // Record any same-directory sibling configs that are silently shadowed,
                // so the CLI and LSP can warn the user. Resolution is unchanged.
                if let Some(shadow) = shadow {
                    sourced_config.discovery_warnings.push(format_shadow_warning(&shadow));
                }

                sourced_config.project_root = Some(project_root);

                Self::load_discovered_config(&mut sourced_config, &config_file, user_config_dir, home_dir)?;
            } else {
                // No rumdl project config - try markdownlint config
                log::debug!("[rumdl-config] No rumdl config found, checking markdownlint config");

                if let Some(markdownlint_path) = Self::discover_markdownlint_config_upward(start_dir, home_dir) {
                    log::debug!(
                        "[rumdl-config] Found markdownlint config: {}",
                        markdownlint_path.display()
                    );

                    if let Err(e) =
                        Self::load_discovered_config(&mut sourced_config, &markdownlint_path, user_config_dir, home_dir)
                    {
                        match e {
                            // A markdownlint file rumdl cannot parse is skipped rather
                            // than fatal: the user never named it, and rumdl only reads
                            // the format as a courtesy. The user config it would have
                            // merged onto is already loaded, which is the state of the
                            // no-project-config case.
                            DiscoveredConfigError::ProjectConfig(e) => {
                                log::debug!("[rumdl-config] Failed to load markdownlint config: {e}");
                            }
                            // A broken user config is fatal, as in every other arm.
                            DiscoveredConfigError::UserConfig(e) => return Err(e),
                        }
                    }
                } else {
                    // No project config at all - use user config as fallback
                    log::debug!("[rumdl-config] No project config found, using user config as fallback");
                    Self::load_user_config(&mut sourced_config, user_config_dir, home_dir)?;
                }
            }
        }

        // Apply CLI overrides (highest precedence)
        if let Some(cli) = cli_overrides {
            sourced_config
                .global
                .enable
                .merge_override(cli.enable.value.clone(), ConfigSource::Cli, None);
            sourced_config
                .global
                .disable
                .merge_override(cli.disable.value.clone(), ConfigSource::Cli, None);
            sourced_config
                .global
                .exclude
                .merge_override(cli.exclude.value.clone(), ConfigSource::Cli, None);
            sourced_config
                .global
                .include
                .merge_override(cli.include.value.clone(), ConfigSource::Cli, None);
            sourced_config.global.respect_gitignore.merge_override(
                cli.respect_gitignore.value,
                ConfigSource::Cli,
                None,
            );
            sourced_config
                .global
                .fixable
                .merge_override(cli.fixable.value.clone(), ConfigSource::Cli, None);
            sourced_config
                .global
                .unfixable
                .merge_override(cli.unfixable.value.clone(), ConfigSource::Cli, None);
            // No rule-specific CLI overrides implemented yet
        }

        // Unknown keys are now collected during parsing and validated via validate_config_sourced()

        Ok(sourced_config)
    }

    /// Load and merge configurations from files and CLI overrides.
    /// If skip_auto_discovery is true, only explicit config paths are loaded.
    pub fn load_with_discovery(
        config_path: Option<&str>,
        cli_overrides: Option<&SourcedGlobalConfig>,
        skip_auto_discovery: bool,
    ) -> Result<Self, ConfigError> {
        Self::load_with_discovery_impl(config_path, cli_overrides, skip_auto_discovery, None, None)
    }

    /// Validate the configuration against a rule registry.
    ///
    /// This method transitions the config from `ConfigLoaded` to `ConfigValidated` state,
    /// enabling conversion to `Config`. Validation warnings are stored in the config
    /// and can be displayed to the user.
    ///
    /// # Example
    ///
    /// ```ignore
    /// let loaded = SourcedConfig::load_with_discovery(path, None, false)?;
    /// let validated = loaded.validate(&registry)?;
    /// let config: Config = validated.into();
    /// ```
    pub fn validate(self, registry: &RuleRegistry) -> Result<SourcedConfig<ConfigValidated>, ConfigError> {
        let warnings = validate_config_sourced_internal(&self, registry);

        Ok(SourcedConfig {
            global: self.global,
            per_file_ignores: self.per_file_ignores,
            per_file_flavor: self.per_file_flavor,
            code_block_tools: self.code_block_tools,
            rules: self.rules,
            loaded_files: self.loaded_files,
            unknown_keys: self.unknown_keys,
            project_root: self.project_root,
            discovery_warnings: self.discovery_warnings,
            validation_warnings: warnings,
            _state: PhantomData,
        })
    }

    /// Validate and convert to Config in one step (convenience method).
    ///
    /// This combines `validate()` and `into()` for callers who want the
    /// validation warnings separately.
    pub fn validate_into(self, registry: &RuleRegistry) -> Result<(Config, Vec<ConfigValidationWarning>), ConfigError> {
        let validated = self.validate(registry)?;
        let warnings = validated.validation_warnings.clone();
        Ok((validated.into(), warnings))
    }

    /// Skip validation and convert directly to ConfigValidated state.
    ///
    /// # Safety
    ///
    /// This method bypasses validation. Use only when:
    /// - You've already validated via `validate_config_sourced()`
    /// - You're in test code that doesn't need validation
    /// - You're migrating legacy code and will add proper validation later
    ///
    /// Prefer `validate()` for new code.
    pub fn into_validated_unchecked(self) -> SourcedConfig<ConfigValidated> {
        SourcedConfig {
            global: self.global,
            per_file_ignores: self.per_file_ignores,
            per_file_flavor: self.per_file_flavor,
            code_block_tools: self.code_block_tools,
            rules: self.rules,
            loaded_files: self.loaded_files,
            unknown_keys: self.unknown_keys,
            project_root: self.project_root,
            discovery_warnings: self.discovery_warnings,
            validation_warnings: Vec::new(),
            _state: PhantomData,
        }
    }

    /// Discover the nearest config file for a specific directory,
    /// walking upward to `project_root` (inclusive).
    ///
    /// Searches for rumdl config files (`.rumdl.toml`, `rumdl.toml`,
    /// `.config/rumdl.toml`, `pyproject.toml` with `[tool.rumdl]`) and
    /// markdownlint config files at each directory level.
    ///
    /// Returns the config file path if found. Does NOT use CWD.
    pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> {
        // The walk never canonicalizes the directories it yields (symlinks and
        // Windows short names stay as the caller wrote them); only stop checks
        // compare canonically. A relative `dir` is resolved against the current
        // directory, so the returned config path is always absolute. UpwardWalk
        // also retains the shared traversal depth bound.
        //
        // The home boundary keeps the walk from treating `~/.rumdl.toml` as a
        // project config, consistent with `discover_config_upward`. This only has
        // an effect when `project_root` is at or above the home directory (e.g. a
        // multi-path run whose grouping root spans the home boundary); for the
        // usual project root below home the walk stops there first.
        collect_project_config_candidates(dir, Some(project_root), Self::resolve_home_boundary(None).as_deref())
            .into_iter()
            .next()
    }

    /// Load a config from a specific file path, with extends resolution, returning
    /// the still-`Loaded` `SourcedConfig` (before validation and conversion).
    ///
    /// Used by per-directory resolution so the caller can layer CLI-level overrides
    /// (e.g. inline `--config`) on top before converting to `Config`, matching the
    /// precedence applied to the global config.
    pub fn load_sourced_for_path(
        config_path: &Path,
        project_root: &Path,
    ) -> Result<SourcedConfig<ConfigLoaded>, ConfigError> {
        let mut sourced_config = SourcedConfig {
            project_root: Some(project_root.to_path_buf()),
            ..SourcedConfig::default()
        };

        let filename = config_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let path_str = config_path.display().to_string();

        // Determine if this is a markdownlint config or rumdl config
        let is_markdownlint = MARKDOWNLINT_CONFIG_FILES.contains(&filename)
            || (filename != "pyproject.toml"
                && filename != ".rumdl.toml"
                && filename != "rumdl.toml"
                && (path_str.ends_with(".json")
                    || path_str.ends_with(".jsonc")
                    || path_str.ends_with(".yaml")
                    || path_str.ends_with(".yml")));

        if is_markdownlint {
            let fragment = parsers::load_from_markdownlint(&path_str)?;
            sourced_config.merge(fragment);
            sourced_config.loaded_files.push(path_str);
        } else {
            let mut chain = ExtendsChain::default();
            let chain_source = source_from_filename(filename);
            load_config_with_extends(
                &mut sourced_config,
                config_path,
                &mut chain,
                chain_source,
                ConfigOrigin::Direct,
                &FsConfigFiles,
            )?;
        }

        Ok(sourced_config)
    }

    /// Load a config from a specific file path, with extends resolution, and convert
    /// to `Config`. Used for per-directory config loading where each subdirectory
    /// config is standalone.
    pub fn load_config_for_path(config_path: &Path, project_root: &Path) -> Result<Config, ConfigError> {
        Ok(Self::load_sourced_for_path(config_path, project_root)?
            .into_validated_unchecked()
            .into())
    }

    /// Load the rumdl config at `root` and the `extends` chain it declares,
    /// reading every file from `source`, into a fresh still-`Loaded` config.
    ///
    /// This is the entry point for embedders without a filesystem: they supply
    /// an [`InMemoryConfigFiles`](super::file_source::InMemoryConfigFiles) and `root` names an
    /// entry in it. `root` is read as `pyproject.toml` when that is its file
    /// name and as `.rumdl.toml` / `rumdl.toml` otherwise; base configs reached
    /// through `extends` are named by the path their reference resolves to,
    /// relative to the declaring file's directory, exactly as on disk.
    #[cfg(any(feature = "wasm", test))]
    pub(crate) fn load_chain_from(root: &Path, source: &dyn ConfigFileSource) -> Result<Self, ConfigError> {
        let mut sourced_config = SourcedConfig::default();
        let filename = root.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let mut chain = ExtendsChain::default();
        load_config_with_extends(
            &mut sourced_config,
            root,
            &mut chain,
            source_from_filename(filename),
            ConfigOrigin::Direct,
            source,
        )?;
        Ok(sourced_config)
    }
}

/// Convert a validated configuration to the final Config type.
///
/// This implementation only exists for `SourcedConfig<ConfigValidated>`,
/// ensuring that validation must occur before conversion.
impl From<SourcedConfig<ConfigValidated>> for Config {
    fn from(sourced: SourcedConfig<ConfigValidated>) -> Self {
        let mut rules = BTreeMap::new();
        let mut withheld_rule_values = std::collections::BTreeSet::new();
        for (rule_name, sourced_rule_cfg) in sourced.rules {
            // Normalize rule name to uppercase for case-insensitive lookup
            let normalized_rule_name = rule_name.to_ascii_uppercase();
            let severity = sourced_rule_cfg.severity.map(|sv| sv.value);
            let mut values = BTreeMap::new();
            for (key, sourced_val) in sourced_rule_cfg.values {
                values.insert(key, sourced_val.value);
            }
            if values.keys().any(|key| sourced_rule_cfg.withheld_keys.contains(key)) {
                withheld_rule_values.insert(normalized_rule_name.clone());
            }
            rules.insert(normalized_rule_name, RuleConfig { severity, values });
        }
        // Enable is "explicit" if it was set by something other than the Default source
        let enable_is_explicit = sourced.global.enable.source != ConfigSource::Default;

        #[allow(deprecated)]
        let global = GlobalConfig {
            enable: sourced.global.enable.value,
            disable: sourced.global.disable.value,
            exclude: sourced.global.exclude.value,
            include: sourced.global.include.value,
            respect_gitignore: sourced.global.respect_gitignore.value,
            line_length: sourced.global.line_length.value,
            output_format: sourced.global.output_format.as_ref().map(|v| v.value.clone()),
            fixable: sourced.global.fixable.value,
            unfixable: sourced.global.unfixable.value,
            flavor: sourced.global.flavor.value,
            force_exclude: sourced.global.force_exclude.value,
            cache_dir: sourced.global.cache_dir.as_ref().map(|v| v.value.clone()),
            cache: sourced.global.cache.value,
            extend_enable: sourced.global.extend_enable.value,
            extend_disable: sourced.global.extend_disable.value,
            editorconfig: sourced.global.editorconfig.value,
            enable_is_explicit,
            include_withheld: sourced.global.include_withheld,
        };

        let mut config = Config {
            extends: None,
            global,
            per_file_ignores: sourced.per_file_ignores.value,
            per_file_flavor: sourced.per_file_flavor.value,
            code_block_tools: sourced.code_block_tools.value,
            rules,
            withheld_rule_values,
            project_root: sourced.project_root,
            per_file_ignores_cache: Arc::new(OnceLock::new()),
            per_file_flavor_cache: Arc::new(OnceLock::new()),
            canonical_project_root_cache: Arc::new(OnceLock::new()),
        };

        // Apply per-rule `enabled = true/false` to global enable/disable lists
        config.apply_per_rule_enabled();

        // Enforce the runtime invariant: every rule-name list is canonicalised.
        // After this point, downstream consumers (`rules::filter_rules`, the LSP,
        // WASM, fix coordinator, per-file-ignores) can match against
        // `Rule::name()` with simple string equality regardless of whether the
        // user's config used canonical IDs (`"MD033"`) or aliases
        // (`"no-inline-html"`).
        config.canonicalize_rule_lists();

        config
    }
}

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

    #[test]
    fn detects_flat_and_dotted_rumdl_sections() {
        assert!(pyproject_declares_rumdl_config("[tool.rumdl]\nline-length = 80\n"));
        // Dotted sections are valid on their own, without a flat header.
        assert!(pyproject_declares_rumdl_config(
            "[tool.rumdl.MD013]\nstyle = \"fixed\"\n"
        ));
        assert!(pyproject_declares_rumdl_config(
            "[tool.rumdl.rules.MD007]\nindent = 4\n"
        ));
    }

    #[test]
    fn ignores_incidental_mentions() {
        // A bare `tool.rumdl` in a comment or string value must not be treated
        // as a config section.
        assert!(!pyproject_declares_rumdl_config("# configure tool.rumdl later\n"));
        assert!(!pyproject_declares_rumdl_config(
            "[project]\ndependencies = [\"tool.rumdl-helper\"]\n"
        ));
        assert!(!pyproject_declares_rumdl_config("[tool.black]\nline-length = 88\n"));
    }

    /// Pure tests for the `$VAR` / `${VAR}` expander used by `extends` resolution.
    /// The injected `lookup` keeps these independent of the real process environment.
    mod expand_env_vars {
        use super::super::expand_env_vars;
        use std::collections::HashMap;

        /// Build a lookup closure from `(name, value)` pairs.
        fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
            let map: HashMap<String, String> = pairs
                .iter()
                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
                .collect();
            move |k: &str| map.get(k).cloned()
        }

        #[test]
        fn expands_bare_and_braced_forms() {
            let e = env(&[("VAR", "val"), ("FOO_BAR", "fb")]);
            assert_eq!(expand_env_vars("$VAR", &e).unwrap(), "val");
            assert_eq!(expand_env_vars("${VAR}", &e).unwrap(), "val");
            // Longest-match identifier: `$FOO_BAR` is one name, not `$FOO` + `_BAR`.
            assert_eq!(expand_env_vars("$FOO_BAR", &e).unwrap(), "fb");
        }

        #[test]
        fn expands_within_paths() {
            let e = env(&[("BASE", "/opt/cfg"), ("A", "x"), ("B", "y")]);
            assert_eq!(expand_env_vars("$BASE/x/y.toml", &e).unwrap(), "/opt/cfg/x/y.toml");
            assert_eq!(expand_env_vars("$A/$B", &e).unwrap(), "x/y");
            assert_eq!(expand_env_vars("${A}suffix", &e).unwrap(), "xsuffix");
        }

        #[test]
        fn dollar_dollar_is_a_literal_dollar() {
            let e = env(&[("VAR", "val")]);
            assert_eq!(expand_env_vars("$$", &e).unwrap(), "$");
            // The escaped `$` is consumed; what follows is literal (not expanded).
            assert_eq!(expand_env_vars("$$VAR", &e).unwrap(), "$VAR");
            assert_eq!(expand_env_vars("$${VAR}", &e).unwrap(), "${VAR}");
            // `$$` is how a literal `$` in a path is written once this feature exists.
            assert_eq!(expand_env_vars("file-$$name.toml", &e).unwrap(), "file-$name.toml");
        }

        #[test]
        fn bare_dollar_name_in_path_is_a_variable_reference() {
            // Documented behavior change: an unescaped `$name` in a path is a variable,
            // not a literal. `$$` writes a literal `$` (see dollar_dollar test above).
            let e = env(&[("name", "core")]);
            assert_eq!(expand_env_vars("file-$name.toml", &e).unwrap(), "file-core.toml");
        }

        #[test]
        fn incidental_dollar_stays_literal() {
            let e = env(&[]);
            // `$` before a non-identifier-start char (or end of input) is literal.
            assert_eq!(expand_env_vars("$5", &e).unwrap(), "$5");
            assert_eq!(expand_env_vars("cost$", &e).unwrap(), "cost$");
            assert_eq!(expand_env_vars("a$/b", &e).unwrap(), "a$/b");
        }

        #[test]
        fn malformed_braces_stay_literal() {
            let e = env(&[("B", "x")]);
            assert_eq!(expand_env_vars("${}", &e).unwrap(), "${}");
            assert_eq!(expand_env_vars("${VAR", &e).unwrap(), "${VAR");
            // Nested `${...}` is not supported: the whole span is literal, no partial expand.
            assert_eq!(expand_env_vars("${A${B}}", &e).unwrap(), "${A${B}}");
        }

        #[test]
        fn undefined_variable_is_an_error() {
            let e = env(&[]);
            assert_eq!(expand_env_vars("$NOPE", &e).unwrap_err(), "NOPE");
            assert_eq!(expand_env_vars("${NOPE}", &e).unwrap_err(), "NOPE");
            assert_eq!(expand_env_vars("prefix/$NOPE/x", &e).unwrap_err(), "NOPE");
        }

        #[test]
        fn replacement_is_not_rescanned() {
            // If `A` expands to "$B", the result is the literal "$B"; `B` is NOT expanded.
            let e = env(&[("A", "$B"), ("B", "should-not-appear")]);
            assert_eq!(expand_env_vars("$A", &e).unwrap(), "$B");
            assert_eq!(expand_env_vars("${A}", &e).unwrap(), "$B");
        }

        #[test]
        fn identifiers_are_ascii_only_unicode_stays_literal() {
            let e = env(&[("VAR", "v")]);
            // Non-ASCII inside braces is not a valid identifier -> whole span literal.
            assert_eq!(expand_env_vars("${föö}", &e).unwrap(), "${föö}");
            // A name ends at the first non-identifier byte; trailing unicode is preserved.
            assert_eq!(expand_env_vars("$VARö", &e).unwrap(), "vö");
            // Literal runs preserve multibyte content around an expansion.
            assert_eq!(expand_env_vars("café/$VAR", &e).unwrap(), "café/v");
        }

        #[test]
        fn passthrough_for_plain_input() {
            let e = env(&[]);
            assert_eq!(expand_env_vars("", &e).unwrap(), "");
            assert_eq!(expand_env_vars("/plain/path.toml", &e).unwrap(), "/plain/path.toml");
        }
    }

    /// Discovery must stop at the project root even when the root is supplied in
    /// a different path representation than the walked directory's ancestors.
    ///
    /// This reproduces the Windows 8.3-short-name / canonical mismatch using a
    /// Unix symlink: the project root is passed as a symlink to the real root, so
    /// it does not string-match the canonical ancestors of the starting
    /// directory. Without canonicalization the walk overshoots the project root
    /// and incorrectly picks up the config in the parent directory.
    #[cfg(unix)]
    #[test]
    fn discover_stops_at_project_root_across_path_representations() {
        use super::SourcedConfig;
        use std::os::unix::fs::symlink;
        use tempfile::tempdir;

        let tmp = tempdir().unwrap();
        // A config ABOVE the project root that must never be discovered.
        std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();

        let real_root = tmp.path().join("project");
        let subdir = real_root.join("docs");
        std::fs::create_dir_all(&subdir).unwrap();

        // Supply the project root via a symlink so it does not string-match the
        // canonical ancestors of `subdir`.
        let linked_root = tmp.path().join("project-link");
        symlink(&real_root, &linked_root).unwrap();

        let found = SourcedConfig::discover_config_for_dir(&subdir, &linked_root);
        assert_eq!(
            found, None,
            "discovery must stop at the project root, not overshoot to the parent config"
        );
    }

    #[test]
    #[serial_test::serial]
    fn project_candidates_absolutize_a_relative_start() {
        let cwd = std::env::current_dir().unwrap();
        let temp = tempfile::Builder::new()
            .prefix("rumdl-relative-config-")
            .tempdir_in(&cwd)
            .unwrap();
        let nested = temp.path().join("docs");
        std::fs::create_dir(&nested).unwrap();
        let config = temp.path().join(".rumdl.toml");
        std::fs::write(&config, "").unwrap();

        let relative_root = temp.path().strip_prefix(&cwd).unwrap();
        let relative_nested = nested.strip_prefix(&cwd).unwrap();
        let candidates = super::collect_project_config_candidates(relative_nested, Some(relative_root), None);

        assert_eq!(candidates.first(), Some(&config));
        assert!(candidates.iter().all(|path| path.is_absolute()));
    }

    mod shadowed_configs {
        use super::super::{ShadowedConfigs, detect_shadowed_configs, format_shadow_warning, rumdl_configs_in_dir};
        use tempfile::tempdir;

        fn names(paths: &[std::path::PathBuf]) -> Vec<String> {
            paths
                .iter()
                .map(|p| {
                    // Use the last two components so `.config/rumdl.toml` is distinguishable
                    // from a top-level `rumdl.toml` without depending on the temp dir prefix.
                    let file = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
                    let parent = p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str());
                    match parent {
                        Some(".config") => format!(".config/{file}"),
                        _ => file.to_string(),
                    }
                })
                .collect()
        }

        #[test]
        fn empty_directory_has_no_configs_and_no_shadow() {
            let tmp = tempdir().unwrap();
            assert!(rumdl_configs_in_dir(tmp.path()).is_empty());
            assert!(detect_shadowed_configs(tmp.path()).is_none());
        }

        #[test]
        fn single_config_does_not_shadow() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
            assert!(detect_shadowed_configs(tmp.path()).is_none());
        }

        #[test]
        fn dot_wins_over_non_dot_and_non_dot_is_shadowed() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();

            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
            assert_eq!(names(&shadowed), vec!["rumdl.toml"]);
        }

        #[test]
        fn config_subdir_counts_as_same_level_shadow() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
            std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();

            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(tmp.path()).unwrap();
            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
            assert_eq!(names(&shadowed), vec![".config/rumdl.toml"]);
        }

        #[test]
        fn pyproject_counts_only_when_it_declares_rumdl() {
            // pyproject WITHOUT [tool.rumdl] is not a rumdl config source -> no shadow.
            let bare = tempdir().unwrap();
            std::fs::write(bare.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(bare.path().join("pyproject.toml"), "[tool.black]\nline-length = 88\n").unwrap();
            assert_eq!(names(&rumdl_configs_in_dir(bare.path())), vec![".rumdl.toml"]);
            assert!(detect_shadowed_configs(bare.path()).is_none());

            // pyproject WITH [tool.rumdl] is a real shadowed source.
            let declared = tempdir().unwrap();
            std::fs::write(declared.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(
                declared.path().join("pyproject.toml"),
                "[tool.rumdl]\nline-length = 80\n",
            )
            .unwrap();
            let ShadowedConfigs { winner, shadowed, .. } = detect_shadowed_configs(declared.path()).unwrap();
            assert_eq!(names(&[winner]), vec![".rumdl.toml"]);
            assert_eq!(names(&shadowed), vec!["pyproject.toml"]);
        }

        #[test]
        fn markdownlint_configs_are_not_rumdl_native_and_never_shadow() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join(".markdownlint.json"), "{}").unwrap();
            assert_eq!(names(&rumdl_configs_in_dir(tmp.path())), vec![".rumdl.toml"]);
            assert!(detect_shadowed_configs(tmp.path()).is_none());
        }

        #[test]
        fn configs_returned_in_precedence_order() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
            std::fs::create_dir_all(tmp.path().join(".config")).unwrap();
            std::fs::write(tmp.path().join(".config/rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();

            assert_eq!(
                names(&rumdl_configs_in_dir(tmp.path())),
                vec![".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]
            );
        }

        #[test]
        fn warning_names_dir_once_with_relative_filenames() {
            let tmp = tempdir().unwrap();
            std::fs::write(tmp.path().join(".rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join("rumdl.toml"), "").unwrap();
            std::fs::write(tmp.path().join("pyproject.toml"), "[tool.rumdl]\n").unwrap();

            let shadow = detect_shadowed_configs(tmp.path()).unwrap();
            let msg = format_shadow_warning(&shadow);

            let dir = {
                let s = tmp.path().to_string_lossy().into_owned();
                if cfg!(windows) { s.replace('\\', "/") } else { s }
            };
            assert!(msg.contains("multiple rumdl config files"), "got: {msg}");
            // The directory is named once; files are shown relative to it (no
            // repeated directory prefix on every path).
            assert_eq!(
                msg.matches(dir.as_str()).count(),
                1,
                "directory should appear exactly once, got: {msg}"
            );
            assert!(
                msg.contains("using .rumdl.toml, ignoring rumdl.toml, pyproject.toml"),
                "winner and shadowed files should be relative names in precedence order, got: {msg}"
            );
            // Paths are normalized to forward slashes on all platforms.
            assert!(!msg.contains('\\'), "paths must be normalized to '/': {msg}");
        }
    }
}