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 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 #[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#[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 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 #[must_use]
85 pub fn as_str(&self) -> &str {
86 &self.0
87 }
88
89 #[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
122pub type DependencyMap = BTreeMap<PackageName, PackageVersion>;
124
125#[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 #[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#[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 #[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#[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 #[must_use]
183 pub fn new() -> Self {
184 Self::default()
185 }
186
187 #[must_use]
189 pub fn with_name(mut self, name: PackageName) -> Self {
190 self.name = Some(name);
191 self
192 }
193
194 #[must_use]
196 pub fn with_version(mut self, version: PackageVersion) -> Self {
197 self.version = Some(version);
198 self
199 }
200
201 #[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 #[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 #[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 #[must_use]
232 pub const fn name(&self) -> Option<&PackageName> {
233 self.name.as_ref()
234 }
235
236 #[must_use]
238 pub const fn version(&self) -> Option<&PackageVersion> {
239 self.version.as_ref()
240 }
241
242 #[must_use]
244 pub const fn package_type(&self) -> Option<PackageType> {
245 self.package_type
246 }
247
248 #[must_use]
250 pub const fn scripts(&self) -> &BTreeMap<PackageScriptName, PackageScript> {
251 &self.scripts
252 }
253
254 #[must_use]
256 pub const fn dependencies(&self) -> &BTreeMap<DependencyKind, DependencyMap> {
257 &self.dependencies
258 }
259}
260
261#[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}