Skip to main content

powerio_matrix/
pipeline.rs

1//! Orchestrates a single case → output directory.
2//!
3//! Given a parsed `BalancedNetwork`, calculates the requested matrix family, writes
4//! `.mtx` files, and emits a `meta.json` sidecar describing what was
5//! produced. Used by both the `batch` CLI subcommand and the TUI's
6//! batch export screen.
7
8use std::path::{Path, PathBuf};
9
10use rand::SeedableRng;
11use rand::distr::{Distribution, StandardUniform};
12use rand_chacha::ChaCha8Rng;
13use serde::{Deserialize, Serialize};
14
15use crate::Result;
16use crate::indexed::IndexedNetwork;
17use crate::io::meta::{CaseMetadata, MatrixMetadata};
18use crate::matrix::{
19    BuildOptions, MatrixStats, ZeroImpedanceRule, ZeroImpedanceSkips, calc_adjacency_matrix,
20    calc_admittance_matrix, calc_bdoubleprime_matrix, calc_bprime_matrix, calc_lacpf_matrix,
21    calc_zero_impedance_skips, check_sddm, negate_into,
22};
23use crate::network::BalancedNetwork;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum MatrixKind {
28    /// MATPOWER FDPF Bp matrix.
29    BPrime,
30    /// MATPOWER FDPF Bpp matrix.
31    BDoublePrime,
32    /// `Re(Y_bus)` — full conductance matrix.
33    YbusG,
34    /// `-Im(Y_bus)` — full bus susceptance matrix (positive convention).
35    YbusB,
36    /// LACPF block: `[[G, -B], [-B, -G]]`, 2n × 2n indefinite.
37    Lacpf,
38    /// 0/1 bus adjacency matrix.
39    Adjacency,
40}
41
42impl MatrixKind {
43    pub const ALL: &'static [MatrixKind] = &[
44        Self::BPrime,
45        Self::BDoublePrime,
46        Self::YbusG,
47        Self::YbusB,
48        Self::Lacpf,
49        Self::Adjacency,
50    ];
51
52    pub fn slug(self) -> &'static str {
53        match self {
54            Self::BPrime => "bprime",
55            Self::BDoublePrime => "bdoubleprime",
56            Self::YbusG => "ybus_real",
57            Self::YbusB => "ybus_imag",
58            Self::Lacpf => "lacpf",
59            Self::Adjacency => "adjacency",
60        }
61    }
62
63    pub fn label(self) -> &'static str {
64        match self {
65            Self::BPrime => "MATPOWER Bp (FDPF)",
66            Self::BDoublePrime => "MATPOWER Bpp (FDPF)",
67            Self::YbusG => "Re(Y_bus)",
68            Self::YbusB => "-Im(Y_bus)",
69            Self::Lacpf => "LACPF block (2n×2n)",
70            Self::Adjacency => "adjacency (0/1)",
71        }
72    }
73}
74
75/// How to populate the RHS vector(s) emitted alongside each matrix.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
77pub enum RhsKind {
78    #[default]
79    None,
80    /// Zero-mean Gaussian random (deterministic from `rng_seed`).
81    Random,
82    /// Power injections from the case: `b = (Pd, Qd) / baseMVA`.
83    Injection,
84}
85
86#[derive(Debug, Clone)]
87pub struct Pipeline {
88    pub matrices: Vec<MatrixKind>,
89    pub options: BuildOptions,
90    pub rhs: RhsKind,
91    pub rng_seed: u64,
92    pub source_file: Option<PathBuf>,
93}
94
95impl Default for Pipeline {
96    fn default() -> Self {
97        Self {
98            matrices: vec![MatrixKind::BPrime],
99            options: BuildOptions::default(),
100            rhs: RhsKind::None,
101            rng_seed: 0x00C0_FFEE,
102            source_file: None,
103        }
104    }
105}
106
107#[derive(Debug, Clone)]
108pub struct PipelineOutputs {
109    pub case_name: String,
110    pub files: Vec<PathBuf>,
111    pub metadata: CaseMetadata,
112}
113
114/// Longest sanitized stem before truncation. Output filenames append fixed
115/// suffixes (`_sensitivity_meta.json` is the longest at 22 bytes) and the
116/// collision hash adds 9 more, so the longest filename stays well under the
117/// common 255 byte component limit.
118const MAX_STEM_LEN: usize = 120;
119
120/// Hex digits of the SHA-256 disambiguator appended to a sanitized stem.
121/// 64 bits, so a batch export cannot be steered into an overwrite by
122/// searching for a second name that hashes into an existing stem.
123const DIGEST_LEN: usize = 16;
124
125/// Filenames Windows reserves for devices, matched against the part of a
126/// name before its first dot, case insensitively: `con` and `con.4` both
127/// resolve to the CON device.
128const WINDOWS_RESERVED: &[&str] = &[
129    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
130    "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
131];
132
133/// Reduces a network name to a single safe filename stem. The name comes from
134/// input content, so an unsanitized value like `../../etc/x` or `/abs/x` used
135/// in `out_dir.join(...)` would write outside `out_dir`. Keeps ASCII
136/// alphanumerics, `-`, `_`, and `.`; every other character (path separators
137/// included) becomes `_`. Leading dots are stripped so the result is never
138/// `.`, `..`, or a hidden file; trailing dots are trimmed and Windows
139/// reserved device names get a leading `_`, both invalid as Windows
140/// filenames; the length is capped at 120 bytes; and an empty result
141/// falls back to `case`.
142///
143/// A name the sanitizer had to change carries a hash of the original, so two
144/// distinct names that would sanitize identically (`a/b` and `a_b`, say)
145/// cannot silently overwrite each other's files in a multi case export. A
146/// name that already ends in the suffix shape is hashed too, so a case can
147/// never be named to impersonate another case's disambiguated stem: the
148/// suffixed and unsuffixed name spaces stay disjoint. Any other safe name
149/// passes through unchanged.
150pub fn sanitize_stem(name: &str) -> String {
151    // Skipping leading dots before mapping is equivalent to trimming them
152    // after: the map is the identity on `.` and never produces one, so the set
153    // of leading dots is the same either way.
154    let mut stem: String = name
155        .chars()
156        .skip_while(|&c| c == '.')
157        .map(|c| {
158            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
159                c
160            } else {
161                '_'
162            }
163        })
164        .collect();
165    while stem.ends_with('.') {
166        stem.pop();
167    }
168    let pre_dot = stem.split('.').next().unwrap_or("");
169    if WINDOWS_RESERVED
170        .iter()
171        .any(|r| pre_dot.eq_ignore_ascii_case(r))
172    {
173        stem.insert(0, '_');
174    }
175    if stem.is_empty() {
176        stem.push_str("case");
177    }
178    if stem == name && stem.len() <= MAX_STEM_LEN && !ends_with_digest(&stem) {
179        return stem;
180    }
181    // The map above only emits ASCII, so byte truncation cannot split a
182    // character, and the budget leaves room for the suffix.
183    stem.truncate(MAX_STEM_LEN - DIGEST_LEN - 1);
184    stem.push('-');
185    stem.push_str(&sha256_hex(name.as_bytes())[..DIGEST_LEN]);
186    stem
187}
188
189/// Whether a stem already carries the disambiguating suffix shape. Such a
190/// name gets a suffix of its own, so no name can be chosen to collide with
191/// another name's disambiguated stem.
192fn ends_with_digest(stem: &str) -> bool {
193    stem.len() > DIGEST_LEN
194        && stem.as_bytes()[stem.len() - DIGEST_LEN - 1] == b'-'
195        && stem[stem.len() - DIGEST_LEN..]
196            .bytes()
197            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
198}
199
200impl Pipeline {
201    /// Build every requested projection and commit each produced file at
202    /// `out_dir` through the no-replace destination. Several cases share one
203    /// output directory in a batch export, so the directory is created when
204    /// absent and unrelated entries survive; no existing entry is ever
205    /// replaced, and a refused run removes the files it had created, leaving
206    /// the directory as it was.
207    pub fn run(&self, net: &BalancedNetwork, out_dir: impl AsRef<Path>) -> Result<PipelineOutputs> {
208        let out_dir = out_dir.as_ref();
209        let view = IndexedNetwork::new(net);
210        // The network name comes from input content, so it must not steer the
211        // output path: a name like `../../x` or `/abs/x` would otherwise write
212        // outside `out_dir`. Filenames use the sanitized stem; the metadata
213        // keeps the original name.
214        let stem = sanitize_stem(view.name());
215
216        let mut inventory: Vec<(String, Vec<u8>)> = Vec::new();
217        let mut matrices_meta = Vec::new();
218        let mut ybus_cache = None;
219
220        for &kind in &self.matrices {
221            let name = format!("{stem}_{}.mtx", kind.slug());
222            let matrix = self.build_for_run(&view, kind, &mut ybus_cache)?;
223            let stats = calc_matrix_stats_for_kind(&matrix, &view, kind, &self.options);
224            let sddm = check_sddm(&matrix);
225            matrices_meta.push(MatrixMetadata {
226                kind: kind.slug().to_string(),
227                file: name.clone(),
228                stats,
229                sddm,
230            });
231            inventory.push((name, crate::io::mtx::to_mtx_bytes(&matrix)?));
232
233            // RHS for matrices that take a RHS of length n (skip LACPF which is 2n).
234            if let Some(rhs) = self.build_rhs(&view, kind) {
235                inventory.push((
236                    format!("{stem}_{}_rhs.mtx", kind.slug()),
237                    crate::io::mtx::to_vector_mtx_bytes(&rhs)?,
238                ));
239            }
240        }
241
242        // Shunt vector as a sidecar (not always meaningful, but cheap).
243        let base = view.per_unit_base();
244        let shunt: Vec<f64> = view.bs().iter().map(|&b| b / base).collect();
245        inventory.push((
246            format!("{stem}_shunt.mtx"),
247            crate::io::mtx::to_vector_mtx_bytes(&shunt)?,
248        ));
249
250        let metadata = CaseMetadata {
251            case_name: view.name().to_string(),
252            source_file: self
253                .source_file
254                .as_ref()
255                .and_then(|p| p.to_str())
256                .map(str::to_string),
257            source_sha256: self
258                .source_file
259                .as_ref()
260                .and_then(|p| std::fs::read(p).ok())
261                .map(|b| sha256_hex(&b)),
262            base_mva: view.base_mva(),
263            n_buses: view.n(),
264            n_branches: view.branches().len(),
265            build_options: self.options.clone(),
266            matrices: matrices_meta,
267            powerio_version: env!("CARGO_PKG_VERSION").to_string(),
268        };
269        inventory.push((
270            format!("{stem}_meta.json"),
271            crate::io::meta::meta_json_bytes(&metadata)?,
272        ));
273
274        std::fs::create_dir_all(out_dir)?;
275        let mut files = Vec::new();
276        for (name, bytes) in inventory {
277            let target = out_dir.join(&name);
278            if let Err(error) = crate::io::mtx::commit_one_file(&target, bytes) {
279                for created in &files {
280                    let _ = std::fs::remove_file(created);
281                }
282                return Err(error);
283            }
284            files.push(target);
285        }
286
287        Ok(PipelineOutputs {
288            case_name: view.name().to_string(),
289            files,
290            metadata,
291        })
292    }
293
294    fn build_for_run(
295        &self,
296        case: &IndexedNetwork,
297        kind: MatrixKind,
298        ybus_cache: &mut Option<YbusCache>,
299    ) -> Result<sprs::CsMat<f64>> {
300        match kind {
301            MatrixKind::YbusG => take_ybus_g(case, &self.options, ybus_cache),
302            MatrixKind::YbusB => take_ybus_b(case, &self.options, ybus_cache),
303            _ => calc_matrix(case, kind, &self.options),
304        }
305    }
306
307    fn build_rhs(&self, case: &IndexedNetwork, kind: MatrixKind) -> Option<Vec<f64>> {
308        // No meaningful RHS for the 2n LACPF block or the structural adjacency.
309        if matches!(self.rhs, RhsKind::None)
310            || matches!(kind, MatrixKind::Lacpf | MatrixKind::Adjacency)
311        {
312            return None;
313        }
314        let n = case.n();
315        let v = match self.rhs {
316            RhsKind::Random => {
317                let mut rng = ChaCha8Rng::seed_from_u64(self.rng_seed.wrapping_add(kind as u64));
318                let dist = StandardUniform;
319                let mut v: Vec<f64> = (0..n)
320                    .map(|_| {
321                        let u: f64 = dist.sample(&mut rng);
322                        u - 0.5
323                    })
324                    .collect();
325                let mean = v.iter().sum::<f64>() / n as f64;
326                for x in &mut v {
327                    *x -= mean; // zero-mean for Laplacian compatibility
328                }
329                v
330            }
331            RhsKind::Injection => {
332                let base = case.per_unit_base();
333                match kind {
334                    MatrixKind::BPrime | MatrixKind::YbusG | MatrixKind::YbusB => {
335                        case.pd().iter().map(|&p| -p / base).collect()
336                    }
337                    MatrixKind::BDoublePrime => case.qd().iter().map(|&q| -q / base).collect(),
338                    MatrixKind::Lacpf | MatrixKind::Adjacency => unreachable!(),
339                }
340            }
341            RhsKind::None => unreachable!(),
342        };
343        Some(v)
344    }
345}
346
347struct YbusCache {
348    g: Option<sprs::CsMat<f64>>,
349    b: Option<sprs::CsMat<f64>>,
350}
351
352fn fill_ybus_cache(
353    view: &IndexedNetwork,
354    opts: &BuildOptions,
355    ybus_cache: &mut Option<YbusCache>,
356) -> Result<()> {
357    let parts = calc_admittance_matrix(view, opts)?;
358    *ybus_cache = Some(YbusCache {
359        g: Some(parts.g),
360        b: Some(parts.b),
361    });
362    Ok(())
363}
364
365fn take_ybus_g(
366    view: &IndexedNetwork,
367    opts: &BuildOptions,
368    ybus_cache: &mut Option<YbusCache>,
369) -> Result<sprs::CsMat<f64>> {
370    if ybus_cache.as_ref().is_none_or(|c| c.g.is_none()) {
371        fill_ybus_cache(view, opts, ybus_cache)?;
372    }
373    Ok(ybus_cache
374        .as_mut()
375        .and_then(|c| c.g.take())
376        .expect("Ybus cache was just filled, so the real part must be present"))
377}
378
379fn take_ybus_b(
380    view: &IndexedNetwork,
381    opts: &BuildOptions,
382    ybus_cache: &mut Option<YbusCache>,
383) -> Result<sprs::CsMat<f64>> {
384    if ybus_cache.as_ref().is_none_or(|c| c.b.is_none()) {
385        fill_ybus_cache(view, opts, ybus_cache)?;
386    }
387    let b = ybus_cache
388        .as_mut()
389        .and_then(|c| c.b.take())
390        .expect("Ybus cache was just filled, so the imaginary part must be present");
391    Ok(negate_into(b))
392}
393
394/// Calculate the square matrix for one [`MatrixKind`] from an indexed network.
395/// This is the dispatch shared by the [`Pipeline`], the `verify` CLI command,
396/// and the TUI inspect screen, so the `YbusB = -Im(Y_bus)` sign lives in one
397/// place.
398pub fn calc_matrix(
399    view: &IndexedNetwork,
400    kind: MatrixKind,
401    opts: &BuildOptions,
402) -> Result<sprs::CsMat<f64>> {
403    match kind {
404        MatrixKind::BPrime => calc_bprime_matrix(view, opts),
405        MatrixKind::BDoublePrime => calc_bdoubleprime_matrix(view, opts),
406        MatrixKind::YbusG => calc_admittance_matrix(view, opts).map(|p| p.g),
407        MatrixKind::YbusB => calc_admittance_matrix(view, opts).map(|p| negate_into(p.b)),
408        MatrixKind::Lacpf => calc_lacpf_matrix(view, opts),
409        MatrixKind::Adjacency => calc_adjacency_matrix(view),
410    }
411}
412
413pub fn select_zero_impedance_rule_for_kind(
414    kind: MatrixKind,
415    opts: &BuildOptions,
416) -> Option<ZeroImpedanceRule> {
417    match kind {
418        MatrixKind::BPrime => Some(match opts.scheme {
419            crate::matrix::Scheme::Bx => ZeroImpedanceRule::Series,
420            crate::matrix::Scheme::Xb => ZeroImpedanceRule::Reactance,
421        }),
422        MatrixKind::BDoublePrime => Some(match opts.scheme {
423            crate::matrix::Scheme::Bx => ZeroImpedanceRule::Reactance,
424            crate::matrix::Scheme::Xb => ZeroImpedanceRule::Series,
425        }),
426        MatrixKind::YbusG | MatrixKind::YbusB | MatrixKind::Lacpf => {
427            Some(ZeroImpedanceRule::Series)
428        }
429        MatrixKind::Adjacency => None,
430    }
431}
432
433pub fn calc_zero_impedance_skips_for_kind(
434    view: &IndexedNetwork,
435    kind: MatrixKind,
436    opts: &BuildOptions,
437) -> ZeroImpedanceSkips {
438    if !opts.skip_zero_impedance {
439        return ZeroImpedanceSkips::default();
440    }
441    select_zero_impedance_rule_for_kind(kind, opts)
442        .map_or_else(ZeroImpedanceSkips::default, |rule| {
443            calc_zero_impedance_skips(view, rule)
444        })
445}
446
447pub fn calc_matrix_stats_for_kind(
448    matrix: &sprs::CsMat<f64>,
449    view: &IndexedNetwork,
450    kind: MatrixKind,
451    opts: &BuildOptions,
452) -> MatrixStats {
453    MatrixStats::from_csr(matrix)
454        .with_zero_impedance_skips(calc_zero_impedance_skips_for_kind(view, kind, opts))
455}
456
457fn sha256_hex(bytes: &[u8]) -> String {
458    use sha2::{Digest, Sha256};
459    use std::fmt::Write as _;
460    let mut hasher = Sha256::new();
461    hasher.update(bytes);
462    let digest = hasher.finalize();
463    let mut out = String::with_capacity(digest.len() * 2);
464    for byte in digest {
465        let _ = write!(out, "{byte:02x}");
466    }
467    out
468}
469
470#[cfg(test)]
471mod tests {
472    use super::{Pipeline, sanitize_stem};
473    use std::path::Path;
474
475    #[test]
476    fn a_pipeline_run_never_replaces_an_existing_entry() {
477        let spec = crate::synth::SynthSpec {
478            n: 8,
479            ..Default::default()
480        };
481        let net = crate::synth::generate(&spec);
482        let pipeline = Pipeline::default();
483
484        // A fresh target commits the complete inventory.
485        let base = tempfile::tempdir().unwrap();
486        let fresh = base.path().join("matrices");
487        let outputs = pipeline.run(&net, &fresh).unwrap();
488        assert_ne!(outputs.files, [] as [std::path::PathBuf; 0]);
489        for file in &outputs.files {
490            assert!(file.is_file(), "{file:?}");
491        }
492
493        // A batch shares one output directory: an unrelated entry survives a
494        // later run beside it.
495        let shared = base.path().join("shared");
496        std::fs::create_dir_all(&shared).unwrap();
497        std::fs::write(shared.join("unrelated.m"), b"unrelated").unwrap();
498        pipeline.run(&net, &shared).unwrap();
499        assert_eq!(
500            std::fs::read(shared.join("unrelated.m")).unwrap(),
501            b"unrelated"
502        );
503
504        // An entry at a produced name refuses the run, keeps its bytes, and
505        // the refused run removes the files it had created.
506        let meta_name = outputs
507            .files
508            .iter()
509            .find_map(|file| {
510                let name = file.file_name()?.to_str()?;
511                name.ends_with("_meta.json").then(|| name.to_owned())
512            })
513            .expect("the run produces a metadata file");
514        let blocked = base.path().join("blocked");
515        std::fs::create_dir_all(&blocked).unwrap();
516        std::fs::write(blocked.join(&meta_name), b"precious").unwrap();
517        let error = pipeline.run(&net, &blocked).unwrap_err();
518        assert!(matches!(error, crate::Error::Commit(_)), "{error:?}");
519        assert_eq!(
520            std::fs::read(blocked.join(&meta_name)).unwrap(),
521            b"precious"
522        );
523        let residue: Vec<_> = std::fs::read_dir(&blocked)
524            .unwrap()
525            .filter_map(std::result::Result::ok)
526            .filter(|entry| entry.file_name().to_str() != Some(meta_name.as_str()))
527            .map(|entry| entry.file_name())
528            .collect();
529        assert!(residue.is_empty(), "{residue:?}");
530
531        // A symbolic link at a produced name is likewise never written
532        // through: the link survives and its designated file keeps its bytes.
533        #[cfg(unix)]
534        {
535            let designated = base.path().join("designated.json");
536            std::fs::write(&designated, b"designated").unwrap();
537            let linked = base.path().join("linked");
538            std::fs::create_dir_all(&linked).unwrap();
539            std::os::unix::fs::symlink(&designated, linked.join(&meta_name)).unwrap();
540            let error = pipeline.run(&net, &linked).unwrap_err();
541            assert!(matches!(error, crate::Error::Commit(_)), "{error:?}");
542            assert!(
543                std::fs::symlink_metadata(linked.join(&meta_name))
544                    .unwrap()
545                    .file_type()
546                    .is_symlink()
547            );
548            assert_eq!(std::fs::read(&designated).unwrap(), b"designated");
549            assert_eq!(std::fs::metadata(&designated).unwrap().len(), 10);
550        }
551    }
552
553    #[test]
554    fn sanitize_stem_confines_names_to_out_dir() {
555        // Every result must be a single component with no separators, so
556        // `out_dir.join(stem)` cannot escape `out_dir`.
557        for name in [
558            "../../etc/passwd",
559            "/abs/path",
560            "..",
561            ".",
562            "..\\..\\win",
563            "",
564            "a/b/c",
565        ] {
566            let stem = sanitize_stem(name);
567            let joined = Path::new("out").join(&stem);
568            assert_eq!(
569                joined.components().count(),
570                2,
571                "{name:?} -> {stem:?} escaped out_dir as {joined:?}"
572            );
573            assert_ne!(stem, "");
574            assert!(stem != "." && stem != "..");
575        }
576    }
577
578    #[test]
579    fn sanitize_stem_keeps_ordinary_names() {
580        assert_eq!(sanitize_stem("case118"), "case118");
581        assert_eq!(sanitize_stem("ieee-13_bus.v2"), "ieee-13_bus.v2");
582    }
583
584    #[test]
585    fn sanitize_stem_separates_names_that_sanitize_alike() {
586        // `a/b` must not overwrite `a_b`'s files in a multi case export.
587        assert_ne!(sanitize_stem("a/b"), sanitize_stem("a_b"));
588        assert_ne!(sanitize_stem("a/b"), sanitize_stem("a\\b"));
589        assert_eq!(sanitize_stem("a_b"), "a_b");
590    }
591
592    #[test]
593    fn a_safe_name_cannot_impersonate_a_disambiguated_stem() {
594        // `.foo` sanitizes to `foo` and so carries a suffix. Naming a second
595        // case exactly that suffixed stem would overwrite it if safe names
596        // passed through unconditionally, and the suffix is derived from a
597        // published hash, so the second name takes no search to construct.
598        let unsafe_stem = sanitize_stem(".foo");
599        assert_ne!(sanitize_stem(&unsafe_stem), unsafe_stem);
600        assert_ne!(sanitize_stem(&unsafe_stem), sanitize_stem(".foo"));
601        // A name that merely contains a hyphen is untouched.
602        assert_eq!(sanitize_stem("ieee-13"), "ieee-13");
603        assert_eq!(sanitize_stem("case-deadbeef"), "case-deadbeef");
604    }
605
606    #[test]
607    fn sanitize_stem_applies_windows_filename_rules() {
608        // Trailing dots and reserved device names are invalid on Windows.
609        let trailing = sanitize_stem("case.");
610        assert!(!trailing.ends_with('.'), "{trailing:?}");
611        for name in ["con", "CON", "aux.4", "lpt9"] {
612            let stem = sanitize_stem(name);
613            let pre_dot = stem.split('.').next().unwrap();
614            assert!(
615                !super::WINDOWS_RESERVED
616                    .iter()
617                    .any(|r| pre_dot.eq_ignore_ascii_case(r)),
618                "{name:?} -> {stem:?} is still a reserved device name"
619            );
620        }
621    }
622
623    #[test]
624    fn sanitize_stem_caps_the_length() {
625        let long = "x".repeat(4096);
626        // Stem plus hash suffix stays within a filename component budget.
627        assert!(sanitize_stem(&long).len() <= super::MAX_STEM_LEN);
628    }
629}