Skip to main content

suno_core/
naming.rs

1//! Pure naming and relative path rendering for [`Clip`] values.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10use crate::Clip;
11use crate::error::{Error, Result};
12use crate::lineage::LineageContext;
13use crate::pathkey::canonical_path_key;
14
15/// The default relative path template.
16///
17/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
18/// `{id}`, `{id8}` (first 8 characters of the clip id), `{root_id8}` (first 8 of
19/// the resolved lineage root id), `{track}` (the album track number, e.g. `7`),
20/// and `{track2}` (that number zero-padded to two digits, e.g. `07`). An empty
21/// placeholder swallows the separator run that follows it, so an unnumbered
22/// `{track2}` leaves no orphan ` - `. Empty path segments are dropped after
23/// rendering.
24///
25/// The default prefixes the file name with the two-digit track number, embeds
26/// `[{id8}]` so same-title clips never collide, and folders under `{album}`,
27/// which resolves to the lineage root's title (else the clip's own title).
28pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{track2} - {creator}-{title} [{id8}]";
29const DEFAULT_MAX_COMPONENT_LEN: usize = 80;
30
31const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(rename_all = "lowercase")]
36pub enum CharacterSet {
37    #[default]
38    Unicode,
39    Ascii,
40}
41
42impl FromStr for CharacterSet {
43    type Err = Error;
44
45    // Case-sensitive to match serde (TOML) and the JSON schema, which accept
46    // lowercase only; the env tier (`SUNO_CHARACTER_SET`) parses through here.
47    fn from_str(s: &str) -> Result<Self> {
48        match s {
49            "unicode" => Ok(Self::Unicode),
50            "ascii" => Ok(Self::Ascii),
51            other => Err(Error::Config(format!(
52                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
53            ))),
54        }
55    }
56}
57
58impl fmt::Display for CharacterSet {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::Unicode => f.write_str("unicode"),
62            Self::Ascii => f.write_str("ascii"),
63        }
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct NamingConfig {
69    pub template: String,
70    pub character_set: CharacterSet,
71    pub max_component_len: usize,
72}
73
74impl Default for NamingConfig {
75    fn default() -> Self {
76        Self {
77            template: DEFAULT_TEMPLATE.to_string(),
78            character_set: CharacterSet::Unicode,
79            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
80        }
81    }
82}
83
84#[derive(Debug, Clone, Copy)]
85pub struct NamingRequest<'a> {
86    pub clip: &'a Clip,
87    pub lineage: &'a LineageContext,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RenderedName {
92    pub relative_path: PathBuf,
93    pub base_name: String,
94}
95
96/// Render one clip's relative path in isolation.
97///
98/// Unlike [`render_clip_names`], this does **not** apply whole-library id
99/// disambiguation: a lone clip cannot know whether another clip shares its
100/// `{id8}`, so only the plural form (reached via
101/// [`build_desired`](crate::desired::build_desired)) appends the stable full-id
102/// suffix for an id8-twin. Callers that need that stability must batch through
103/// the plural form.
104pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
105    let album = album_component(request, config);
106    render_with_album(request, config, &album)
107}
108
109/// Render every request's relative path as a batch, disambiguating collisions.
110///
111/// The `{id8}` suffix is **whole-library-stable**: a clip whose id is in
112/// `colliding_ids` (the store-derived set of clip ids sharing an `{id8}` with
113/// another distinct clip, see
114/// [`colliding_clip_ids`](crate::LineageStore::colliding_clip_ids)) gets
115/// the full clip id appended regardless of which other clips are in this batch,
116/// so trashing or `--limit`-excluding an id8-twin never renames the kept clip
117/// (#356). The two trailing batch passes remain as a correctness backstop for
118/// template shapes the id8 set cannot see (no `{id8}`/`{id}` placeholder) and for
119/// case/NFC collisions.
120pub fn render_clip_names(
121    requests: &[NamingRequest<'_>],
122    config: &NamingConfig,
123    colliding_albums: &BTreeSet<String>,
124    colliding_ids: &BTreeSet<String>,
125) -> Vec<RenderedName> {
126    let albums = disambiguated_albums(requests, config, colliding_albums);
127    let mut rendered = requests
128        .iter()
129        .zip(&albums)
130        .map(|(request, album)| render_with_album(*request, config, album))
131        .collect::<Vec<_>>();
132
133    // Whole-library id pass (#356): append the full clip id to any clip whose
134    // id8 is shared archive-wide, before the batch passes so they never re-flag
135    // an already-disambiguated pair. The decision depends only on the
136    // store-derived set, never on the batch, so it is stable across runs.
137    for (index, request) in requests.iter().enumerate() {
138        if colliding_ids.contains(&request.clip.id) {
139            rendered[index] = with_suffix(
140                rendered[index].clone(),
141                &request.clip.id,
142                config.character_set,
143                config.max_component_len,
144            );
145        }
146    }
147
148    // Two passes so distinct clips never land on one path: the first keys on the
149    // exact rendered string, the second on the filesystem-canonical form (NFC +
150    // lowercase), catching paths that differ only by case or NFD/NFC and would
151    // collide on case-insensitive or NFC-normalising filesystems (Windows, macOS).
152    for apply_canonical in [false, true] {
153        let mut collisions = BTreeMap::<String, Vec<usize>>::new();
154        for (index, name) in rendered.iter().enumerate() {
155            let key = if apply_canonical {
156                canonical_path_key(&name.relative_path.to_string_lossy())
157            } else {
158                name.relative_path.to_string_lossy().into_owned()
159            };
160            collisions.entry(key).or_default().push(index);
161        }
162        for indexes in collisions.into_values().filter(|v| v.len() > 1) {
163            for index in indexes {
164                let suffix = &requests[index].clip.id;
165                rendered[index] = with_suffix(
166                    rendered[index].clone(),
167                    suffix,
168                    config.character_set,
169                    config.max_component_len,
170                );
171            }
172        }
173    }
174
175    rendered
176}
177
178/// The album path component for every request, with a clip whose root title
179/// collides across distinct roots disambiguated by `[{root_id8}]`.
180///
181/// Distinct roots must never share an album folder. `colliding_albums` is the
182/// authoritative set of such shared root titles, computed once from the whole
183/// lineage store, so the decision is stable across runs and independent of the
184/// batch. A clip whose resolved album is in that set gets its root's short id
185/// appended; every other clip keeps the bare album and groups with its
186/// same-root siblings.
187fn disambiguated_albums(
188    requests: &[NamingRequest<'_>],
189    config: &NamingConfig,
190    colliding_albums: &BTreeSet<String>,
191) -> Vec<String> {
192    requests
193        .iter()
194        .map(|request| album_for(*request, config, colliding_albums))
195        .collect()
196}
197
198/// The (possibly disambiguated) album component for one request.
199fn album_for(
200    request: NamingRequest<'_>,
201    config: &NamingConfig,
202    colliding_albums: &BTreeSet<String>,
203) -> String {
204    let raw_album = request.lineage.album(&title_name(request.clip));
205    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
206    if colliding_albums.contains(raw_album.trim()) {
207        let suffix = truncate_chars(&request.lineage.root_id, 8);
208        append_suffix(
209            &album,
210            &suffix,
211            config.character_set,
212            config.max_component_len,
213        )
214    } else {
215        album
216    }
217}
218
219/// The sanitised album component: the resolved lineage album (root title, else
220/// the clip's own title).
221fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
222    let album = request.lineage.album(&title_name(request.clip));
223    sanitise_component(&album, config.character_set, config.max_component_len)
224}
225
226/// Render one clip's path with an already-resolved album component.
227fn render_with_album(
228    request: NamingRequest<'_>,
229    config: &NamingConfig,
230    album: &str,
231) -> RenderedName {
232    let clip = request.clip;
233    let creator = sanitise_component(
234        &creator_name(clip),
235        config.character_set,
236        config.max_component_len,
237    );
238    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
239    let title = sanitise_component(
240        &title_name(clip),
241        config.character_set,
242        config.max_component_len,
243    );
244    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
245    let id8 = sanitise_component(
246        &truncate_chars(&clip.id, 8),
247        CharacterSet::Ascii,
248        config.max_component_len,
249    );
250    let root_id8 = sanitise_component(
251        &truncate_chars(&request.lineage.root_id, 8),
252        CharacterSet::Ascii,
253        config.max_component_len,
254    );
255    let track = request.lineage.track;
256    let track_raw = if track > 0 {
257        track.to_string()
258    } else {
259        String::new()
260    };
261    let track_pad = if track > 0 {
262        format!("{track:02}")
263    } else {
264        String::new()
265    };
266    let substitutions = SegmentSubstitutions {
267        creator: &creator,
268        handle: &handle,
269        album,
270        title: &title,
271        root_id8: &root_id8,
272        id8: &id8,
273        id: &id,
274        track: &track_raw,
275        track2: &track_pad,
276    };
277    let mut components = config
278        .template
279        .split('/')
280        .filter_map(|segment| {
281            let rendered = substitute_segment(segment, substitutions);
282            let sanitised = sanitise_segment(
283                &rendered,
284                config.character_set,
285                config.max_component_len,
286                [id8.as_str(), root_id8.as_str()],
287            );
288            (!sanitised.is_empty()).then_some(sanitised)
289        })
290        .collect::<Vec<_>>();
291
292    if components.is_empty() {
293        components.push(title.clone());
294    }
295
296    let mut base_name = components
297        .pop()
298        .filter(|value| !value.is_empty())
299        .unwrap_or_else(|| title.clone());
300    // Guarantee a non-empty file name even when every token sanitises away.
301    if base_name.is_empty() {
302        base_name = append_suffix(
303            &base_name,
304            &clip.id,
305            config.character_set,
306            config.max_component_len,
307        );
308    }
309
310    let mut relative_path = PathBuf::new();
311    for component in components {
312        relative_path.push(component);
313    }
314
315    relative_path.push(&base_name);
316    RenderedName {
317        relative_path,
318        base_name,
319    }
320}
321
322#[derive(Clone, Copy)]
323struct SegmentSubstitutions<'a> {
324    creator: &'a str,
325    handle: &'a str,
326    album: &'a str,
327    title: &'a str,
328    root_id8: &'a str,
329    id8: &'a str,
330    id: &'a str,
331    track: &'a str,
332    track2: &'a str,
333}
334
335fn substitute_segment(segment: &str, substitutions: SegmentSubstitutions<'_>) -> String {
336    let mut rendered = String::with_capacity(segment.len());
337    let mut remainder = segment;
338    while let Some(start) = remainder.find('{') {
339        rendered.push_str(&remainder[..start]);
340        remainder = &remainder[start..];
341        if let Some((token_len, value)) = placeholder_match(remainder, substitutions) {
342            rendered.push_str(value);
343            remainder = &remainder[token_len..];
344            // An empty placeholder swallows the separator run that follows it, so
345            // an optional token (e.g. an unnumbered `{track2}`) leaves no orphan
346            // separator like a leading " - ".
347            if value.is_empty() {
348                remainder = remainder.trim_start_matches([' ', '-', '_', '.']);
349            }
350        } else {
351            rendered.push('{');
352            remainder = &remainder[1..];
353        }
354    }
355    rendered.push_str(remainder);
356    rendered
357}
358
359fn placeholder_match<'a>(
360    segment: &str,
361    substitutions: SegmentSubstitutions<'a>,
362) -> Option<(usize, &'a str)> {
363    if segment.starts_with("{creator}") {
364        Some(("{creator}".len(), substitutions.creator))
365    } else if segment.starts_with("{handle}") {
366        Some(("{handle}".len(), substitutions.handle))
367    } else if segment.starts_with("{album}") {
368        Some(("{album}".len(), substitutions.album))
369    } else if segment.starts_with("{title}") {
370        Some(("{title}".len(), substitutions.title))
371    } else if segment.starts_with("{root_id8}") {
372        Some(("{root_id8}".len(), substitutions.root_id8))
373    } else if segment.starts_with("{id8}") {
374        Some(("{id8}".len(), substitutions.id8))
375    } else if segment.starts_with("{id}") {
376        Some(("{id}".len(), substitutions.id))
377    } else if segment.starts_with("{track2}") {
378        Some(("{track2}".len(), substitutions.track2))
379    } else if segment.starts_with("{track}") {
380        Some(("{track}".len(), substitutions.track))
381    } else {
382        None
383    }
384}
385
386fn with_suffix(
387    mut rendered: RenderedName,
388    suffix: &str,
389    character_set: CharacterSet,
390    max_component_len: usize,
391) -> RenderedName {
392    rendered.base_name = append_suffix(
393        &rendered.base_name,
394        suffix,
395        character_set,
396        max_component_len,
397    );
398    rendered.relative_path.set_file_name(&rendered.base_name);
399    rendered
400}
401
402fn creator_name(clip: &Clip) -> String {
403    non_blank(&clip.display_name)
404        .or_else(|| non_blank(&clip.handle))
405        .unwrap_or("Unknown Creator")
406        .to_string()
407}
408
409fn title_name(clip: &Clip) -> String {
410    let title = clip.title.trim();
411    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
412        "Untitled".to_string()
413    } else {
414        title.to_string()
415    }
416}
417
418fn append_suffix(
419    base: &str,
420    suffix: &str,
421    character_set: CharacterSet,
422    max_component_len: usize,
423) -> String {
424    let suffix_pattern = format!(" [{suffix}]");
425    if base.ends_with(&suffix_pattern) {
426        return sanitise_component(base, character_set, max_component_len);
427    }
428
429    let max_len =
430        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
431    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
432    // Sanitise the base before measuring it. The character set can expand a
433    // character (ascii turns `ß` into `ss`), so budgeting the cut on the raw
434    // length could let the sanitised prefix grow back over the room reserved for
435    // the suffix and slice through it again (#120).
436    let base = sanitise_component(base, character_set, max_len);
437    let truncated = truncate_chars(base.trim_end(), allowed);
438    let combined = format!("{truncated}{suffix_pattern}");
439    sanitise_component(&combined, character_set, max_len)
440}
441
442/// Sanitise a rendered template segment, preserving a trailing ` [id]`
443/// disambiguator (the `[{id8}]` or `[{root_id8}]` the template embeds) when the
444/// segment would otherwise be truncated through it. Only the title portion is
445/// shortened, so two long-titled siblings keep their distinguishing id and the
446/// closing bracket is never left unbalanced (#120). A segment that does not end
447/// in a disambiguator is sanitised exactly as before.
448fn sanitise_segment(
449    rendered: &str,
450    character_set: CharacterSet,
451    max_component_len: usize,
452    disambiguators: [&str; 2],
453) -> String {
454    for suffix in disambiguators {
455        if suffix.is_empty() {
456            continue;
457        }
458        let pattern = format!(" [{suffix}]");
459        if let Some(prefix) = rendered.strip_suffix(&pattern) {
460            return append_suffix(prefix, suffix, character_set, max_component_len);
461        }
462    }
463    sanitise_component(rendered, character_set, max_component_len)
464}
465
466/// Sanitise a free-form playlist name into a single safe path component.
467///
468/// Applies the same Unicode filtering and length cap as clip path components
469/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
470/// playlist file name obeys the same filesystem rules as the rest of the
471/// library. An empty or fully-stripped name falls back to `playlist` so the
472/// caller always has a non-empty stem to append `.m3u8` to.
473pub fn sanitise_name(name: &str) -> String {
474    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
475    if cleaned.is_empty() {
476        "playlist".to_string()
477    } else {
478        cleaned
479    }
480}
481
482/// The `.stems` sub-folder that sits beside a song's audio file.
483///
484/// `base` is the song's extensionless relative path (the same value the audio
485/// and its sidecars are built from), so the folder is `{base}.stems`. It cannot
486/// collide with the audio file (`{base}.<ext>`) or any `{base}.<sidecar>`
487/// because the `.stems` suffix is distinct, mirroring the sidecar convention.
488pub fn stems_folder(base: &str) -> String {
489    format!("{base}.stems")
490}
491
492/// The relative path of one stem file inside a song's [`stems_folder`].
493///
494/// Base+label+disambiguation rather than label-only, because Auto Split can
495/// mislabel stems and Advanced Split yields ~100 instruments, so blank or
496/// duplicate labels are expected. The file is
497/// `{song file name} - {label} [{stem id8}].{ext}`; ` - {label}` is dropped when
498/// the label sanitises to empty, and the `[{stem id8}]` disambiguator (first 8
499/// of the stable stem id) keeps blank or duplicate labels collision-free. Every
500/// component runs through the same [`sanitise_component`] filter, honouring
501/// `character_set`.
502pub fn stem_file_path(
503    base: &str,
504    label: &str,
505    stem_id: &str,
506    ext: &str,
507    character_set: CharacterSet,
508) -> String {
509    let folder = stems_folder(base);
510    // The song's own file-name stem (the last path component of `base`), reused
511    // so a stem stays identifiable even when viewed outside its `.stems` folder.
512    let song_stem = base.rsplit('/').next().unwrap_or(base);
513    let label = sanitise_component(label, character_set, DEFAULT_MAX_COMPONENT_LEN);
514    let id8 = sanitise_component(
515        &truncate_chars(stem_id, 8),
516        CharacterSet::Ascii,
517        DEFAULT_MAX_COMPONENT_LEN,
518    );
519
520    let mut name = song_stem.to_string();
521    if !label.is_empty() {
522        name.push_str(" - ");
523        name.push_str(&label);
524    }
525    if !id8.is_empty() {
526        name.push_str(" [");
527        name.push_str(&id8);
528        name.push(']');
529    }
530    // A degenerate base (empty song stem, blank label, empty id) must still
531    // yield a usable name rather than a hidden dotfile.
532    if name.trim().is_empty() {
533        name = "stem".to_string();
534    }
535    format!("{folder}/{name}.{}", sanitise_ext(ext))
536}
537
538/// Reduce a candidate extension to a safe lowercase alphanumeric token,
539/// defaulting to `mp3` when it is empty or fully stripped. The caller passes the
540/// resolved stem format's extension (`wav` or `mp3`); stems are stored RAW.
541fn sanitise_ext(ext: &str) -> String {
542    let cleaned: String = ext
543        .trim_start_matches('.')
544        .chars()
545        .filter(|c| c.is_ascii_alphanumeric())
546        .flat_map(char::to_lowercase)
547        .take(8)
548        .collect();
549    if cleaned.is_empty() {
550        "mp3".to_string()
551    } else {
552        cleaned
553    }
554}
555
556fn sanitise_component(
557    value: &str,
558    character_set: CharacterSet,
559    max_component_len: usize,
560) -> String {
561    // Single pass: map each char to its charset-safe form, collapsing runs of
562    // whitespace to one space and dropping leading/trailing whitespace.
563    let mut collapsed = String::with_capacity(value.len());
564    let mut pending_space = false;
565    let push = |out: char, buf: &mut String, pending: &mut bool| {
566        if out.is_whitespace() {
567            *pending = !buf.is_empty();
568        } else {
569            if *pending {
570                buf.push(' ');
571            }
572            *pending = false;
573            buf.push(out);
574        }
575    };
576    match character_set {
577        CharacterSet::Unicode => {
578            for ch in value.chars() {
579                push(unicode_char(ch), &mut collapsed, &mut pending_space);
580            }
581        }
582        CharacterSet::Ascii => {
583            for ch in value.chars() {
584                for out in ascii_chars(ch) {
585                    push(out, &mut collapsed, &mut pending_space);
586                }
587            }
588        }
589    }
590
591    let trimmed = collapsed.trim_matches([' ', '.']);
592    if trimmed.is_empty() {
593        return String::new();
594    }
595
596    // Keep at most `max` characters, then trim any space or dot the cut exposed.
597    // Slice at a char boundary rather than truncating a copy.
598    let max = max_component_len.max(1);
599    let end = trimmed
600        .char_indices()
601        .nth(max)
602        .map_or(trimmed.len(), |(index, _)| index);
603    let result = trimmed[..end].trim_matches([' ', '.']);
604    if result.is_empty() {
605        return String::new();
606    }
607    if result == "." || result == ".." {
608        return "item".to_string();
609    }
610    let mut result = result.to_string();
611    if is_reserved_name(&result) {
612        // Guard the stem, not the whole component: `NUL.mp3` must become
613        // `NUL_.mp3`, since `NUL.mp3_` keeps `NUL` as its dot-stem and stays a
614        // Windows device name.
615        let stem_end = result.find('.').unwrap_or(result.len());
616        result.insert(stem_end, '_');
617    }
618    result
619}
620
621fn unicode_char(ch: char) -> char {
622    if matches!(
623        ch,
624        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
625    ) || ch.is_control()
626    {
627        ' '
628    } else {
629        ch
630    }
631}
632
633fn ascii_chars(ch: char) -> Vec<char> {
634    if ch.is_ascii() {
635        return vec![unicode_char(ch)];
636    }
637
638    match ch {
639        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
640        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
641        'Ç' => vec!['C'],
642        'ç' => vec!['c'],
643        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
644        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
645        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
646        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
647        'Ñ' => vec!['N'],
648        'ñ' => vec!['n'],
649        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
650        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
651        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
652        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
653        'Ý' | 'Ÿ' => vec!['Y'],
654        'ý' | 'ÿ' => vec!['y'],
655        'Æ' => vec!['A', 'E'],
656        'æ' => vec!['a', 'e'],
657        'Œ' => vec!['O', 'E'],
658        'œ' => vec!['o', 'e'],
659        'ß' => vec!['s', 's'],
660        _ => vec![' '],
661    }
662}
663
664fn truncate_chars(value: &str, max_len: usize) -> String {
665    value.chars().take(max_len).collect()
666}
667
668fn non_blank(value: &str) -> Option<&str> {
669    let trimmed = value.trim();
670    (!trimmed.is_empty()).then_some(trimmed)
671}
672
673/// Whether `value`'s stem is a Windows reserved device name (`CON`, `PRN`,
674/// `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`), matched case-insensitively.
675///
676/// Exposed to the crate so the naming-safety fuzz can assert no rendered path
677/// component is ever a reserved name; the sanitiser guarantees it at
678/// [`sanitise_component`].
679pub(crate) fn is_reserved_name(value: &str) -> bool {
680    let stem = value.split('.').next().unwrap_or(value);
681    // Every reserved device name is 3 (CON/PRN/AUX/NUL) or 4 (COMx/LPTx) ASCII
682    // bytes, so anything else cannot match without allocating an uppercased copy.
683    if !matches!(stem.len(), 3 | 4) {
684        return false;
685    }
686    const RESERVED: [&str; 22] = [
687        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
688        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
689    ];
690    RESERVED.iter().any(|name| name.eq_ignore_ascii_case(stem))
691}
692
693#[cfg(test)]
694mod tests;
695
696#[cfg(test)]
697mod proptests;