1use crate::error::{Error, Result};
7use serde_json::{Map, Value};
8use std::path::Path;
9
10#[derive(Debug, Clone)]
11pub struct Lock {
12 pub content_hash: Option<String>,
13 pub packages: Vec<LockPackage>,
14 pub packages_dev: Vec<LockPackage>,
15 pub platform: Vec<(String, String)>,
17 pub platform_dev: Vec<(String, String)>,
18 pub plugin_api_version: Option<String>,
19 pub aliases: Vec<Value>,
20}
21
22#[derive(Debug, Clone)]
23pub struct LockPackage {
24 pub raw: Map<String, Value>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DistKind {
29 Zip,
30 Path,
32 Other,
33 Missing,
34}
35
36impl LockPackage {
37 fn str_field(&self, key: &str) -> Option<&str> {
38 self.raw.get(key).and_then(Value::as_str)
39 }
40
41 pub fn name(&self) -> &str {
42 self.str_field("name").unwrap_or("")
43 }
44
45 pub fn version(&self) -> &str {
46 self.str_field("version").unwrap_or("")
47 }
48
49 pub fn package_type(&self) -> &str {
51 self.str_field("type").unwrap_or("library")
52 }
53
54 pub fn dist_url(&self) -> Option<&str> {
55 self.raw.get("dist")?.get("url")?.as_str()
56 }
57
58 pub fn dist_url_expanded(&self) -> Option<String> {
64 let url = self.dist_url()?;
65 if !url.contains('%') {
66 return Some(url.to_owned());
67 }
68 let version = crate::version::normalize_pretty(self.version())
69 .unwrap_or_else(|_| self.version().to_owned());
70 let kind = self
71 .raw
72 .get("dist")
73 .and_then(|d| d.get("type"))
74 .and_then(Value::as_str)
75 .unwrap_or("");
76 Some(
77 url.replace("%package%", self.name())
78 .replace("%version%", &version)
79 .replace("%reference%", self.dist_reference().unwrap_or(""))
80 .replace("%type%", kind)
81 .replace("%prettyVersion%", self.version()),
82 )
83 }
84
85 pub fn dist_reference(&self) -> Option<&str> {
86 self.raw.get("dist")?.get("reference")?.as_str()
87 }
88
89 pub fn dist_shasum(&self) -> Option<&str> {
91 self.raw
92 .get("dist")?
93 .get("shasum")?
94 .as_str()
95 .filter(|s| !s.is_empty())
96 }
97
98 pub fn dist_kind(&self) -> DistKind {
99 match self
100 .raw
101 .get("dist")
102 .and_then(|d| d.get("type"))
103 .and_then(Value::as_str)
104 {
105 Some("zip") => DistKind::Zip,
106 Some("path") => DistKind::Path,
107 Some(_) => DistKind::Other,
108 None => DistKind::Missing,
109 }
110 }
111
112 pub fn target_dir(&self) -> Option<&str> {
115 self.str_field("target-dir")
116 .map(|t| t.trim_matches('/'))
117 .filter(|t| !t.is_empty())
118 }
119
120 pub fn install_subpath(&self) -> String {
122 match self.target_dir() {
123 Some(t) => format!("{}/{}", self.name(), t),
124 None => self.name().to_owned(),
125 }
126 }
127
128 pub fn is_metapackage(&self) -> bool {
129 self.package_type() == "metapackage"
130 }
131
132 pub fn is_virtual(&self, flex_packs: bool) -> bool {
135 self.is_metapackage() || (flex_packs && self.package_type() == "symfony-pack")
136 }
137
138 pub fn bins(&self) -> Vec<&str> {
139 self.raw
140 .get("bin")
141 .and_then(Value::as_array)
142 .map(|a| a.iter().filter_map(Value::as_str).collect())
143 .unwrap_or_default()
144 }
145}
146
147fn parse_packages(v: Option<&Value>) -> Vec<LockPackage> {
148 v.and_then(Value::as_array)
149 .map(|a| {
150 a.iter()
151 .filter_map(|p| p.as_object())
152 .map(|m| LockPackage { raw: m.clone() })
153 .collect()
154 })
155 .unwrap_or_default()
156}
157
158fn parse_platform(v: Option<&Value>) -> Vec<(String, String)> {
161 v.and_then(Value::as_object)
162 .map(|m| {
163 m.iter()
164 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
165 .collect()
166 })
167 .unwrap_or_default()
168}
169
170impl Lock {
171 pub fn parse(text: &str) -> Result<Self> {
172 let v: Value = serde_json::from_str(text).map_err(|source| Error::Json {
173 context: "composer.lock".to_owned(),
174 source,
175 })?;
176 Ok(Self::from_value(&v))
177 }
178
179 pub fn from_value(v: &Value) -> Self {
182 Lock {
183 content_hash: v
184 .get("content-hash")
185 .and_then(Value::as_str)
186 .map(str::to_owned),
187 packages: parse_packages(v.get("packages")),
188 packages_dev: parse_packages(v.get("packages-dev")),
189 platform: parse_platform(v.get("platform")),
190 platform_dev: parse_platform(v.get("platform-dev")),
191 plugin_api_version: v
192 .get("plugin-api-version")
193 .and_then(Value::as_str)
194 .map(str::to_owned),
195 aliases: v
196 .get("aliases")
197 .and_then(Value::as_array)
198 .cloned()
199 .unwrap_or_default(),
200 }
201 }
202
203 pub fn read(path: &Path) -> Result<Self> {
204 let text = std::fs::read_to_string(path).map_err(|source| Error::ReadFile {
205 path: path.to_path_buf(),
206 source,
207 })?;
208 Self::parse(&text)
209 }
210
211 pub fn flex_packs(&self, root_manifest: &Value, with_dev: bool, plugins_enabled: bool) -> bool {
216 plugins_enabled
217 && self
218 .wanted_packages(with_dev)
219 .any(|p| p.name() == "symfony/flex")
220 && matches!(
221 crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
222 crate::layout::PluginVerdict::Allowed
223 )
224 }
225
226 pub fn wanted_packages(&self, with_dev: bool) -> impl Iterator<Item = &LockPackage> {
227 self.packages
228 .iter()
229 .chain(
230 self.packages_dev
231 .iter()
232 .take(if with_dev { usize::MAX } else { 0 }),
233 )
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn parses_minimal_lock() {
243 let lock = Lock::parse(
244 r#"{"content-hash":"abc","packages":[{"name":"a/b","version":"1.0.0",
245 "dist":{"type":"zip","url":"https://x/y.zip","reference":"deadbeef","shasum":""},
246 "type":"library","bin":["bin/tool"]}],
247 "packages-dev":[],"platform":{"php":">=8.1"},"platform-dev":[]}"#,
248 )
249 .expect("parse");
250 assert_eq!(lock.content_hash.as_deref(), Some("abc"));
251 let p = &lock.packages[0];
252 assert_eq!(p.name(), "a/b");
253 assert_eq!(p.dist_kind(), DistKind::Zip);
254 assert_eq!(p.dist_shasum(), None); assert_eq!(p.bins(), vec!["bin/tool"]);
256 assert_eq!(lock.platform, vec![("php".to_owned(), ">=8.1".to_owned())]);
257 assert_eq!(lock.wanted_packages(false).count(), 1);
258 }
259
260 #[test]
261 fn empty_platform_as_array() {
262 let lock = Lock::parse(r#"{"packages":[],"platform":[]}"#).expect("parse");
263 assert!(lock.platform.is_empty());
264 }
265}
266
267#[cfg(test)]
268mod placeholder_tests {
269 use super::*;
270
271 #[test]
272 fn dist_url_placeholders_like_composer_mirror() {
273 let p = LockPackage {
274 raw: serde_json::from_str(r#"{"name": "ssddanbrown/asserthtml", "version": "v3.2.0",
275 "dist": {"type": "zip", "url": "https://codeberg.org/api/v1/repos/%package%/archive/%prettyVersion%.zip?r=%reference%&t=%type%&v=%version%", "reference": "0811b5c"}}"#).unwrap(),
276 };
277 assert_eq!(
278 p.dist_url_expanded().unwrap(),
279 "https://codeberg.org/api/v1/repos/ssddanbrown/asserthtml/archive/v3.2.0.zip?r=0811b5c&t=zip&v=3.2.0.0"
280 );
281 }
282}