use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; use std::fs; use std::path::Path; pub mod archiver; pub mod dumper; pub mod version; /// Package stability level. /// Higher value = less stable. /// Corresponds to `Composer\Package\BasePackage::STABILITY_*`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[repr(u8)] pub enum Stability { #[default] Stable = 0, RC = 5, Beta = 10, Alpha = 15, Dev = 20, } impl Stability { /// Parse a stability string (case-insensitive) into a `Stability` value. /// /// Recognizes: "stable", "RC", "beta", "alpha", "dev". /// Defaults to `Stability::Stable` for unrecognized values. pub fn parse(s: &str) -> Self { match s.to_lowercase().as_str() { "dev" => Stability::Dev, "alpha" => Stability::Alpha, "beta" => Stability::Beta, "rc" => Stability::RC, _ => Stability::Stable, } } } /// A versioned relationship between two packages. /// Corresponds to `Composer\Package\Link`. #[derive(Debug, Clone)] pub struct Link { pub source: String, pub target: String, pub constraint: String, pub pretty_constraint: Option, pub description: String, } /// Package author metadata. #[derive(Debug, Clone)] pub struct Author { pub name: Option, pub email: Option, pub homepage: Option, pub role: Option, } /// Autoload rule sets (PSR-4, PSR-0, classmap, files). #[derive(Debug, Clone, Default)] pub struct AutoloadRules { pub psr4: BTreeMap>, pub psr0: BTreeMap>, pub classmap: Vec, pub files: Vec, } /// Support channel information. #[derive(Debug, Clone, Default)] pub struct Support { pub email: Option, pub issues: Option, pub forum: Option, pub wiki: Option, pub source: Option, pub docs: Option, pub irc: Option, pub chat: Option, pub rss: Option, pub security: Option, } /// Funding link. #[derive(Debug, Clone)] pub struct Funding { pub url: Option, pub funding_type: Option, } /// Version alias entry for root packages. #[derive(Debug, Clone)] pub struct VersionAlias { pub package: String, pub version: String, pub alias: String, pub alias_normalized: String, } /// Core package data covering `BasePackage` + `Package` fields. /// Corresponds to `Composer\Package\Package` (implements `PackageInterface`). #[derive(Debug, Clone)] pub struct PackageData { // BasePackage fields pub name: String, pub pretty_name: String, // Package fields pub version: String, pub pretty_version: String, pub package_type: String, pub target_dir: Option, // source pub source_type: Option, pub source_url: Option, pub source_reference: Option, // dist pub dist_type: Option, pub dist_url: Option, pub dist_reference: Option, pub dist_sha1_checksum: Option, pub release_date: Option, pub extra: BTreeMap, pub binaries: Vec, pub dev: bool, pub stability: Stability, pub notification_url: Option, // dependency links pub requires: BTreeMap, pub conflicts: BTreeMap, pub provides: BTreeMap, pub replaces: BTreeMap, pub dev_requires: BTreeMap, pub suggests: BTreeMap, // autoload pub autoload: AutoloadRules, pub dev_autoload: AutoloadRules, pub is_default_branch: bool, } /// Package with full metadata (description, authors, license, etc.). /// Corresponds to `Composer\Package\CompletePackage`. #[derive(Debug, Clone)] pub struct CompletePackageData { pub package: PackageData, pub description: Option, pub homepage: Option, pub license: Vec, pub keywords: Vec, pub authors: Vec, pub scripts: BTreeMap>, pub support: Support, pub funding: Vec, pub repositories: Vec, /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg. pub abandoned: Option, pub archive_name: Option, pub archive_excludes: Vec, } /// The root project package with project-level configuration. /// Corresponds to `Composer\Package\RootPackage`. #[derive(Debug, Clone)] pub struct RootPackageData { pub complete: CompletePackageData, pub minimum_stability: Stability, pub prefer_stable: bool, pub stability_flags: BTreeMap, pub config: BTreeMap, pub references: BTreeMap, pub aliases: Vec, } /// Accessor for `PackageData` fields. /// Corresponds to `Composer\Package\PackageInterface`. pub trait Package { fn name(&self) -> &str; fn pretty_name(&self) -> &str; fn version(&self) -> &str; fn pretty_version(&self) -> &str; fn package_type(&self) -> &str; fn target_dir(&self) -> Option<&str>; fn source_type(&self) -> Option<&str>; fn source_url(&self) -> Option<&str>; fn source_reference(&self) -> Option<&str>; fn dist_type(&self) -> Option<&str>; fn dist_url(&self) -> Option<&str>; fn dist_reference(&self) -> Option<&str>; fn dist_sha1_checksum(&self) -> Option<&str>; fn release_date(&self) -> Option<&str>; fn extra(&self) -> &BTreeMap; fn binaries(&self) -> &[String]; fn is_dev(&self) -> bool; fn stability(&self) -> Stability; fn notification_url(&self) -> Option<&str>; fn requires(&self) -> &BTreeMap; fn conflicts(&self) -> &BTreeMap; fn provides(&self) -> &BTreeMap; fn replaces(&self) -> &BTreeMap; fn dev_requires(&self) -> &BTreeMap; fn suggests(&self) -> &BTreeMap; fn autoload(&self) -> &AutoloadRules; fn dev_autoload(&self) -> &AutoloadRules; fn is_default_branch(&self) -> bool; } /// Accessor for `CompletePackageData` fields. /// Corresponds to `Composer\Package\CompletePackageInterface`. pub trait CompletePackage: Package { fn description(&self) -> Option<&str>; fn homepage(&self) -> Option<&str>; fn license(&self) -> &[String]; fn keywords(&self) -> &[String]; fn authors(&self) -> &[Author]; fn scripts(&self) -> &BTreeMap>; fn support(&self) -> &Support; fn funding(&self) -> &[Funding]; fn repositories(&self) -> &[serde_json::Value]; fn abandoned(&self) -> Option<&str>; fn archive_name(&self) -> Option<&str>; fn archive_excludes(&self) -> &[String]; } /// Accessor for `RootPackageData` fields. /// Corresponds to `Composer\Package\RootPackageInterface`. pub trait RootPackage: CompletePackage { fn minimum_stability(&self) -> Stability; fn prefer_stable(&self) -> bool; fn stability_flags(&self) -> &BTreeMap; fn config(&self) -> &BTreeMap; fn references(&self) -> &BTreeMap; fn aliases(&self) -> &[VersionAlias]; } /// Implements `Package` trait by delegating to an inner `PackageData` field. macro_rules! delegate_package { ($type:ty => $($path:ident).+) => { impl Package for $type { fn name(&self) -> &str { &self.$($path).+.name } fn pretty_name(&self) -> &str { &self.$($path).+.pretty_name } fn version(&self) -> &str { &self.$($path).+.version } fn pretty_version(&self) -> &str { &self.$($path).+.pretty_version } fn package_type(&self) -> &str { &self.$($path).+.package_type } fn target_dir(&self) -> Option<&str> { self.$($path).+.target_dir.as_deref() } fn source_type(&self) -> Option<&str> { self.$($path).+.source_type.as_deref() } fn source_url(&self) -> Option<&str> { self.$($path).+.source_url.as_deref() } fn source_reference(&self) -> Option<&str> { self.$($path).+.source_reference.as_deref() } fn dist_type(&self) -> Option<&str> { self.$($path).+.dist_type.as_deref() } fn dist_url(&self) -> Option<&str> { self.$($path).+.dist_url.as_deref() } fn dist_reference(&self) -> Option<&str> { self.$($path).+.dist_reference.as_deref() } fn dist_sha1_checksum(&self) -> Option<&str> { self.$($path).+.dist_sha1_checksum.as_deref() } fn release_date(&self) -> Option<&str> { self.$($path).+.release_date.as_deref() } fn extra(&self) -> &BTreeMap { &self.$($path).+.extra } fn binaries(&self) -> &[String] { &self.$($path).+.binaries } fn is_dev(&self) -> bool { self.$($path).+.dev } fn stability(&self) -> Stability { self.$($path).+.stability } fn notification_url(&self) -> Option<&str> { self.$($path).+.notification_url.as_deref() } fn requires(&self) -> &BTreeMap { &self.$($path).+.requires } fn conflicts(&self) -> &BTreeMap { &self.$($path).+.conflicts } fn provides(&self) -> &BTreeMap { &self.$($path).+.provides } fn replaces(&self) -> &BTreeMap { &self.$($path).+.replaces } fn dev_requires(&self) -> &BTreeMap { &self.$($path).+.dev_requires } fn suggests(&self) -> &BTreeMap { &self.$($path).+.suggests } fn autoload(&self) -> &AutoloadRules { &self.$($path).+.autoload } fn dev_autoload(&self) -> &AutoloadRules { &self.$($path).+.dev_autoload } fn is_default_branch(&self) -> bool { self.$($path).+.is_default_branch } } }; } /// Implements `CompletePackage` trait by delegating to an inner `CompletePackageData` field. macro_rules! delegate_complete_package { ($type:ty => $($path:ident).+) => { impl CompletePackage for $type { fn description(&self) -> Option<&str> { self.$($path).+.description.as_deref() } fn homepage(&self) -> Option<&str> { self.$($path).+.homepage.as_deref() } fn license(&self) -> &[String] { &self.$($path).+.license } fn keywords(&self) -> &[String] { &self.$($path).+.keywords } fn authors(&self) -> &[Author] { &self.$($path).+.authors } fn scripts(&self) -> &BTreeMap> { &self.$($path).+.scripts } fn support(&self) -> &Support { &self.$($path).+.support } fn funding(&self) -> &[Funding] { &self.$($path).+.funding } fn repositories(&self) -> &[serde_json::Value] { &self.$($path).+.repositories } fn abandoned(&self) -> Option<&str> { self.$($path).+.abandoned.as_deref() } fn archive_name(&self) -> Option<&str> { self.$($path).+.archive_name.as_deref() } fn archive_excludes(&self) -> &[String] { &self.$($path).+.archive_excludes } } }; } impl Package for PackageData { fn name(&self) -> &str { &self.name } fn pretty_name(&self) -> &str { &self.pretty_name } fn version(&self) -> &str { &self.version } fn pretty_version(&self) -> &str { &self.pretty_version } fn package_type(&self) -> &str { &self.package_type } fn target_dir(&self) -> Option<&str> { self.target_dir.as_deref() } fn source_type(&self) -> Option<&str> { self.source_type.as_deref() } fn source_url(&self) -> Option<&str> { self.source_url.as_deref() } fn source_reference(&self) -> Option<&str> { self.source_reference.as_deref() } fn dist_type(&self) -> Option<&str> { self.dist_type.as_deref() } fn dist_url(&self) -> Option<&str> { self.dist_url.as_deref() } fn dist_reference(&self) -> Option<&str> { self.dist_reference.as_deref() } fn dist_sha1_checksum(&self) -> Option<&str> { self.dist_sha1_checksum.as_deref() } fn release_date(&self) -> Option<&str> { self.release_date.as_deref() } fn extra(&self) -> &BTreeMap { &self.extra } fn binaries(&self) -> &[String] { &self.binaries } fn is_dev(&self) -> bool { self.dev } fn stability(&self) -> Stability { self.stability } fn notification_url(&self) -> Option<&str> { self.notification_url.as_deref() } fn requires(&self) -> &BTreeMap { &self.requires } fn conflicts(&self) -> &BTreeMap { &self.conflicts } fn provides(&self) -> &BTreeMap { &self.provides } fn replaces(&self) -> &BTreeMap { &self.replaces } fn dev_requires(&self) -> &BTreeMap { &self.dev_requires } fn suggests(&self) -> &BTreeMap { &self.suggests } fn autoload(&self) -> &AutoloadRules { &self.autoload } fn dev_autoload(&self) -> &AutoloadRules { &self.dev_autoload } fn is_default_branch(&self) -> bool { self.is_default_branch } } impl CompletePackage for CompletePackageData { fn description(&self) -> Option<&str> { self.description.as_deref() } fn homepage(&self) -> Option<&str> { self.homepage.as_deref() } fn license(&self) -> &[String] { &self.license } fn keywords(&self) -> &[String] { &self.keywords } fn authors(&self) -> &[Author] { &self.authors } fn scripts(&self) -> &BTreeMap> { &self.scripts } fn support(&self) -> &Support { &self.support } fn funding(&self) -> &[Funding] { &self.funding } fn repositories(&self) -> &[serde_json::Value] { &self.repositories } fn abandoned(&self) -> Option<&str> { self.abandoned.as_deref() } fn archive_name(&self) -> Option<&str> { self.archive_name.as_deref() } fn archive_excludes(&self) -> &[String] { &self.archive_excludes } } impl RootPackage for RootPackageData { fn minimum_stability(&self) -> Stability { self.minimum_stability } fn prefer_stable(&self) -> bool { self.prefer_stable } fn stability_flags(&self) -> &BTreeMap { &self.stability_flags } fn config(&self) -> &BTreeMap { &self.config } fn references(&self) -> &BTreeMap { &self.references } fn aliases(&self) -> &[VersionAlias] { &self.aliases } } // CompletePackageData delegates Package → inner PackageData delegate_package!(CompletePackageData => package); // RootPackageData delegates Package → inner CompletePackageData → PackageData delegate_package!(RootPackageData => complete.package); // RootPackageData delegates CompletePackage → inner CompletePackageData delegate_complete_package!(RootPackageData => complete); /// Unstructured representation of a composer.json file. /// Used by `init` and `create-project` to write a new composer.json. /// Unlike the typed hierarchy above, all fields live at a single level /// and map directly to the JSON keys via serde. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawPackageData { #[serde(default = "default_root_package_name")] pub name: String, /// Root project's version, when explicitly set. Composer falls back to /// `1.0.0+no-version-set` when this is missing; we keep the raw `Option` /// here and let the resolver apply that default so the in-memory shape /// stays close to the JSON input. #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub package_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub homepage: Option, #[serde(skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub authors: Vec, #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")] pub minimum_stability: Option, #[serde(default)] pub require: BTreeMap, #[serde( rename = "require-dev", default, skip_serializing_if = "BTreeMap::is_empty" )] pub require_dev: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub conflict: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub provide: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub replace: BTreeMap, /// `repositories` accepts either a JSON array or a JSON object keyed by /// repository name. Composer iterates `foreach ($repoConfigs as ...)` in /// `RepositoryFactory::createRepos`, so PHP transparently handles either /// shape; mirror that here. #[serde( default, deserialize_with = "deserialize_repositories", skip_serializing_if = "Vec::is_empty" )] pub repositories: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub autoload: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bin: Vec, #[serde(flatten)] pub extra_fields: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawAuthor { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawAutoload { #[serde(rename = "psr-4")] pub psr4: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RawRepository { #[serde(rename = "type")] pub repo_type: String, /// Required for VCS / composer / artifact / path types; absent for inline /// `package` repositories. Modeled as Option to mirror Composer's /// per-type schema (Composer enforces presence in `RepositoryFactory`, /// not at the JSON-parse step). #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, /// Inline package definition(s) for `type: package` repositories. Either /// a single object or an array of objects, mirroring /// `Composer\Repository\PackageRepository`'s schema. #[serde(default, skip_serializing_if = "Option::is_none")] pub package: Option, /// `only: ["name", ...]` — restrict this repository to the listed package /// names (glob `*` allowed). Mirrors `Composer\Repository\FilterRepository`. #[serde(default, skip_serializing_if = "Option::is_none")] pub only: Option>, /// `exclude: ["name", ...]` — drop the listed package names from this /// repository. Mutually exclusive with `only` per `FilterRepository`. #[serde(default, skip_serializing_if = "Option::is_none")] pub exclude: Option>, /// `canonical: false` — packages from this repo enter the pool but do /// not claim authoritative ownership of their names, so lower-priority /// repositories can still answer for the same name. Mirrors /// `FilterRepository::loadPackages`'s `namesFound = []` reset. #[serde(default, skip_serializing_if = "Option::is_none")] pub canonical: Option, /// Inline `security-advisories` block on a repository entry. Maps package /// name → list of advisory objects whose `affectedVersions` constraint /// (and `advisoryId`) is read by the resolver when /// `config.audit.block-insecure` is set: matching versions are filtered /// out of the pool before solving, mirroring Composer's /// `SecurityAdvisoryPoolFilter`. #[serde( rename = "security-advisories", default, skip_serializing_if = "Option::is_none" )] pub security_advisories: Option, } /// Default root-package name when `composer.json` omits the `name` field. /// Mirrors Composer's `RootPackageLoader` fallback. fn default_root_package_name() -> String { "__root__".to_string() } /// Deserialize `repositories` from either a JSON array or a JSON object. /// PHP's `json_decode($x, true)` produces an associative array in either /// case, and `RepositoryFactory::createRepos` iterates the values without /// caring whether the key was an int (array) or a string (object). The map /// keys are dropped — `RawRepository` doesn't carry a name field, and /// downstream code doesn't depend on the original keying. fn deserialize_repositories<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { struct RepositoriesVisitor; impl<'de> Visitor<'de> for RepositoriesVisitor { type Value = Vec; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a sequence or map of repository definitions") } fn visit_seq(self, mut seq: A) -> Result where A: SeqAccess<'de>, { let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0)); while let Some(repo) = seq.next_element::()? { out.push(repo); } Ok(out) } fn visit_map(self, mut map: A) -> Result where A: MapAccess<'de>, { let mut out = Vec::with_capacity(map.size_hint().unwrap_or(0)); while let Some((_key, repo)) = map.next_entry::()? { out.push(repo); } Ok(out) } } deserializer.deserialize_any(RepositoriesVisitor) } impl RawPackageData { pub fn new(name: String) -> Self { Self { name, version: None, description: None, package_type: None, homepage: None, license: None, authors: Vec::new(), minimum_stability: None, require: BTreeMap::new(), require_dev: BTreeMap::new(), conflict: BTreeMap::new(), provide: BTreeMap::new(), replace: BTreeMap::new(), repositories: Vec::new(), autoload: None, bin: Vec::new(), extra_fields: BTreeMap::new(), } } /// Reject root composer.json that requires its own package name. /// /// Mirrors the check in Composer's /// `Composer\Package\Loader\RootPackageLoader::load()` (RuntimeException /// thrown for `$config['require'][$config['name']]` / /// `$config['require-dev'][$config['name']]`). pub fn validate_root_does_not_self_require(&self) -> anyhow::Result<()> { if self.require.contains_key(&self.name) || self.require_dev.contains_key(&self.name) { anyhow::bail!( "Root package '{}' cannot require itself in its composer.json\nDid you accidentally name your root package after an external package?", self.name ); } Ok(()) } } pub fn read_from_file(path: &Path) -> anyhow::Result { let content = fs::read_to_string(path)?; let data: RawPackageData = serde_json::from_str(&content)?; Ok(data) } impl RootPackageData { /// Mirrors `Composer\Package\Loader\RootPackageLoader::load()`: /// converts the raw, untyped `RawPackageData` (the parsed-JSON form) /// into the fully typed `RootPackageData`, applying field defaults and /// constructing [`Link`] objects from the raw constraint strings. pub fn from_raw(raw: RawPackageData) -> Self { fn make_links( source: &str, deps: BTreeMap, description: &str, ) -> BTreeMap { deps.into_iter() .map(|(target, constraint)| { let normalized = target.to_lowercase(); let link = Link { source: source.to_string(), target: normalized.clone(), constraint, pretty_constraint: None, description: description.to_string(), }; (normalized, link) }) .collect() } let pretty_name = raw.name.clone(); let name = raw.name.to_lowercase(); let pretty_version = raw .version .unwrap_or_else(|| "1.0.0+no-version-set".to_string()); let version = pretty_version.clone(); let package_type = raw .package_type .map(|t| t.to_lowercase()) .unwrap_or_else(|| "library".to_string()); let requires = make_links(&name, raw.require, "requires"); let dev_requires = make_links(&name, raw.require_dev, "requires (for development)"); let conflicts = make_links(&name, raw.conflict, "conflicts"); let provides = make_links(&name, raw.provide, "provides"); let replaces = make_links(&name, raw.replace, "replaces"); let autoload = raw .autoload .map(|a| AutoloadRules { psr4: a .psr4 .into_iter() .map(|(ns, path)| (ns, vec![path])) .collect(), ..Default::default() }) .unwrap_or_default(); let extra: BTreeMap = raw .extra_fields .get("extra") .and_then(|v| v.as_object()) .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); let license = raw.license.into_iter().collect(); let authors = raw .authors .into_iter() .map(|a| Author { name: Some(a.name), email: a.email, homepage: None, role: None, }) .collect(); let repositories = raw .repositories .into_iter() .filter_map(|r| serde_json::to_value(r).ok()) .collect(); let keywords = raw .extra_fields .get("keywords") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(String::from) .collect() }) .unwrap_or_default(); let scripts: BTreeMap> = raw .extra_fields .get("scripts") .and_then(|v| v.as_object()) .map(|obj| { obj.iter() .map(|(k, v)| { let handlers = match v { serde_json::Value::String(s) => vec![s.clone()], serde_json::Value::Array(arr) => arr .iter() .filter_map(|x| x.as_str()) .map(String::from) .collect(), _ => vec![], }; (k.clone(), handlers) }) .collect() }) .unwrap_or_default(); let support = raw .extra_fields .get("support") .and_then(|v| v.as_object()) .map(|obj| { let get_str = |key: &str| obj.get(key).and_then(|v| v.as_str()).map(String::from); Support { email: get_str("email"), issues: get_str("issues"), forum: get_str("forum"), wiki: get_str("wiki"), source: get_str("source"), docs: get_str("docs"), irc: get_str("irc"), chat: get_str("chat"), rss: get_str("rss"), security: get_str("security"), } }) .unwrap_or_default(); let funding = raw .extra_fields .get("funding") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_object()) .map(|obj| Funding { url: obj.get("url").and_then(|v| v.as_str()).map(String::from), funding_type: obj.get("type").and_then(|v| v.as_str()).map(String::from), }) .collect() }) .unwrap_or_default(); let config: BTreeMap = raw .extra_fields .get("config") .and_then(|v| v.as_object()) .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); let prefer_stable = raw .extra_fields .get("prefer-stable") .and_then(|v| v.as_bool()) .unwrap_or(false); let minimum_stability = Stability::parse(raw.minimum_stability.as_deref().unwrap_or("stable")); let package_data = PackageData { name, pretty_name, version, pretty_version, package_type, target_dir: None, source_type: None, source_url: None, source_reference: None, dist_type: None, dist_url: None, dist_reference: None, dist_sha1_checksum: None, release_date: None, extra, binaries: raw.bin, dev: false, stability: Stability::Stable, notification_url: None, requires, conflicts, provides, replaces, dev_requires, suggests: BTreeMap::new(), autoload, dev_autoload: AutoloadRules::default(), is_default_branch: false, }; let complete_data = CompletePackageData { package: package_data, description: raw.description, homepage: raw.homepage, license, keywords, authors, scripts, support, funding, repositories, abandoned: None, archive_name: None, archive_excludes: Vec::new(), }; RootPackageData { complete: complete_data, minimum_stability, prefer_stable, stability_flags: BTreeMap::new(), config, references: BTreeMap::new(), aliases: Vec::new(), } } } pub fn to_json_pretty(value: &impl Serialize) -> serde_json::Result { let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); let mut buf = Vec::new(); let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); value.serialize(&mut ser)?; let mut json = String::from_utf8(buf).expect("serde_json produces valid UTF-8"); json.push('\n'); Ok(json) } pub fn write_to_file(value: &impl Serialize, path: &Path) -> anyhow::Result<()> { let json = to_json_pretty(value)?; fs::write(path, json)?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn raw_minimal_json() { let raw = RawPackageData::new("test/pkg".to_string()); let json = to_json_pretty(&raw).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["name"], "test/pkg"); assert!(parsed["require"].is_object()); assert!(parsed.get("description").is_none()); assert!(parsed.get("type").is_none()); assert!(parsed.get("authors").is_none()); assert!(parsed.get("require-dev").is_none()); assert!(parsed.get("autoload").is_none()); } #[test] fn raw_full_json() { let mut raw = RawPackageData::new("acme/full".to_string()); raw.description = Some("A full package".to_string()); raw.package_type = Some("library".to_string()); raw.homepage = Some("https://example.com".to_string()); raw.license = Some("MIT".to_string()); raw.authors = vec![RawAuthor { name: "Jane Doe".to_string(), email: Some("jane@example.com".to_string()), }]; raw.minimum_stability = Some("dev".to_string()); raw.require.insert("php".to_string(), ">=8.1".to_string()); raw.require_dev .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); raw.repositories = vec![RawRepository { repo_type: "vcs".to_string(), url: Some("https://github.com/acme/repo".to_string()), package: None, only: None, exclude: None, canonical: None, security_advisories: None, }]; let mut psr4 = BTreeMap::new(); psr4.insert("Acme\\Full\\".to_string(), "src/".to_string()); raw.autoload = Some(RawAutoload { psr4 }); let json = to_json_pretty(&raw).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["name"], "acme/full"); assert_eq!(parsed["description"], "A full package"); assert_eq!(parsed["type"], "library"); assert_eq!(parsed["homepage"], "https://example.com"); assert_eq!(parsed["license"], "MIT"); assert_eq!(parsed["minimum-stability"], "dev"); assert_eq!(parsed["authors"][0]["name"], "Jane Doe"); assert_eq!(parsed["authors"][0]["email"], "jane@example.com"); assert_eq!(parsed["require"]["php"], ">=8.1"); assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^10.0"); assert_eq!(parsed["repositories"][0]["type"], "vcs"); assert_eq!(parsed["autoload"]["psr-4"]["Acme\\Full\\"], "src/"); } #[test] fn raw_deserialize_minimal() { let json = r#"{"name": "test/pkg"}"#; let raw: RawPackageData = serde_json::from_str(json).unwrap(); assert_eq!(raw.name, "test/pkg"); assert!(raw.description.is_none()); assert!(raw.require.is_empty()); assert!(raw.require_dev.is_empty()); assert!(raw.authors.is_empty()); assert!(raw.extra_fields.is_empty()); } #[test] fn raw_roundtrip_preserves_all_fields() { let mut raw = RawPackageData::new("acme/roundtrip".to_string()); raw.description = Some("Test roundtrip".to_string()); raw.require.insert("php".to_string(), ">=8.1".to_string()); raw.require_dev .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); let json1 = to_json_pretty(&raw).unwrap(); let deserialized: RawPackageData = serde_json::from_str(&json1).unwrap(); let json2 = to_json_pretty(&deserialized).unwrap(); assert_eq!(json1, json2); } #[test] fn raw_extra_fields_preserved() { let json = r#"{ "name": "test/extra", "require": {}, "scripts": {"post-install-cmd": ["echo hello"]}, "config": {"sort-packages": true}, "extra": {"custom-key": "custom-value"} }"#; let raw: RawPackageData = serde_json::from_str(json).unwrap(); assert_eq!(raw.name, "test/extra"); assert!(raw.extra_fields.contains_key("scripts")); assert!(raw.extra_fields.contains_key("config")); assert!(raw.extra_fields.contains_key("extra")); // Roundtrip: extra fields should be preserved in output let output = to_json_pretty(&raw).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); assert!(parsed["scripts"].is_object()); assert!(parsed["config"].is_object()); assert!(parsed["extra"].is_object()); } #[test] fn raw_read_from_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("composer.json"); let content = r#"{"name": "test/file", "require": {"php": ">=8.0"}}"#; std::fs::write(&path, content).unwrap(); let raw = read_from_file(&path).unwrap(); assert_eq!(raw.name, "test/file"); assert_eq!(raw.require.get("php").unwrap(), ">=8.0"); } #[test] fn raw_none_fields_omitted() { let raw = RawPackageData::new("test/empty".to_string()); let json = to_json_pretty(&raw).unwrap(); assert!(!json.contains("\"description\"")); assert!(!json.contains("\"type\"")); assert!(!json.contains("\"homepage\"")); assert!(!json.contains("\"license\"")); assert!(!json.contains("\"authors\"")); assert!(!json.contains("\"minimum-stability\"")); assert!(!json.contains("\"require-dev\"")); assert!(!json.contains("\"repositories\"")); assert!(!json.contains("\"autoload\"")); } #[test] fn validate_root_self_require_rejects_self_in_require() { let mut raw = RawPackageData::new("foo/bar".to_string()); raw.require .insert("foo/bar".to_string(), "@dev".to_string()); let err = raw.validate_root_does_not_self_require().unwrap_err(); let msg = err.to_string(); assert!( msg.contains("Root package 'foo/bar' cannot require itself"), "{msg}" ); assert!(msg.contains("accidentally name your root package"), "{msg}"); } #[test] fn validate_root_self_require_rejects_self_in_require_dev() { let mut raw = RawPackageData::new("foo/bar".to_string()); raw.require_dev .insert("foo/bar".to_string(), "@dev".to_string()); assert!(raw.validate_root_does_not_self_require().is_err()); } #[test] fn validate_root_self_require_accepts_other_packages() { let mut raw = RawPackageData::new("foo/bar".to_string()); raw.require .insert("vendor/lib".to_string(), "^1.0".to_string()); raw.require_dev .insert("vendor/dev-tool".to_string(), "^2.0".to_string()); assert!(raw.validate_root_does_not_self_require().is_ok()); } #[test] fn validate_root_self_require_accepts_empty_requires() { let raw = RawPackageData::new("foo/bar".to_string()); assert!(raw.validate_root_does_not_self_require().is_ok()); } #[test] fn raw_repositories_array_form() { let json = r#"{ "name": "test/array", "repositories": [ {"type": "vcs", "url": "https://example.com/a"}, {"type": "vcs", "url": "https://example.com/b"} ] }"#; let raw: RawPackageData = serde_json::from_str(json).unwrap(); assert_eq!(raw.repositories.len(), 2); assert_eq!( raw.repositories[0].url.as_deref(), Some("https://example.com/a") ); assert_eq!( raw.repositories[1].url.as_deref(), Some("https://example.com/b") ); } #[test] fn raw_repositories_object_form() { let json = r#"{ "name": "test/object", "repositories": { "first": {"type": "vcs", "url": "https://example.com/a"}, "second": {"type": "package", "package": {"name": "x/y", "version": "1.0.0"}} } }"#; let raw: RawPackageData = serde_json::from_str(json).unwrap(); assert_eq!(raw.repositories.len(), 2); assert_eq!(raw.repositories[0].repo_type, "vcs"); assert_eq!( raw.repositories[0].url.as_deref(), Some("https://example.com/a") ); assert_eq!(raw.repositories[1].repo_type, "package"); assert!(raw.repositories[1].package.is_some()); } }