1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ComposerExport {
10 pub root: ComposerPackage,
11 #[serde(default)]
12 pub packages: Vec<ComposerPackage>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ComposerPackage {
17 pub name: String,
18 #[serde(default)]
19 pub version: Option<String>,
20 #[serde(rename = "type", default)]
21 pub package_type: Option<String>,
22 #[serde(default)]
23 pub description: Option<String>,
24 #[serde(default)]
25 pub install_path: Option<String>,
26 #[serde(default)]
27 pub keywords: Vec<String>,
28 #[serde(default)]
29 pub is_root: bool,
30}
31
32impl ComposerExport {
33 pub fn package_for_path<'a>(&'a self, absolute_path: &Path) -> &'a ComposerPackage {
34 self.packages
35 .iter()
36 .filter_map(|package| {
37 let install_path = package.install_path.as_ref()?;
38 let install_path = PathBuf::from(install_path);
39 if absolute_path.starts_with(&install_path) {
40 Some((install_path.components().count(), package))
41 } else {
42 None
43 }
44 })
45 .max_by_key(|(depth, _)| *depth)
46 .map(|(_, package)| package)
47 .unwrap_or(&self.root)
48 }
49}
50
51pub fn export_packages(repo: &Path) -> Result<ComposerExport> {
52 if let Ok(export) = export_packages_via_php(repo) {
53 return Ok(export);
54 }
55 export_packages_via_lock(repo)
56}
57
58fn export_packages_via_php(repo: &Path) -> Result<ComposerExport> {
59 let script = r#"<?php
60$repo = $argv[1] ?? getcwd();
61$autoload = $repo . '/vendor/autoload.php';
62if (!file_exists($autoload)) {
63 fwrite(STDERR, "vendor autoload missing\n");
64 exit(2);
65}
66require $autoload;
67$root = json_decode(file_get_contents($repo . '/composer.json'), true);
68$packages = [];
69if (class_exists('Composer\\InstalledVersions')) {
70 foreach (Composer\InstalledVersions::getInstalledPackages() as $name) {
71 $packages[] = [
72 'name' => $name,
73 'version' => Composer\InstalledVersions::getPrettyVersion($name),
74 'install_path' => Composer\InstalledVersions::getInstallPath($name),
75 'type' => null,
76 'description' => null,
77 'keywords' => [],
78 'is_root' => false,
79 ];
80 }
81}
82echo json_encode([
83 'root' => [
84 'name' => $root['name'] ?? 'root/app',
85 'version' => $root['version'] ?? null,
86 'type' => $root['type'] ?? null,
87 'description' => $root['description'] ?? null,
88 'install_path' => $repo,
89 'keywords' => $root['keywords'] ?? [],
90 'is_root' => true,
91 ],
92 'packages' => $packages,
93], JSON_UNESCAPED_SLASHES);
94"#;
95
96 let temp = tempfile::NamedTempFile::new().context("create php exporter temp file")?;
97 fs::write(temp.path(), script)?;
98
99 let output = Command::new("php")
100 .arg(temp.path())
101 .arg(repo)
102 .output()
103 .context("run embedded composer exporter")?;
104 if !output.status.success() {
105 anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr));
106 }
107 Ok(serde_json::from_slice(&output.stdout)?)
108}
109
110fn export_packages_via_lock(repo: &Path) -> Result<ComposerExport> {
111 #[derive(Debug, Deserialize)]
112 struct ComposerJson {
113 name: Option<String>,
114 version: Option<String>,
115 #[serde(rename = "type")]
116 package_type: Option<String>,
117 description: Option<String>,
118 #[serde(default)]
119 keywords: Vec<String>,
120 }
121 #[derive(Debug, Deserialize)]
122 struct LockPackage {
123 name: String,
124 version: Option<String>,
125 #[serde(rename = "type")]
126 package_type: Option<String>,
127 description: Option<String>,
128 #[serde(default)]
129 keywords: Vec<String>,
130 }
131 #[derive(Debug, Deserialize)]
132 struct LockFile {
133 #[serde(default)]
134 packages: Vec<LockPackage>,
135 }
136
137 let composer_json: ComposerJson = serde_json::from_slice(
138 &fs::read(repo.join("composer.json")).context("read composer.json")?,
139 )?;
140 let lock: LockFile = serde_json::from_slice(
141 &fs::read(repo.join("composer.lock")).unwrap_or_else(|_| b"{\"packages\":[]}".to_vec()),
142 )?;
143
144 Ok(ComposerExport {
145 root: ComposerPackage {
146 name: composer_json.name.unwrap_or_else(|| "root/app".to_string()),
147 version: composer_json.version,
148 package_type: composer_json.package_type,
149 description: composer_json.description,
150 install_path: Some(repo.display().to_string()),
151 keywords: composer_json.keywords,
152 is_root: true,
153 },
154 packages: lock
155 .packages
156 .into_iter()
157 .map(|package| ComposerPackage {
158 install_path: Some(
159 repo.join("vendor")
160 .join(package.name.replace('/', std::path::MAIN_SEPARATOR_STR))
161 .display()
162 .to_string(),
163 ),
164 name: package.name,
165 version: package.version,
166 package_type: package.package_type,
167 description: package.description,
168 keywords: package.keywords,
169 is_root: false,
170 })
171 .collect(),
172 })
173}
174
175#[cfg(test)]
176mod tests {
177 use std::fs;
178
179 use tempfile::tempdir;
180
181 use super::export_packages;
182
183 #[test]
184 fn lockfile_fallback_exports_root_and_packages() {
185 let dir = tempdir().unwrap();
186 fs::write(
187 dir.path().join("composer.json"),
188 r#"{"name":"acme/app","description":"demo","keywords":["php","search"]}"#,
189 )
190 .unwrap();
191 fs::write(
192 dir.path().join("composer.lock"),
193 r#"{"packages":[{"name":"laravel/framework","version":"11.0.0","type":"library"}]}"#,
194 )
195 .unwrap();
196
197 let export = export_packages(dir.path()).unwrap();
198 assert_eq!(export.root.name, "acme/app");
199 assert_eq!(export.packages[0].name, "laravel/framework");
200 }
201}