Skip to main content

use_composer_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! composer_text_newtype {
8    ($name:ident) => {
9        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10        pub struct $name(String);
11
12        impl $name {
13            pub fn new(input: &str) -> Result<Self, ComposerJsonError> {
14                let trimmed = input.trim();
15                if trimmed.is_empty() {
16                    Err(ComposerJsonError::Empty)
17                } else {
18                    Ok(Self(trimmed.to_string()))
19                }
20            }
21
22            pub fn as_str(&self) -> &str {
23                &self.0
24            }
25        }
26
27        impl fmt::Display for $name {
28            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29                formatter.write_str(self.as_str())
30            }
31        }
32
33        impl FromStr for $name {
34            type Err = ComposerJsonError;
35
36            fn from_str(input: &str) -> Result<Self, Self::Err> {
37                Self::new(input)
38            }
39        }
40    };
41}
42
43composer_text_newtype!(ComposerVendorName);
44composer_text_newtype!(ComposerPackageShortName);
45composer_text_newtype!(ComposerRequirement);
46composer_text_newtype!(ComposerScriptName);
47composer_text_newtype!(ComposerScript);
48composer_text_newtype!(ComposerRepositoryUrl);
49
50/// Composer package name metadata in `vendor/package` form.
51#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub struct ComposerPackageName {
53    vendor: String,
54    package: String,
55}
56
57impl ComposerPackageName {
58    pub fn new(input: &str) -> Result<Self, ComposerJsonError> {
59        let trimmed = input.trim();
60        let Some((vendor, package)) = trimmed.split_once('/') else {
61            return Err(ComposerJsonError::InvalidPackageName);
62        };
63        if vendor.is_empty() || package.is_empty() || package.contains('/') {
64            return Err(ComposerJsonError::InvalidPackageName);
65        }
66        if trimmed.chars().any(char::is_whitespace) {
67            return Err(ComposerJsonError::ContainsWhitespace);
68        }
69        Ok(Self {
70            vendor: vendor.to_string(),
71            package: package.to_string(),
72        })
73    }
74
75    pub fn vendor(&self) -> &str {
76        &self.vendor
77    }
78
79    pub fn package(&self) -> &str {
80        &self.package
81    }
82}
83
84impl fmt::Display for ComposerPackageName {
85    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(formatter, "{}/{}", self.vendor, self.package)
87    }
88}
89
90impl FromStr for ComposerPackageName {
91    type Err = ComposerJsonError;
92
93    fn from_str(input: &str) -> Result<Self, Self::Err> {
94        Self::new(input)
95    }
96}
97
98/// Composer repository kind metadata.
99#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub enum ComposerRepositoryKind {
101    Composer,
102    Vcs,
103    Path,
104    Artifact,
105    Package,
106}
107
108impl ComposerRepositoryKind {
109    pub const fn as_str(self) -> &'static str {
110        match self {
111            Self::Composer => "composer",
112            Self::Vcs => "vcs",
113            Self::Path => "path",
114            Self::Artifact => "artifact",
115            Self::Package => "package",
116        }
117    }
118}
119
120/// Composer stability label metadata.
121#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub enum ComposerStability {
123    Dev,
124    Alpha,
125    Beta,
126    Rc,
127    Stable,
128}
129
130impl ComposerStability {
131    pub const fn as_str(self) -> &'static str {
132        match self {
133            Self::Dev => "dev",
134            Self::Alpha => "alpha",
135            Self::Beta => "beta",
136            Self::Rc => "RC",
137            Self::Stable => "stable",
138        }
139    }
140}
141
142/// Composer package type metadata.
143#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
144pub enum ComposerPackageType {
145    Library,
146    Project,
147    Metapackage,
148    ComposerPlugin,
149    Other,
150}
151
152impl ComposerPackageType {
153    pub const fn as_str(self) -> &'static str {
154        match self {
155            Self::Library => "library",
156            Self::Project => "project",
157            Self::Metapackage => "metapackage",
158            Self::ComposerPlugin => "composer-plugin",
159            Self::Other => "other",
160        }
161    }
162}
163
164/// Composer repository metadata.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct ComposerRepository {
167    kind: ComposerRepositoryKind,
168    url: Option<ComposerRepositoryUrl>,
169}
170
171impl ComposerRepository {
172    pub const fn new(kind: ComposerRepositoryKind) -> Self {
173        Self { kind, url: None }
174    }
175
176    pub fn with_url(mut self, url: ComposerRepositoryUrl) -> Self {
177        self.url = Some(url);
178        self
179    }
180
181    pub const fn kind(&self) -> ComposerRepositoryKind {
182        self.kind
183    }
184
185    pub const fn url(&self) -> Option<&ComposerRepositoryUrl> {
186        self.url.as_ref()
187    }
188}
189
190/// Composer autoload metadata without resolving paths.
191#[derive(Clone, Debug, Default, Eq, PartialEq)]
192pub struct ComposerAutoloadConfig {
193    psr4: BTreeMap<String, Vec<String>>,
194    classmap: Vec<String>,
195    files: Vec<String>,
196}
197
198impl ComposerAutoloadConfig {
199    pub fn new() -> Self {
200        Self::default()
201    }
202
203    pub fn with_psr4(mut self, prefix: &str, path: &str) -> Self {
204        self.psr4
205            .entry(prefix.to_string())
206            .or_default()
207            .push(path.to_string());
208        self
209    }
210
211    pub fn with_classmap(mut self, path: &str) -> Self {
212        self.classmap.push(path.to_string());
213        self
214    }
215
216    pub fn with_file(mut self, path: &str) -> Self {
217        self.files.push(path.to_string());
218        self
219    }
220
221    pub const fn psr4(&self) -> &BTreeMap<String, Vec<String>> {
222        &self.psr4
223    }
224
225    pub fn classmap(&self) -> &[String] {
226        &self.classmap
227    }
228
229    pub fn files(&self) -> &[String] {
230        &self.files
231    }
232}
233
234/// Partial practical Composer JSON metadata.
235#[derive(Clone, Debug, Default, Eq, PartialEq)]
236pub struct ComposerJson {
237    name: Option<ComposerPackageName>,
238    package_type: Option<ComposerPackageType>,
239    minimum_stability: Option<ComposerStability>,
240    requirements: BTreeMap<String, ComposerRequirement>,
241    dev_requirements: BTreeMap<String, ComposerRequirement>,
242    scripts: BTreeMap<ComposerScriptName, ComposerScript>,
243    repositories: Vec<ComposerRepository>,
244    autoload: Option<ComposerAutoloadConfig>,
245}
246
247impl ComposerJson {
248    pub fn new() -> Self {
249        Self::default()
250    }
251
252    pub fn with_name(mut self, name: ComposerPackageName) -> Self {
253        self.name = Some(name);
254        self
255    }
256
257    pub const fn with_package_type(mut self, package_type: ComposerPackageType) -> Self {
258        self.package_type = Some(package_type);
259        self
260    }
261
262    pub const fn with_minimum_stability(mut self, stability: ComposerStability) -> Self {
263        self.minimum_stability = Some(stability);
264        self
265    }
266
267    pub fn with_requirement(mut self, name: &str, requirement: ComposerRequirement) -> Self {
268        self.requirements.insert(name.to_string(), requirement);
269        self
270    }
271
272    pub fn with_script(mut self, name: ComposerScriptName, script: ComposerScript) -> Self {
273        self.scripts.insert(name, script);
274        self
275    }
276
277    pub fn with_repository(mut self, repository: ComposerRepository) -> Self {
278        self.repositories.push(repository);
279        self
280    }
281
282    pub fn with_autoload(mut self, autoload: ComposerAutoloadConfig) -> Self {
283        self.autoload = Some(autoload);
284        self
285    }
286
287    pub const fn name(&self) -> Option<&ComposerPackageName> {
288        self.name.as_ref()
289    }
290
291    pub const fn package_type(&self) -> Option<ComposerPackageType> {
292        self.package_type
293    }
294
295    pub const fn minimum_stability(&self) -> Option<ComposerStability> {
296        self.minimum_stability
297    }
298
299    pub const fn requirements(&self) -> &BTreeMap<String, ComposerRequirement> {
300        &self.requirements
301    }
302
303    pub const fn dev_requirements(&self) -> &BTreeMap<String, ComposerRequirement> {
304        &self.dev_requirements
305    }
306
307    pub const fn autoload(&self) -> Option<&ComposerAutoloadConfig> {
308        self.autoload.as_ref()
309    }
310}
311
312/// Error returned when Composer metadata is invalid.
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314pub enum ComposerJsonError {
315    Empty,
316    ContainsWhitespace,
317    InvalidPackageName,
318}
319
320impl fmt::Display for ComposerJsonError {
321    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match self {
323            Self::Empty => formatter.write_str("Composer metadata cannot be empty"),
324            Self::ContainsWhitespace => {
325                formatter.write_str("Composer package name cannot contain whitespace")
326            },
327            Self::InvalidPackageName => {
328                formatter.write_str("Composer package name must look like vendor/package")
329            },
330        }
331    }
332}
333
334impl Error for ComposerJsonError {}
335
336#[cfg(test)]
337mod tests {
338    use super::{
339        ComposerAutoloadConfig, ComposerJson, ComposerJsonError, ComposerPackageName,
340        ComposerPackageType, ComposerRequirement,
341    };
342
343    #[test]
344    fn builds_composer_json_metadata() -> Result<(), ComposerJsonError> {
345        let package = ComposerJson::new()
346            .with_name(ComposerPackageName::new("acme/demo")?)
347            .with_package_type(ComposerPackageType::Library)
348            .with_requirement("php", ComposerRequirement::new("^8.2")?)
349            .with_autoload(ComposerAutoloadConfig::new().with_psr4("Acme\\Demo\\", "src/"));
350
351        assert_eq!(package.name().expect("name").vendor(), "acme");
352        assert!(package.requirements().contains_key("php"));
353        assert!(
354            package
355                .autoload()
356                .expect("autoload")
357                .psr4()
358                .contains_key("Acme\\Demo\\")
359        );
360        Ok(())
361    }
362}