sumo-types 0.2.1

Reads and writes SUMO (Eclipse SUMO) .net.xml, .rou.xml and .add.xml files as well-typed Rust structs
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
/// Errors this build script can produce.
///
/// A boxed `std` error rather than `anyhow`: nothing here ever inspects an
/// error — Cargo prints whatever `main` returns and stops the build — so the
/// only thing `anyhow` was buying was `.context()`'s ergonomics, which
/// `.map_err(|error| format!(...))?` covers in one more line per site.
///
/// This does *not* remove `anyhow` from the build: `xsd-parser` depends on
/// it, so it is compiled either way. What it removes is this crate's own
/// declared dependency on it — one fewer version to keep in step, and
/// `[dependencies]`/`[build-dependencies]` that no longer disagree about
/// which error crate this project uses.
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

use inflector::Inflector;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, atomic::AtomicUsize};
use xsd_parser::{
    Config, Ident2, Name, TypeIdent,
    config::Schema,
    generate,
    models::{
        NameBuilder as DefaultNameBuilder, format_ident, format_unknown_variant, make_type_name,
        meta::MetaType,
    },
    traits::{NameBuilder, Naming},
};

const ORIGINAL_XSD_DIR: &str = "xsd";
const OUTPUT_FILE_NAME: &str = "generated_schema.rs";

/// Maps each of this crate's `[features]` (see `Cargo.toml`) to the XSD its
/// reader is generated from. Adding a new SUMO file format is meant to be
/// mostly a matter of adding an entry here, a matching feature, and that
/// format's own `domain`/`schema_mapper` module — the XSD patching below and
/// the `SumoNaming` strategy are already format-agnostic.
///
/// Every active feature's schema is generated in a single `generate()` call
/// (see `main`), not one call per feature: `xsd-parser` scopes type
/// resolution per call, so generating separately would give each format its
/// own incompatible copy of the primitives common schemas like
/// `types/base.xsd` define (`positionType`, `boolType`, ...) instead of one
/// shared `schema` module.
///
/// Not every SUMO schema generates as-is; `additional_file.xsd` needs the
/// targeted patch in [`PER_FILE_PATCHES`] first.
const FEATURE_SCHEMAS: &[(&str, &str)] = &[
    ("net", "net_file.xsd"),
    ("routes", "routes_file.xsd"),
    ("additional", "additional_file.xsd"),
];

/// The one feature in `Cargo.toml` that isn't a SUMO file format: it turns
/// on the *serializers* for whichever formats [`FEATURE_SCHEMAS`] already
/// selected, rather than adding a schema of its own.
const WRITE_FEATURE: &str = "write";

/// Cargo sets `CARGO_FEATURE_<NAME>` (uppercased, `-` -> `_`) for every
/// enabled feature of the crate the build script belongs to.
fn feature_enabled(name: &str) -> bool {
    env::var(format!(
        "CARGO_FEATURE_{}",
        name.to_uppercase().replace('-', "_")
    ))
    .is_ok()
}

/// Some SUMO xsd files (types/base.xsd) declare DTD entity constants in
/// their own <!DOCTYPE ...> (e.g. <!ENTITY FloatPattern "[-+]?...">) and use
/// them inside `xsd:pattern` patterns (`&FloatPattern;`). The XML parser used by
/// xsd-parser does not resolve the DOCTYPE's internal subset, so those
/// references make parsing fail if left as-is.
///
/// Unlike neutralizing them (replacing them with a wildcard), here we
/// resolve them to their real value: since they're declared and used within
/// the same file, the substitution is textual and doesn't lose precision
/// from the original pattern. Standard XML entities (&amp; &lt; &gt; &apos;
/// &quot;) are left untouched.
fn resolve_dtd_entities(content: &str) -> String {
    let entities = parse_dtd_entities(content);
    if entities.is_empty() {
        return content.to_string();
    }

    let mut segments = content.split('&');
    let first = segments.next().unwrap_or_default().to_string();

    segments.fold(first, |mut resolved, segment| {
        resolved.push_str(&resolve_entity_reference(segment, &entities));
        resolved
    })
}

