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    pub fn dist_reference(&self) -> Option<&str> {
59        self.raw.get("dist")?.get("reference")?.as_str()
60    }
61
62    /// sha1 shasum of the dist if non-empty (often empty on Packagist).
63    pub fn dist_shasum(&self) -> Option<&str> {
64        self.raw
65            .get("dist")?
66            .get("shasum")?
67            .as_str()
68            .filter(|s| !s.is_empty())
69    }
70
71    pub fn dist_kind(&self) -> DistKind {
72        match self
73            .raw
74            .get("dist")
75            .and_then(|d| d.get("type"))
76            .and_then(Value::as_str)
77        {
78            Some("zip") => DistKind::Zip,
79            Some("path") => DistKind::Path,
80            Some(_) => DistKind::Other,
81            None => DistKind::Missing,
82        }
83    }
84
85    /// `target-dir` (legacy PSR-0): the package installs into
86    /// vendor/<name>/<target-dir>.
87    pub fn target_dir(&self) -> Option<&str> {
88        self.str_field("target-dir")
89            .map(|t| t.trim_matches('/'))
90            .filter(|t| !t.is_empty())
91    }
92
93    /// Install path relative to vendor/ (`name` or `name/target-dir`).
94    pub fn install_subpath(&self) -> String {
95        match self.target_dir() {
96            Some(t) => format!("{}/{}", self.name(), t),
97            None => self.name().to_owned(),
98        }
99    }
100
101    pub fn is_metapackage(&self) -> bool {
102        self.package_type() == "metapackage"
103    }
104
105    pub fn bins(&self) -> Vec<&str> {
106        self.raw
107            .get("bin")
108            .and_then(Value::as_array)
109            .map(|a| a.iter().filter_map(Value::as_str).collect())
110            .unwrap_or_default()
111    }
112}
113
114fn parse_packages(v: Option<&Value>) -> Vec<LockPackage> {
115    v.and_then(Value::as_array)
116        .map(|a| {
117            a.iter()
118                .filter_map(|p| p.as_object())
119                .map(|m| LockPackage { raw: m.clone() })
120                .collect()
121        })
122        .unwrap_or_default()
123}
124
125/// `platform` is `{}` or `{"php": ">=8.2", "ext-mbstring": "*"}` and, PHP
126/// encoding quirk, sometimes `[]` (empty array) when there is nothing.
127fn parse_platform(v: Option<&Value>) -> Vec<(String, String)> {
128    v.and_then(Value::as_object)
129        .map(|m| {
130            m.iter()
131                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
132                .collect()
133        })
134        .unwrap_or_default()
135}
136
137impl Lock {
138    pub fn parse(text: &str) -> Result<Self> {
139        let v: Value = serde_json::from_str(text).map_err(|source| Error::Json {
140            context: "composer.lock".to_owned(),
141            source,
142        })?;
143        Ok(Lock {
144            content_hash: v
145                .get("content-hash")
146                .and_then(Value::as_str)
147                .map(str::to_owned),
148            packages: parse_packages(v.get("packages")),
149            packages_dev: parse_packages(v.get("packages-dev")),
150            platform: parse_platform(v.get("platform")),
151            platform_dev: parse_platform(v.get("platform-dev")),
152            plugin_api_version: v
153                .get("plugin-api-version")
154                .and_then(Value::as_str)
155                .map(str::to_owned),
156            aliases: v
157                .get("aliases")
158                .and_then(Value::as_array)
159                .cloned()
160                .unwrap_or_default(),
161        })
162    }
163
164    pub fn read(path: &Path) -> Result<Self> {
165        let text = std::fs::read_to_string(path).map_err(|source| Error::ReadFile {
166            path: path.to_path_buf(),
167            source,
168        })?;
169        Self::parse(&text)
170    }
171
172    /// Packages to install according to --no-dev.
173    pub fn wanted_packages(&self, with_dev: bool) -> impl Iterator<Item = &LockPackage> {
174        self.packages
175            .iter()
176            .chain(
177                self.packages_dev
178                    .iter()
179                    .take(if with_dev { usize::MAX } else { 0 }),
180            )
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn parses_minimal_lock() {
190        let lock = Lock::parse(
191            r#"{"content-hash":"abc","packages":[{"name":"a/b","version":"1.0.0",
192                "dist":{"type":"zip","url":"https://x/y.zip","reference":"deadbeef","shasum":""},
193                "type":"library","bin":["bin/tool"]}],
194               "packages-dev":[],"platform":{"php":">=8.1"},"platform-dev":[]}"#,
195        )
196        .expect("parse");
197        assert_eq!(lock.content_hash.as_deref(), Some("abc"));
198        let p = &lock.packages[0];
199        assert_eq!(p.name(), "a/b");
200        assert_eq!(p.dist_kind(), DistKind::Zip);
201        assert_eq!(p.dist_shasum(), None); // empty -> None
202        assert_eq!(p.bins(), vec!["bin/tool"]);
203        assert_eq!(lock.platform, vec![("php".to_owned(), ">=8.1".to_owned())]);
204        assert_eq!(lock.wanted_packages(false).count(), 1);
205    }
206
207    #[test]
208    fn empty_platform_as_array() {
209        let lock = Lock::parse(r#"{"packages":[],"platform":[]}"#).expect("parse");
210        assert!(lock.platform.is_empty());
211    }
212}