Skip to main content

use_package_json/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::{collections::BTreeMap, error::Error};
6
7macro_rules! text_newtype {
8    ($name:ident) => {
9        #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
10        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11        pub struct $name(String);
12
13        impl $name {
14            /// Creates non-empty package metadata text.
15            ///
16            /// # Errors
17            ///
18            /// Returns [`PackageJsonTextError::Empty`] when `input` is empty after trimming.
19            pub fn new(input: &str) -> Result<Self, PackageJsonTextError> {
20                let trimmed = input.trim();
21                if trimmed.is_empty() {
22                    Err(PackageJsonTextError::Empty)
23                } else {
24                    Ok(Self(trimmed.to_string()))
25                }
26            }
27
28            /// Returns the stored text.
29            #[must_use]
30            pub fn as_str(&self) -> &str {
31                &self.0
32            }
33        }
34
35        impl fmt::Display for $name {
36            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37                formatter.write_str(self.as_str())
38            }
39        }
40
41        impl FromStr for $name {
42            type Err = PackageJsonTextError;
43
44            fn from_str(input: &str) -> Result<Self, Self::Err> {
45                Self::new(input)
46            }
47        }
48    };
49}
50
51/// Validated package name metadata.
52#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct PackageName(String);
55
56impl PackageName {
57    /// Creates lightly validated package name metadata.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`PackageJsonTextError`] when `input` is empty, contains whitespace, or has an invalid scoped shape.
62    pub fn new(input: &str) -> Result<Self, PackageJsonTextError> {
63        let trimmed = input.trim();
64        if trimmed.is_empty() {
65            return Err(PackageJsonTextError::Empty);
66        }
67        if trimmed.chars().any(char::is_whitespace) {
68            return Err(PackageJsonTextError::ContainsWhitespace);
69        }
70        if let Some(rest) = trimmed.strip_prefix('@') {
71            let Some((scope, name)) = rest.split_once('/') else {
72                return Err(PackageJsonTextError::InvalidScopedName);
73            };
74            if scope.is_empty() || name.is_empty() || name.contains('/') {
75                return Err(PackageJsonTextError::InvalidScopedName);
76            }
77        } else if trimmed.contains('/') {
78            return Err(PackageJsonTextError::InvalidName);
79        }
80        Ok(Self(trimmed.to_string()))
81    }
82
83    /// Returns the package name.
84    #[must_use]
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88
89    /// Returns whether this package name is scoped.
90    #[must_use]
91    pub fn is_scoped(&self) -> bool {
92        self.0.starts_with('@')
93    }
94}
95
96impl fmt::Display for PackageName {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str(self.as_str())
99    }
100}
101
102impl FromStr for PackageName {
103    type Err = PackageJsonTextError;
104
105    fn from_str(input: &str) -> Result<Self, Self::Err> {
106        Self::new(input)
107    }
108}
109
110impl TryFrom<&str> for PackageName {
111    type Error = PackageJsonTextError;
112
113    fn try_from(value: &str) -> Result<Self, Self::Error> {
114        Self::new(value)
115    }
116}
117
118text_newtype!(PackageVersion);
119text_newtype!(PackageScriptName);
120text_newtype!(PackageScript);
121
122/// Dependency map keyed by package name.
123pub type DependencyMap = BTreeMap<PackageName, PackageVersion>;
124
125/// `package.json` dependency section kind.
126#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
127#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
128pub enum DependencyKind {
129    Dependencies,
130    DevDependencies,
131    PeerDependencies,
132    OptionalDependencies,
133    BundleDependencies,
134}
135
136impl DependencyKind {
137    /// Returns the `package.json` field name.
138    #[must_use]
139    pub const fn as_str(self) -> &'static str {
140        match self {
141            Self::Dependencies => "dependencies",
142            Self::DevDependencies => "devDependencies",
143            Self::PeerDependencies => "peerDependencies",
144            Self::OptionalDependencies => "optionalDependencies",
145            Self::BundleDependencies => "bundleDependencies",
146        }
147    }
148}
149
150/// `package.json` package type metadata.
151#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
152#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum PackageType {
154    Module,
155    CommonJs,
156}
157
158impl PackageType {
159    /// Returns the package type label.
160    #[must_use]
161    pub const fn as_str(self) -> &'static str {
162        match self {
163            Self::Module => "module",
164            Self::CommonJs => "commonjs",
165        }
166    }
167}
168
169/// Partial practical `package.json` metadata.
170#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
171#[derive(Clone, Debug, Default, Eq, PartialEq)]
172pub struct PackageJson {
173    name: Option<PackageName>,
174    version: Option<PackageVersion>,
175    package_type: Option<PackageType>,
176    scripts: BTreeMap<PackageScriptName, PackageScript>,
177    dependencies: BTreeMap<DependencyKind, DependencyMap>,
178}
179
180impl PackageJson {
181    /// Creates empty package metadata.
182    #[must_use]
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Sets the package name.
188    #[must_use]
189    pub fn with_name(mut self, name: PackageName) -> Self {
190        self.name = Some(name);
191        self
192    }
193
194    /// Sets the package version.
195    #[must_use]
196    pub fn with_version(mut self, version: PackageVersion) -> Self {
197        self.version = Some(version);
198        self
199    }
200
201    /// Sets the package type.
202    #[must_use]
203    pub const fn with_package_type(mut self, package_type: PackageType) -> Self {
204        self.package_type = Some(package_type);
205        self
206    }
207
208    /// Adds a script entry.
209    #[must_use]
210    pub fn with_script(mut self, name: PackageScriptName, script: PackageScript) -> Self {
211        self.scripts.insert(name, script);
212        self
213    }
214
215    /// Adds a dependency entry under a dependency kind.
216    #[must_use]
217    pub fn with_dependency(
218        mut self,
219        kind: DependencyKind,
220        name: PackageName,
221        version: PackageVersion,
222    ) -> Self {
223        self.dependencies
224            .entry(kind)
225            .or_default()
226            .insert(name, version);
227        self
228    }
229
230    /// Returns the optional package name.
231    #[must_use]
232    pub const fn name(&self) -> Option<&PackageName> {
233        self.name.as_ref()
234    }
235
236    /// Returns the optional package version.
237    #[must_use]
238    pub const fn version(&self) -> Option<&PackageVersion> {
239        self.version.as_ref()
240    }
241
242    /// Returns the optional package type.
243    #[must_use]
244    pub const fn package_type(&self) -> Option<PackageType> {
245        self.package_type
246    }
247
248    /// Returns script entries.
249    #[must_use]
250    pub const fn scripts(&self) -> &BTreeMap<PackageScriptName, PackageScript> {
251        &self.scripts
252    }
253
254    /// Returns dependency entries grouped by dependency kind.
255    #[must_use]
256    pub const fn dependencies(&self) -> &BTreeMap<DependencyKind, DependencyMap> {
257        &self.dependencies
258    }
259}
260
261/// Error returned when package metadata text is invalid.
262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
263pub enum PackageJsonTextError {
264    Empty,
265    ContainsWhitespace,
266    InvalidScopedName,
267    InvalidName,
268}
269
270impl fmt::Display for PackageJsonTextError {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::Empty => formatter.write_str("package metadata text cannot be empty"),
274            Self::ContainsWhitespace => {
275                formatter.write_str("package metadata text cannot contain whitespace")
276            }
277            Self::InvalidScopedName => {
278                formatter.write_str("scoped package names must look like @scope/name")
279            }
280            Self::InvalidName => formatter.write_str("package name has an invalid shape"),
281        }
282    }
283}
284
285impl Error for PackageJsonTextError {}
286
287#[cfg(test)]
288mod tests {
289    use super::{
290        DependencyKind, PackageJson, PackageJsonTextError, PackageName, PackageScript,
291        PackageScriptName, PackageType, PackageVersion,
292    };
293
294    #[test]
295    fn validates_package_names() -> Result<(), PackageJsonTextError> {
296        let scoped = PackageName::new("@rustuse/example")?;
297        assert!(scoped.is_scoped());
298        assert_eq!(
299            PackageName::new("bad name"),
300            Err(PackageJsonTextError::ContainsWhitespace)
301        );
302        assert_eq!(
303            PackageName::new("@scope"),
304            Err(PackageJsonTextError::InvalidScopedName)
305        );
306        Ok(())
307    }
308
309    #[test]
310    fn stores_package_metadata() -> Result<(), PackageJsonTextError> {
311        let manifest = PackageJson::new()
312            .with_name(PackageName::new("demo")?)
313            .with_version(PackageVersion::new("0.1.0")?)
314            .with_package_type(PackageType::Module)
315            .with_script(
316                PackageScriptName::new("test")?,
317                PackageScript::new("vitest")?,
318            )
319            .with_dependency(
320                DependencyKind::Dependencies,
321                PackageName::new("react")?,
322                PackageVersion::new("^18")?,
323            );
324
325        assert_eq!(manifest.name().map(PackageName::as_str), Some("demo"));
326        assert_eq!(manifest.scripts().len(), 1);
327        assert_eq!(manifest.dependencies().len(), 1);
328        Ok(())
329    }
330}