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#[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#[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#[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#[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#[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#[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#[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#[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}