Skip to main content

sofie_model/
metadata.rs

1use std::cell::OnceCell;
2use std::collections::BTreeMap;
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use serde_json::Value;
9
10use crate::naming::crate_name;
11use crate::{Dependency, ModelError};
12
13#[derive(Clone)]
14pub(crate) struct Package {
15    pub name: String,
16    /// The lib target's name, which `[lib] name` may set apart from the package's.
17    pub crate_name: String,
18    pub dependencies: Vec<Dependency>,
19    pub directory: PathBuf,
20    pub serves: Vec<String>,
21    pub remote: Vec<String>,
22    pub structure_ceilings: bool,
23}
24
25/// Read through `cargo metadata` so each manifest is interpreted exactly as Cargo does.
26pub(crate) struct Packages {
27    manifest: PathBuf,
28    by_manifest: BTreeMap<PathBuf, Package>,
29    /// Resolving the whole dependency graph can be slow and need the network, so it is read once and only on demand.
30    resolved: OnceCell<Value>,
31}
32
33#[derive(Clone, Copy)]
34enum Graph {
35    WorkspaceOnly,
36    WithDependencies,
37}
38
39impl Packages {
40    pub(crate) fn read(manifest: &Path) -> Result<Self, ModelError> {
41        Ok(Self {
42            manifest: manifest.to_path_buf(),
43            by_manifest: packages_of(manifest, &cargo_metadata(manifest, Graph::WorkspaceOnly)?)?,
44            resolved: OnceCell::new(),
45        })
46    }
47
48    pub(crate) fn at(&self, manifest: &Path) -> Result<&Package, ModelError> {
49        self.by_manifest.get(&canonical(manifest)?).ok_or_else(|| {
50            ModelError::in_file(
51                manifest,
52                "`cargo metadata` did not list this package; make it a member of the app's workspace",
53            )
54        })
55    }
56
57    pub(crate) fn members(&self) -> impl Iterator<Item = &Package> {
58        self.by_manifest.values()
59    }
60
61    pub(crate) fn dependency(&self, name: &str) -> Result<Option<Package>, ModelError> {
62        if let Some(member) = named(&self.by_manifest, name) {
63            return Ok(Some(member.clone()));
64        }
65        let resolved = packages_of(&self.manifest, self.resolved()?)?;
66        Ok(named(&resolved, name).cloned())
67    }
68
69    pub(crate) fn enabled_features(&self, name: &str) -> Result<Vec<String>, ModelError> {
70        Ok(enabled_features(self.resolved()?, name))
71    }
72
73    fn resolved(&self) -> Result<&Value, ModelError> {
74        if let Some(resolved) = self.resolved.get() {
75            return Ok(resolved);
76        }
77        let resolved = cargo_metadata(&self.manifest, Graph::WithDependencies)?;
78        Ok(self.resolved.get_or_init(|| resolved))
79    }
80}
81
82const FRAMEWORK_PACKAGE: &str = "sofie-framework";
83
84/// Resolving them reads the whole dependency graph, so they are read only when asked.
85pub fn framework_features(app_root: &Path) -> Result<Vec<String>, ModelError> {
86    let manifest = app_root.join("app").join("Cargo.toml");
87    let metadata = cargo_metadata(&manifest, Graph::WithDependencies)?;
88    Ok(enabled_features(&metadata, FRAMEWORK_PACKAGE))
89}
90
91/// As Cargo resolved them for the whole workspace, so as they are compiled.
92fn enabled_features(metadata: &Value, name: &str) -> Vec<String> {
93    let id = metadata["packages"]
94        .as_array()
95        .into_iter()
96        .flatten()
97        .find(|package| package["name"] == name)
98        .and_then(|package| package["id"].as_str());
99    let nodes = metadata["resolve"]["nodes"]
100        .as_array()
101        .into_iter()
102        .flatten();
103    nodes
104        .filter(|node| id.is_some() && node["id"].as_str() == id)
105        .flat_map(|node| node["features"].as_array().into_iter().flatten())
106        .filter_map(|feature| feature.as_str().map(str::to_owned))
107        .collect()
108}
109
110pub fn workspace_root(manifest: &Path) -> Result<PathBuf, ModelError> {
111    let metadata = cargo_metadata(manifest, Graph::WorkspaceOnly)?;
112    metadata["workspace_root"]
113        .as_str()
114        .map(PathBuf::from)
115        .ok_or_else(|| ModelError::in_file(manifest, "`cargo metadata` named no workspace root"))
116}
117
118fn named<'packages>(
119    packages: &'packages BTreeMap<PathBuf, Package>,
120    name: &str,
121) -> Option<&'packages Package> {
122    packages.values().find(|package| package.name == name)
123}
124
125fn packages_of(
126    manifest: &Path,
127    metadata: &Value,
128) -> Result<BTreeMap<PathBuf, Package>, ModelError> {
129    let listed = metadata["packages"]
130        .as_array()
131        .map_or(&[][..], Vec::as_slice);
132    let lib_names: BTreeMap<&str, String> = listed
133        .iter()
134        .filter_map(|package| Some((package["name"].as_str()?, lib_name(package)?)))
135        .collect();
136    listed
137        .iter()
138        .map(|package| package_of(manifest, package, &lib_names))
139        .collect()
140}
141
142fn cargo_metadata(manifest: &Path, graph: Graph) -> Result<Value, ModelError> {
143    // Cargo sets `CARGO` when it runs us; prefer it so the same toolchain reads the manifest.
144    let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
145    let mut command = Command::new(cargo);
146    command.args(["metadata", "--format-version", "1"]);
147    if matches!(graph, Graph::WorkspaceOnly) {
148        command.arg("--no-deps");
149    }
150    let output = command
151        .arg("--manifest-path")
152        .arg(manifest)
153        .output()
154        .map_err(|error| {
155            ModelError::in_file(
156                manifest,
157                format!("running `cargo metadata` failed: {error}"),
158            )
159        })?;
160    if !output.status.success() {
161        let stderr = String::from_utf8_lossy(&output.stderr);
162        return Err(ModelError::in_file(
163            manifest,
164            format!(
165                "`cargo metadata` rejected the workspace; fix it until `cargo metadata` succeeds: {}",
166                stderr.trim()
167            ),
168        ));
169    }
170    serde_json::from_slice(&output.stdout).map_err(|error| {
171        ModelError::in_file(
172            manifest,
173            format!("`cargo metadata` printed invalid JSON: {error}"),
174        )
175    })
176}
177
178fn package_of(
179    manifest: &Path,
180    package: &Value,
181    lib_names: &BTreeMap<&str, String>,
182) -> Result<(PathBuf, Package), ModelError> {
183    let incomplete = || {
184        ModelError::in_file(
185            manifest,
186            "`cargo metadata` listed a package without a name or manifest path",
187        )
188    };
189    let name = package["name"].as_str().ok_or_else(incomplete)?;
190    let listed_manifest = canonical(Path::new(
191        package["manifest_path"].as_str().ok_or_else(incomplete)?,
192    ))?;
193    let directory = listed_manifest
194        .parent()
195        .map(Path::to_path_buf)
196        .unwrap_or_default();
197    let names = |key: &str| -> Vec<String> {
198        package["metadata"]["sofie"][key]
199            .as_array()
200            .map_or(&[][..], Vec::as_slice)
201            .iter()
202            .filter_map(|name| name.as_str().map(str::to_owned))
203            .collect()
204    };
205    let package = Package {
206        name: name.to_owned(),
207        crate_name: lib_names
208            .get(name)
209            .cloned()
210            .unwrap_or_else(|| crate_name(name)),
211        dependencies: normal_dependencies(package, lib_names),
212        directory,
213        serves: names("serves"),
214        remote: names("remote"),
215        structure_ceilings: structure_ceilings(package, &listed_manifest)?,
216    };
217    Ok((listed_manifest, package))
218}
219
220fn structure_ceilings(package: &Value, manifest: &Path) -> Result<bool, ModelError> {
221    match &package["metadata"]["sofie"]["structure-ceilings"] {
222        Value::Null => Ok(false),
223        Value::Bool(on) => Ok(*on),
224        _ => Err(ModelError::in_file(
225            manifest,
226            "`[package.metadata.sofie] structure-ceilings` is `true` or `false`",
227        )),
228    }
229}
230
231/// `crate-type` lists these in place of `lib`.
232const LIB_KINDS: [&str; 6] = ["lib", "rlib", "dylib", "cdylib", "staticlib", "proc-macro"];
233
234fn lib_name(package: &Value) -> Option<String> {
235    let targets = package["targets"].as_array()?;
236    let lib = targets.iter().find(|target| {
237        target["kind"].as_array().is_some_and(|kinds| {
238            kinds
239                .iter()
240                .any(|kind| LIB_KINDS.iter().any(|lib| kind == lib))
241        })
242    })?;
243    lib["name"].as_str().map(crate_name)
244}
245
246fn canonical(manifest: &Path) -> Result<PathBuf, ModelError> {
247    fs::canonicalize(manifest).map_err(|error| {
248        ModelError::in_file(manifest, format!("cannot read the manifest: {error}"))
249    })
250}
251
252/// A package `cargo metadata` did not list is named after itself: knowing its `[lib] name` needs the slow full graph.
253fn normal_dependencies(package: &Value, lib_names: &BTreeMap<&str, String>) -> Vec<Dependency> {
254    let dependencies = package["dependencies"]
255        .as_array()
256        .map_or(&[][..], Vec::as_slice);
257    dependencies
258        .iter()
259        .filter(|dependency| dependency["kind"].is_null())
260        .filter_map(|dependency| {
261            let package = dependency["name"].as_str()?;
262            let crate_name = match dependency["rename"].as_str() {
263                Some(rename) => crate_name(rename),
264                None => lib_names
265                    .get(package)
266                    .cloned()
267                    .unwrap_or_else(|| crate_name(package)),
268            };
269            Some(Dependency {
270                package: package.to_owned(),
271                crate_name,
272            })
273        })
274        .collect()
275}
276
277#[cfg(test)]
278mod tests {
279    use serde_json::json;
280
281    use super::enabled_features;
282
283    #[test]
284    fn enabled_features_are_the_resolved_ones_of_the_named_package() {
285        let metadata = json!({
286            "packages": [
287                {"name": "sofie-framework", "id": "framework 0.1"},
288                {"name": "shop", "id": "shop 0.1"},
289            ],
290            "resolve": {"nodes": [
291                {"id": "shop 0.1", "features": ["default"]},
292                {"id": "framework 0.1", "features": ["engine", "i18n"]},
293            ]},
294        });
295
296        assert_eq!(
297            enabled_features(&metadata, "sofie-framework"),
298            ["engine", "i18n"]
299        );
300        assert_eq!(
301            enabled_features(&metadata, "sofie-identity"),
302            Vec::<String>::new()
303        );
304    }
305}