Skip to main content

vivacity_core/
lock.rs

1//! Reading composer.lock. Minimal typed view over the raw JSON:
2//! `installed.json`/`installed.php` will have to serve the entries back
3//! **unchanged**, so each package keeps its raw value (`raw`) and only exposes
4//! as typed fields what the installer needs.
5
6use 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    /// Project platform constraints (php, ext-*, lib-*) -> constraint.
16    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    /// A `path` repository package: `dist.url` is the source directory.
31    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    /// Package type, Composer default: "library".
50    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    /// `Package::getDistUrls`: a dist url holding `%` goes through
59    /// `ComposerMirror::processUrl` — `%package%`, `%version%` (normalized),
60    /// `%reference%`, `%type%` and `%prettyVersion%` are substituted (a
61    /// `package` repository entry such as
62    /// `https://host/archive/%prettyVersion%.zip`).
63    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    /// sha1 shasum of the dist if non-empty (often empty on Packagist).
90    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    /// `target-dir` (legacy PSR-0): the package installs into
113    /// vendor/<name>/<target-dir>.
114    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    /// Install path relative to vendor/ (`name` or `name/target-dir`).
121    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    /// Installed nowhere: a metapackage, or a `symfony-pack` when Flex is
133    /// active (its `SymfonyPackInstaller extends MetapackageInstaller`).
134    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
158/// `platform` is `{}` or `{"php": ">=8.2", "ext-mbstring": "*"}` and, PHP
159/// encoding quirk, sometimes `[]` (empty array) when there is nothing.
160fn 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(Lock {
177            content_hash: v
178                .get("content-hash")
179                .and_then(Value::as_str)
180                .map(str::to_owned),
181            packages: parse_packages(v.get("packages")),
182            packages_dev: parse_packages(v.get("packages-dev")),
183            platform: parse_platform(v.get("platform")),
184            platform_dev: parse_platform(v.get("platform-dev")),
185            plugin_api_version: v
186                .get("plugin-api-version")
187                .and_then(Value::as_str)
188                .map(str::to_owned),
189            aliases: v
190                .get("aliases")
191                .and_then(Value::as_array)
192                .cloned()
193                .unwrap_or_default(),
194        })
195    }
196
197    pub fn read(path: &Path) -> Result<Self> {
198        let text = std::fs::read_to_string(path).map_err(|source| Error::ReadFile {
199            path: path.to_path_buf(),
200            source,
201        })?;
202        Self::parse(&text)
203    }
204
205    /// Packages to install according to --no-dev.
206    /// Whether `symfony-pack` packages are virtual for this install: Flex
207    /// is in the lock, plugins are enabled and Flex is allowed (Composer
208    /// loads it and it registers the pack installer).
209    pub fn flex_packs(&self, root_manifest: &Value, with_dev: bool, plugins_enabled: bool) -> bool {
210        plugins_enabled
211            && self
212                .wanted_packages(with_dev)
213                .any(|p| p.name() == "symfony/flex")
214            && matches!(
215                crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
216                crate::layout::PluginVerdict::Allowed
217            )
218    }
219
220    pub fn wanted_packages(&self, with_dev: bool) -> impl Iterator<Item = &LockPackage> {
221        self.packages
222            .iter()
223            .chain(
224                self.packages_dev
225                    .iter()
226                    .take(if with_dev { usize::MAX } else { 0 }),
227            )
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn parses_minimal_lock() {
237        let lock = Lock::parse(
238            r#"{"content-hash":"abc","packages":[{"name":"a/b","version":"1.0.0",
239                "dist":{"type":"zip","url":"https://x/y.zip","reference":"deadbeef","shasum":""},
240                "type":"library","bin":["bin/tool"]}],
241               "packages-dev":[],"platform":{"php":">=8.1"},"platform-dev":[]}"#,
242        )
243        .expect("parse");
244        assert_eq!(lock.content_hash.as_deref(), Some("abc"));
245        let p = &lock.packages[0];
246        assert_eq!(p.name(), "a/b");
247        assert_eq!(p.dist_kind(), DistKind::Zip);
248        assert_eq!(p.dist_shasum(), None); // empty -> None
249        assert_eq!(p.bins(), vec!["bin/tool"]);
250        assert_eq!(lock.platform, vec![("php".to_owned(), ">=8.1".to_owned())]);
251        assert_eq!(lock.wanted_packages(false).count(), 1);
252    }
253
254    #[test]
255    fn empty_platform_as_array() {
256        let lock = Lock::parse(r#"{"packages":[],"platform":[]}"#).expect("parse");
257        assert!(lock.platform.is_empty());
258    }
259}
260
261#[cfg(test)]
262mod placeholder_tests {
263    use super::*;
264
265    #[test]
266    fn dist_url_placeholders_like_composer_mirror() {
267        let p = LockPackage {
268            raw: serde_json::from_str(r#"{"name": "ssddanbrown/asserthtml", "version": "v3.2.0",
269                "dist": {"type": "zip", "url": "https://codeberg.org/api/v1/repos/%package%/archive/%prettyVersion%.zip?r=%reference%&t=%type%&v=%version%", "reference": "0811b5c"}}"#).unwrap(),
270        };
271        assert_eq!(
272            p.dist_url_expanded().unwrap(),
273            "https://codeberg.org/api/v1/repos/ssddanbrown/asserthtml/archive/v3.2.0.zip?r=0811b5c&t=zip&v=3.2.0.0"
274        );
275    }
276}