Skip to main content

devset_core/
name.rs

1//! The names profiles, sources, features and scaffolds go by, and `source/profile`.
2//!
3//! Every name is ASCII letters, digits, `-` and `_`, beginning with a letter or a digit, so it
4//! reads the same in TOML keys, on the command line and in paths.
5
6use alloc::borrow::Cow;
7use core::fmt;
8use core::str::FromStr;
9
10use derive_more::{AsRef, Display, Into};
11use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
12use serde::{Deserialize, Serialize};
13use serde_with::{DeserializeFromStr, SerializeDisplay};
14
15use crate::errors::NameError;
16
17/// Defines a name: a checked string, compared and sorted as written.
18macro_rules! name {
19    ($(#[$doc:meta])* $name:ident, $kind:literal) => {
20        $(#[$doc])*
21        #[derive(
22            Clone,
23            PartialEq,
24            Eq,
25            PartialOrd,
26            Ord,
27            Hash,
28            AsRef,
29            Display,
30            derive_more::Debug,
31            Into,
32            Serialize,
33            Deserialize,
34            JsonSchema,
35        )]
36        #[as_ref(str)]
37        #[debug("{_0:?}")]
38        #[into(String)]
39        #[serde(try_from = "String", into = "String")]
40        #[schemars(inline)]
41        pub struct $name(Box<str>);
42
43        impl $name {
44            /// The name as written.
45            #[must_use]
46            pub fn as_str(&self) -> &str {
47                &self.0
48            }
49        }
50
51        impl TryFrom<String> for $name {
52            type Error = NameError;
53
54            fn try_from(name: String) -> Result<Self, NameError> {
55                match check(&name, $kind) {
56                    Ok(()) => Ok(Self(name.into_boxed_str())),
57                    Err(rule) => Err(NameError { kind: $kind, name, rule }),
58                }
59            }
60        }
61
62        impl FromStr for $name {
63            type Err = NameError;
64
65            fn from_str(name: &str) -> Result<Self, NameError> {
66                Self::try_from(name.to_owned())
67            }
68        }
69
70        impl PartialEq<str> for $name {
71            fn eq(&self, other: &str) -> bool {
72                *self.0 == *other
73            }
74        }
75    };
76}
77
78name! {
79    /// A profile's name, unique in its source and among a target's layers.
80    ProfileName, "profile"
81}
82
83name! {
84    /// The name a target gives a source in `[sources]`.
85    SourceName, "source"
86}
87
88name! {
89    /// A feature of a profile; never `default`, which names the list of features on by default.
90    FeatureName, "feature"
91}
92
93name! {
94    /// A group of starter files in a profile's `[scaffolds]`.
95    ScaffoldName, "scaffold"
96}
97
98/// The first rule `name`, a name of `kind`, breaks.
99fn check(name: &str, kind: &str) -> Result<(), &'static str> {
100    let mut chars = name.chars();
101    if !chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) {
102        return Err("begin with a letter or a digit");
103    }
104    if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) {
105        return Err("use letters, digits, `-` and `_`");
106    }
107    if kind == "feature" && name == "default" {
108        return Err("`default` names the list of features on by default");
109    }
110    Ok(())
111}
112
113/// A profile in a named source, `source/profile`: how a target names a layer.
114#[derive(
115    Clone,
116    PartialEq,
117    Eq,
118    PartialOrd,
119    Ord,
120    Hash,
121    derive_more::Debug,
122    SerializeDisplay,
123    DeserializeFromStr,
124)]
125#[debug("{self}")]
126pub struct ProfileRef {
127    /// The source, as `[sources]` names it.
128    pub source: SourceName,
129    /// The profile, by its name in that source.
130    pub profile: ProfileName,
131}
132
133impl fmt::Display for ProfileRef {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(f, "{}/{}", self.source, self.profile)
136    }
137}
138
139impl FromStr for ProfileRef {
140    type Err = NameError;
141
142    fn from_str(written: &str) -> Result<Self, NameError> {
143        let unqualified = || NameError {
144            kind: "layer",
145            name: written.to_owned(),
146            rule: "write `source/profile`",
147        };
148        let (source, profile) = written.split_once('/').ok_or_else(unqualified)?;
149        Ok(Self { source: source.parse()?, profile: profile.parse()? })
150    }
151}
152
153/// A scaffold of a profile, `profile/group`: how the command line and `state.toml` name it.
154#[derive(
155    Clone,
156    PartialEq,
157    Eq,
158    PartialOrd,
159    Ord,
160    Hash,
161    derive_more::Debug,
162    SerializeDisplay,
163    DeserializeFromStr,
164)]
165#[debug("{self}")]
166pub struct ScaffoldId {
167    /// The profile.
168    pub profile: ProfileName,
169    /// The group, in its `[scaffolds]`.
170    pub group: ScaffoldName,
171}
172
173impl fmt::Display for ScaffoldId {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        write!(f, "{}/{}", self.profile, self.group)
176    }
177}
178
179impl FromStr for ScaffoldId {
180    type Err = NameError;
181
182    fn from_str(written: &str) -> Result<Self, NameError> {
183        let unqualified = || NameError {
184            kind: "scaffold",
185            name: written.to_owned(),
186            rule: "write `profile/group`",
187        };
188        let (profile, group) = written.split_once('/').ok_or_else(unqualified)?;
189        Ok(Self { profile: profile.parse()?, group: group.parse()? })
190    }
191}
192
193impl JsonSchema for ProfileRef {
194    fn schema_name() -> Cow<'static, str> {
195        "ProfileRef".into()
196    }
197
198    fn json_schema(_: &mut SchemaGenerator) -> Schema {
199        json_schema!({
200            "description": "A profile in a named source: `source/profile`.",
201            "type": "string",
202            "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*/[A-Za-z0-9][A-Za-z0-9_-]*$",
203        })
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{FeatureName, ProfileName, ProfileRef};
210
211    #[test]
212    fn names() {
213        for ok in ["rust", "cargo-deny", "a_b", "x1", "1x"] {
214            assert!(ProfileName::try_from(ok.to_owned()).is_ok(), "{ok} is a name");
215        }
216        for bad in ["", "-x", "_x", "a/b", "a b", "a.b", "é"] {
217            assert!(ProfileName::try_from(bad.to_owned()).is_err(), "{bad:?} is not");
218        }
219        assert!(FeatureName::try_from("default".to_owned()).is_err(), "`default` names the list");
220        assert!(ProfileName::try_from("default".to_owned()).is_ok(), "which a profile may be");
221    }
222
223    #[test]
224    fn profile_refs() {
225        let at: ProfileRef = "atxp/rust".parse().expect("a reference");
226        assert_eq!((at.source.as_str(), at.profile.as_str()), ("atxp", "rust"), "its parts");
227        assert_eq!(at.to_string(), "atxp/rust", "written as read");
228        for bad in ["rust", "atxp/", "/rust", "a/b/c"] {
229            assert!(bad.parse::<ProfileRef>().is_err(), "{bad} is not a reference");
230        }
231    }
232}