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 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
25pub(crate) struct Packages {
27 manifest: PathBuf,
28 by_manifest: BTreeMap<PathBuf, Package>,
29 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
84pub 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
91fn 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 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
231const 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
252fn 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}