/// Extracts the `(name, value)` pairs from the `<!ENTITY ...>` declarations
/// present in `content`'s DOCTYPE internal subset.
fn parse_dtd_entities(content: &str) -> Vec<(&str, &str)> {
    content
        .lines()
        .filter_map(|line| {
            let rest = line.trim().strip_prefix("<!ENTITY ")?.strip_suffix('>')?;
            let (name, value) = rest.split_once(char::is_whitespace)?;
            let value = value.trim().strip_prefix('"')?.strip_suffix('"')?;
            Some((name.trim(), value))
        })
        .collect()
}

/// Resolves the piece of text that follows an `&` (the result of splitting
/// the original content on that character): if it starts with a known
/// entity reference, it's replaced with its value; otherwise, the `&` is
/// restored.
fn resolve_entity_reference(segment: &str, entities: &[(&str, &str)]) -> String {
    let Some((name, rest)) = segment.split_once(';') else {
        return format!("&{segment}");
    };

    let value = match name {
        "amp" | "lt" | "gt" | "apos" | "quot" => return format!("&{name};{rest}"),
        _ => entities
            .iter()
            .find_map(|(n, v)| (*n == name).then_some(*v)),
    };

    match value {
        Some(value) => format!("{value}{rest}"),
        None => format!("&{segment}"),
    }
}

