Skip to main content

nova_pm/
name.rs

1//! The rules for a package's name (spec
2//! `docs/superpowers/specs/2026-10-07-phase-3-0-foundations-design.md`
3//! §5.1).
4
5/// Names Windows reserves for devices. A package name becomes a directory
6/// and an executable, so it may be none of these, in any case.
7const WINDOWS_DEVICES: [&str; 22] = [
8    "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
9    "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
10];
11
12/// Check a package name: ASCII letters, digits, `-` and `_`, starting with
13/// a letter, at most 64 characters, and not a Windows device name. The
14/// error says which rule the name breaks.
15pub fn check_name(name: &str) -> Result<(), String> {
16    let Some(first) = name.chars().next() else {
17        return Err("a package name cannot be empty".to_string());
18    };
19    if !first.is_ascii_alphabetic() {
20        return Err(format!(
21            "package name `{name}` must start with an ASCII letter"
22        ));
23    }
24    if let Some(bad) = name
25        .chars()
26        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
27    {
28        return Err(format!(
29            "package name `{name}` contains {bad:?}; a name may use only ASCII letters, \
30             digits, `-` and `_`"
31        ));
32    }
33    if name.len() > 64 {
34        return Err(format!(
35            "package name `{name}` is {} characters long; the limit is 64",
36            name.len()
37        ));
38    }
39    if WINDOWS_DEVICES.contains(&name.to_ascii_lowercase().as_str()) {
40        return Err(format!("`{name}` is a name Windows reserves for a device"));
41    }
42    Ok(())
43}
44
45/// Whether `component`, one name in a path, is valid on Windows, macOS
46/// and Linux (spec
47/// `docs/superpowers/specs/2026-10-09-phase-3-3b-index-and-publishing-design.md`
48/// §6.5):
49/// - not empty, `.` or `..`;
50/// - none of `/ : \ < > " | ? *` or a control character;
51/// - no trailing `.` or space;
52/// - not a Windows device name, with or without an extension.
53pub fn is_portable(component: &str) -> bool {
54    if component.is_empty() || component == "." || component == ".." {
55        return false;
56    }
57    if component
58        .chars()
59        .any(|c| c.is_control() || "/:\\<>\"|?*".contains(c))
60    {
61        return false;
62    }
63    if component.ends_with('.') || component.ends_with(' ') {
64        return false;
65    }
66    let stem = component.split('.').next().unwrap_or(component);
67    !WINDOWS_DEVICES.contains(&stem.to_ascii_lowercase().as_str())
68}
69
70/// A package's import name: its name with each `-` replaced by `_` (spec
71/// `docs/superpowers/specs/2026-10-08-phase-3-3a-local-packages-design.md`
72/// §3.4), so `json-api` is imported as `import json_api`.
73pub fn import_name(name: &str) -> String {
74    name.replace('-', "_")
75}