/// xsd-parser derives Rust type names from XSD primitive types
/// (xsd:float -> `FloatType`, xsd:time -> `TimeType`, xsd:ID -> `IdType`, ...).
/// SUMO defines its own simpleType "floatType", "timeType" and "idType" in
/// base.xsd, which, once capitalized, collide exactly with those generated
/// primitive names, and xsd-parser doesn't disambiguate them (E0428: name
/// defined twice). We rename them so they don't collide; this only affects
/// the generated `schema` layer, not our own code.
const RENAMED_TYPES: &[(&str, &str)] = &[
    (r#""idType""#, r#""sumoIdType""#),
    (r#""floatType""#, r#""sumoFloatType""#),
    (r#""timeType""#, r#""sumoTimeType""#),
];

fn rename_colliding_types(content: &str) -> String {
    RENAMED_TYPES
        .iter()
        .fold(content.to_string(), |content, (from, to)| {
            content.replace(from, to)
        })
}

/// Patches that apply to one specific schema rather than to all of them:
/// `file name -> (literal text to find, replacement)`.
///
/// `additional_file.xsd` fails to generate untouched, with
/// `UnknownType(fileOptionType)`. The cause is not the file itself — it
/// never mentions `fileOptionType` — but its `types/metadata.xsd` include:
/// that schema pulls in the 13 `*ConfigurationType.xsd` files describing
/// every SUMO tool's command-line options (netconvert, duarouter,
/// polyconvert, ...), and `fileOptionType` lives in that graph. All
/// `additional_file.xsd` actually wants from it is one optional
/// `<metadata>` child of `<additional>`, carrying the provenance block SUMO
/// writes (which tool and version produced the file).
///
/// Dropping the include and that one `minOccurs="0"` element collapses the
/// graph to `route.xsd` + `taz.xsd` + `base.xsd` — the same shape
/// `net_file.xsd` and `routes_file.xsd` already generate from — and the
/// whole schema comes out clean. The cost is that `<metadata>` can't be
/// read; nothing this crate models needs it.
///
/// The second patch names an anonymous `xsd:choice`. xsd-parser derives a
/// name for such a type from a *global counter over everything it has
/// generated so far*, so the same choice came out as
/// `E3DetectorContent75Type` with all three formats enabled but
/// `E3DetectorContent70Type` with only `additional` — the ordinal shifts
/// with the active feature set, and `schema_mapper` can't name a type whose
/// name depends on which features the consumer picked. Hoisting the choice
/// into a named `xsd:group` makes xsd-parser derive the name from the group
/// instead (`E3DetectorDetGateGroupType`), stable across every
/// combination. The group
/// is a pure refactor of the XSD: `xsd:group` is an inlined definition, so
/// the document shape it accepts is unchanged.
///
/// Keyed by file name on purpose: [`patch_xsd`] runs over all 75 vendored
/// schemas, and 46 of them include `types/base.xsd` while several include
/// `types/metadata.xsd`. Applying this blindly would silently reshape
/// schemas that have nothing to do with `.add.xml`.
const PER_FILE_PATCHES: &[(&str, &[(&str, &str)])] = &[
    (
        "additional_file.xsd",
        &[
            // Cut the whole `types/metadata.xsd` subtree loose ...
            (
                "    <xsd:include schemaLocation=\"types/metadata.xsd\"/>\n",
                "",
            ),
            // ... along with the single optional element that needed it.
            (
                "            <xsd:element name=\"metadata\" type=\"metadataType\" minOccurs=\"0\" maxOccurs=\"1\"/>\n",
                "",
            ),
            // Give the detEntry/detExit choice a name of its own.
            (
                "            <xsd:choice minOccurs=\"2\" maxOccurs=\"unbounded\">\n\
             \x20               <xsd:element name=\"detEntry\" type=\"detEntryExitType\" minOccurs=\"1\" maxOccurs=\"unbounded\"/>\n\
             \x20               <xsd:element name=\"detExit\" type=\"detEntryExitType\" minOccurs=\"1\" maxOccurs=\"unbounded\"/>\n\
             \x20           </xsd:choice>\n",
                "            <xsd:group ref=\"detGateGroup\" minOccurs=\"2\" maxOccurs=\"unbounded\"/>\n",
            ),
            (
                "    <xsd:complexType name=\"e3DetectorType\">\n",
                "    <xsd:group name=\"detGateGroup\">\n\
             \x20       <xsd:choice>\n\
             \x20           <xsd:element name=\"detEntry\" type=\"detEntryExitType\" minOccurs=\"1\" maxOccurs=\"unbounded\"/>\n\
             \x20           <xsd:element name=\"detExit\" type=\"detEntryExitType\" minOccurs=\"1\" maxOccurs=\"unbounded\"/>\n\
             \x20       </xsd:choice>\n\
             \x20   </xsd:group>\n\
             \n\
             \x20   <xsd:complexType name=\"e3DetectorType\">\n",
            ),
        ],
    ),
    (
        // `types/route.xsd` has the same anonymous-choice naming problem as
        // `additional_file.xsd`'s `e3DetectorType` above, three times over:
        // `vTypeType`'s car-following-model choice, and `vehicleType`'s
        // route/routeDistribution choice and its `<stop>` sequence are each
        // wrapped in an anonymous `<xsd:sequence minOccurs="0">`, so
        // xsd-parser names the field it produces from the same global,
        // feature-set-dependent counter (`content_40`, `content_43`, ... —
        // the exact digits shift with whatever else is in the same
        // `generate()` call). `schema_writer` (built under `write`) has to
        // construct these fields to leave them empty, so — unlike on the read
        // side, where nothing ever had to name them — an unstable field name
        // is no longer just a cosmetic surprise, it is source code that
        // doesn't compile under every feature combination. Same fix as
        // `e3DetectorType`: hoist each anonymous sequence into a named
        // `xsd:group`, so xsd-parser derives a stable field name from the
        // group instead.
        "route.xsd",
        &[
            (
                "            <xsd:sequence minOccurs=\"0\">\n\
             \x20               <xsd:choice>\n\
             \x20                   <xsd:element name=\"carFollowing-IDM\" type=\"cfIDMType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-IDMM\" type=\"cfIDMMType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-EIDM\" type=\"cfEIDMType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-Krauss\" type=\"cfKraussType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-KraussPS\" type=\"cfKraussType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-KraussOrig1\" type=\"cfKraussType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-SmartSK\" type=\"cfSmartType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-Daniel1\" type=\"cfSmartType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-PWagner2009\" type=\"cfPWagType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-BKerner\" type=\"cfBKernerType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-Wiedemann\" type=\"cfWiedemannType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-W99\" type=\"cfW99Type\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-ACC\" type=\"cfACCType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-CACC\" type=\"cfCACCType\"/>\n\
             \x20                   <xsd:element name=\"carFollowing-CC\" type=\"cfCCType\"/>\n\
             \x20               </xsd:choice>\n\
             \x20               <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20           </xsd:sequence>\n",
                "            <xsd:group ref=\"vTypeCarFollowingGroup\" minOccurs=\"0\"/>\n",
            ),
            (
                "    <xsd:complexType name=\"vTypeType\">\n",
                "    <xsd:group name=\"vTypeCarFollowingGroup\">\n\
             \x20       <xsd:sequence>\n\
             \x20           <xsd:choice>\n\
             \x20               <xsd:element name=\"carFollowing-IDM\" type=\"cfIDMType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-IDMM\" type=\"cfIDMMType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-EIDM\" type=\"cfEIDMType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-Krauss\" type=\"cfKraussType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-KraussPS\" type=\"cfKraussType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-KraussOrig1\" type=\"cfKraussType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-SmartSK\" type=\"cfSmartType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-Daniel1\" type=\"cfSmartType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-PWagner2009\" type=\"cfPWagType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-BKerner\" type=\"cfBKernerType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-Wiedemann\" type=\"cfWiedemannType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-W99\" type=\"cfW99Type\"/>\n\
             \x20               <xsd:element name=\"carFollowing-ACC\" type=\"cfACCType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-CACC\" type=\"cfCACCType\"/>\n\
             \x20               <xsd:element name=\"carFollowing-CC\" type=\"cfCCType\"/>\n\
             \x20           </xsd:choice>\n\
             \x20           <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20       </xsd:sequence>\n\
             \x20   </xsd:group>\n\
             \n\
             \x20   <xsd:complexType name=\"vTypeType\">\n",
            ),
            (
                "            <xsd:sequence minOccurs=\"0\">\n\
             \x20               <xsd:choice>\n\
             \x20                   <xsd:element name=\"route\" type=\"vehicleRouteType\"/>\n\
             \x20                   <xsd:element name=\"routeDistribution\" type=\"vehicleRouteDistributionType\"/>\n\
             \x20               </xsd:choice>\n\
             \x20               <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20           </xsd:sequence>\n\
             \x20           <xsd:sequence minOccurs=\"0\">\n\
             \x20               <xsd:element name=\"stop\" type=\"stopType\" maxOccurs=\"unbounded\"/>\n\
             \x20               <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20           </xsd:sequence>\n",
                "            <xsd:group ref=\"vehicleRouteChoiceGroup\" minOccurs=\"0\"/>\n\
             \x20           <xsd:group ref=\"vehicleStopGroup\" minOccurs=\"0\"/>\n",
            ),
            (
                "    <xsd:complexType name=\"vehicleType\">\n",
                "    <xsd:group name=\"vehicleRouteChoiceGroup\">\n\
             \x20       <xsd:sequence>\n\
             \x20           <xsd:choice>\n\
             \x20               <xsd:element name=\"route\" type=\"vehicleRouteType\"/>\n\
             \x20               <xsd:element name=\"routeDistribution\" type=\"vehicleRouteDistributionType\"/>\n\
             \x20           </xsd:choice>\n\
             \x20           <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20       </xsd:sequence>\n\
             \x20   </xsd:group>\n\
             \n\
             \x20   <xsd:group name=\"vehicleStopGroup\">\n\
             \x20       <xsd:sequence>\n\
             \x20           <xsd:element name=\"stop\" type=\"stopType\" maxOccurs=\"unbounded\"/>\n\
             \x20           <xsd:element name=\"param\" type=\"paramType\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\
             \x20       </xsd:sequence>\n\
             \x20   </xsd:group>\n\
             \n\
             \x20   <xsd:complexType name=\"vehicleType\">\n",
            ),
        ],
    ),
];

/// Applies the [`PER_FILE_PATCHES`] registered for `file_name`. A no-op for
/// any schema with no entry there, which is all but one of them.
///
/// Fails if a pattern isn't found rather than skipping it: these patches
/// are matched against literal text from Eclipse SUMO's schemas, so
/// re-vendoring an updated `xsd/` is exactly when one would stop applying.
/// Silently skipping would surface much later as an opaque xsd-parser
/// error, or — worse, for the naming patch — as generated code that still
/// compiles but under a different type name.
fn apply_per_file_patches(file_name: &str, content: &str) -> Result<String> {
    let Some((_, patches)) = PER_FILE_PATCHES.iter().find(|(name, _)| *name == file_name) else {
        return Ok(content.to_string());
    };

    patches
        .iter()
        .try_fold(content.to_string(), |content, (from, to)| {
            if !content.contains(from) {
                return Err(format!(
                    "patch for {file_name} no longer matches; the vendored schema must have \
                     changed. Expected to find:\n{from}"
                )
                .into());
            }

            Ok(content.replace(from, to))
        })
}

/// Composition of the text patches applied to each `.xsd` before handing it
/// to xsd-parser. `file_name` selects the [`PER_FILE_PATCHES`]; the other
/// two patches are format-agnostic and apply to every schema.
fn patch_xsd(file_name: &str, content: &str) -> Result<String> {
    let content = apply_per_file_patches(file_name, content)?;
    Ok(rename_colliding_types(&resolve_dtd_entities(&content)))
}

/// Recursively copies `src` into `dst`, patching (see [`patch_xsd`]) any
/// `.xsd` file it finds along the way.
fn copy_and_patch_schemas(src: &Path, dst: &Path) -> Result<()> {
    fs::create_dir_all(dst)?;

    fs::read_dir(src)?.try_for_each(|entry| {
        let entry = entry?;
        let path = entry.path();
        let dest_path = dst.join(entry.file_name());

        if path.is_dir() {
            return copy_and_patch_schemas(&path, &dest_path);
        }

        if path.extension().is_some_and(|ext| ext == "xsd") {
            let content = fs::read_to_string(&path)?;
            let file_name = entry.file_name();
            let patched = patch_xsd(&file_name.to_string_lossy(), &content)?;
            fs::write(&dest_path, patched)?;
        } else {
            fs::copy(&path, &dest_path)?;
        }

        Ok(())
    })
}

/// Naming strategy passed to xsd-parser (`Config::with_naming`) for
/// types/modules/fields/constants: identical to the default one (unifies to
/// `PascalCase`/`snake_case`/`SCREAMING_SNAKE_CASE`), but with
/// [`format_variant_name`] replaced by a version that doesn't lose
/// information (see its documentation).
///
/// The `unify` logic is duplicated here because xsd-parser doesn't expose it
/// publicly (`unify_string` is private to the crate); this is a faithful
/// copy of its default implementation.
#[derive(Debug, Clone, Default)]
struct SumoNaming(Arc<AtomicUsize>);

impl Naming for SumoNaming {
    fn clone_boxed(&self) -> Box<dyn Naming> {
        Box::new(self.clone())
    }

    fn builder(&self) -> Box<dyn NameBuilder> {
        Box::new(DefaultNameBuilder::new(
            self.0.clone(),
            Box::new(self.clone()),
        ))
    }

    fn unify(&self, s: &str) -> String {
        unify(s)
    }

    fn make_type_name(&self, postfixes: &[String], ty: &MetaType, ident: &TypeIdent) -> Name {
        make_type_name(self, postfixes, ty, ident)
    }

    fn make_unknown_variant(&self, id: usize) -> Ident2 {
        format_unknown_variant(id)
    }

    fn format_module_name(&self, s: &str) -> String {
        format_ident(self.unify(s).to_snake_case())
    }

    fn format_type_name(&self, s: &str) -> String {
        format_ident(self.unify(s))
    }

    fn format_field_name(&self, s: &str) -> String {
        format_ident(self.unify(s).to_snake_case())
    }

    fn format_variant_name(&self, s: &str) -> String {
        format_variant_name(s)
    }

    fn format_constant_name(&self, s: &str) -> String {
        format_ident(self.unify(s).to_screaming_snake_case())
    }
}

/// Faithful copy of the `PascalCase` normalization used by xsd-parser's
/// default `Naming` for types/modules/fields/constants (its real
/// implementation, `unify_string`, is not public).
fn unify(s: &str) -> String {
    let mut done = true;
    let unified = s.replace(
        |c: char| {
            let replace = !c.is_alphanumeric();
            if c != '_' && !replace {
                done = false;
            }
            c != '_' && replace
        },
        "_",
    );

    if done {
        unified
    } else {
        unified.to_screaming_snake_case().to_pascal_case()
    }
}

/// Builds an enum variant name from the XSD value `s`.
///
/// xsd-parser's default `Naming` unifies everything to `PascalCase`, which is
/// case-insensitive: XSD values that only differ by case (e.g. the
/// single-character codes `net_file.xsd` uses for `state="M"/"m"` or
/// `dir="s"/"t"/"T"`, or "true"/"True" in `boolType`) collapse into the same
/// Rust identifier, which xsd-parser neither detects nor disambiguates
/// (duplicate enum variants -> compile error).
///
/// Here, instead, we preserve all the information from the original value:
/// alphanumeric characters are kept as-is (with their original case), and
/// each non-alphanumeric symbol is translated to a distinct word (instead of
/// collapsing them all to "_", which would also produce the reserved `_`
/// identifier for single-symbol values like "-").
fn format_variant_name(s: &str) -> String {
    let sanitized: String = s
        .chars()
        .map(|c| {
            if c.is_alphanumeric() {
                c.to_string()
            } else {
                describe_symbol(c)
            }
        })
        .collect();

    format_ident(if sanitized.is_empty() {
        "Empty".to_string()
    } else {
        sanitized
    })
}

/// Gives a readable, distinct name to a non-alphanumeric character, so that
/// two different symbols (e.g. "-" and "=" in `connectionType/@state`) never
/// collapse into the same identifier.
fn describe_symbol(c: char) -> String {
    match c {
        '-' => "Dash".to_string(),
        '=' => "Eq".to_string(),
        '_' => "_".to_string(),
        other => format!("U{:x}", other as u32),
    }
}

/// Formats the generated code so that rustc diagnostics pointing into
/// `schema` have real line numbers to point at: xsd-parser hands back a
/// `TokenStream`, whose `to_string()` is a single multi-megabyte line.
///
/// Falls back to the unformatted source rather than failing the build — a
/// pretty-printing problem is a readability problem, not a correctness one,
/// and `syn` failing to re-parse xsd-parser's own output would be its bug,
/// not something a consumer of this crate can act on.
fn pretty_print(code: &str) -> String {
    match syn::parse_file(code) {
        Ok(parsed) => prettyplease::unparse(&parsed),
        Err(error) => {
            println!("cargo:warning=could not pretty-print the generated schema: {error}");
            code.to_string()
        }
    }
}

/// Prints the error with [`Display`](std::fmt::Display) and exits, rather
/// than returning it from `main` for Cargo to render.
///
/// `fn main() -> Result<_, E>` formats `E` with [`Debug`], which for a boxed
/// error built from a `String` escapes the message — the `PER_FILE_PATCHES`
/// mismatch reports the multi-line XSD snippet it expected to find, and
/// under `Debug` that arrives as one line full of literal `\n`, which is
/// exactly the message where readability matters most.
fn main() {
    if let Err(error) = run() {
        eprintln!("error: {error}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    println!("cargo:rerun-if-changed={ORIGINAL_XSD_DIR}");

    let out_dir = PathBuf::from(
        env::var("OUT_DIR")
            .map_err(|error| format!("OUT_DIR environment variable is not set: {error}"))?,
    );
    let dest_path = out_dir.join(OUTPUT_FILE_NAME);

    let active_schemas: Vec<&str> = FEATURE_SCHEMAS
        .iter()
        .filter(|(feature, _)| feature_enabled(feature))
        .map(|(_, xsd)| *xsd)
        .collect();

    // With no format feature there is no schema to generate, but failing
    // here would surface as an opaque "custom build command failed". Write
    // an empty module instead and let the `compile_error!` in `lib.rs`
    // report it as an ordinary rustc diagnostic naming the missing feature.
    if active_schemas.is_empty() {
        fs::write(&dest_path, "").map_err(|error| {
            format!(
                "Failed to write empty schema to {}: {error}",
                dest_path.display()
            )
        })?;
        return Ok(());
    }

    let patched_xsd_dir = out_dir.join("patched_xsd");
    copy_and_patch_schemas(Path::new(ORIGINAL_XSD_DIR), &patched_xsd_dir)
        .map_err(|error| format!("Failed to patch and copy XSD schemas: {error}"))?;

    // Serializers are generated only under `write`, and cost roughly 40% more
    // generated code than the deserializers alone (~84k lines vs. ~60k with
    // every format on) — a compile-time bill readers shouldn't pay. Both
    // directions come out of the same `generate()` call so they agree on the
    // types they share; only the render steps differ.
    let mut config = Config::default()
        .with_naming(SumoNaming::default())
        .with_quick_xml_deserialize();
    if feature_enabled(WRITE_FEATURE) {
        config = config.with_quick_xml_serialize();
    }
    config.parser.schemas = active_schemas
        .iter()
        .map(|xsd| Schema::File(patched_xsd_dir.join(xsd)))
        .collect();

    let code = generate(config)
        .map_err(|error| format!("Error generating code from XSD schema: {error:?}"))?;

    fs::write(&dest_path, pretty_print(&code.to_string())).map_err(|error| {
        format!(
            "Failed to write generated file to {}: {error}",
            dest_path.display()
        )
    })?;

    Ok(())
}