pop_common/
manifest.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use crate::Error;
4use anyhow;
5pub use cargo_toml::{Dependency, LtoSetting, Manifest, Profile, Profiles};
6use glob::glob;
7use std::{
8	fs::write,
9	path::{Path, PathBuf},
10};
11
12/// Parses the contents of a `Cargo.toml` manifest.
13///
14/// # Arguments
15/// * `path` - The optional path to the manifest, defaulting to the current directory if not
16///   specified.
17pub fn from_path(path: &Path) -> Result<Manifest, Error> {
18	// Resolve manifest path
19	let path = match path.ends_with("Cargo.toml") {
20		true => path.to_path_buf(),
21		false => path.join("Cargo.toml"),
22	};
23	if !path.is_file() {
24		return Err(Error::ManifestPath(path.display().to_string()));
25	}
26	Ok(Manifest::from_path(path.canonicalize()?)?)
27}
28
29/// Get the names and paths of all cargo projects that are associated with a workspace manifest.
30///
31/// # Arguments
32/// * `manifest` - Path to the workspace manifest root folder.
33pub fn get_workspace_project_names(project_path: &Path) -> Result<Vec<(String, PathBuf)>, Error> {
34	let mut result = Vec::new();
35
36	// Check if this is actually a workspace manifest
37	let manifest = from_path(project_path)?;
38	let workspace = manifest
39		.workspace
40		.as_ref()
41		.ok_or_else(|| Error::Config("Manifest is not a workspace manifest".into()))?;
42
43	// Get workspace members
44	for member in &workspace.members {
45		// Handle glob patterns in member paths
46		for entry in glob(&project_path.join(member).to_string_lossy())
47			.map_err(|e| Error::Config(format!("Invalid glob pattern '{}': {}", member, e)))?
48			.filter_map(Result::ok)
49		{
50			let member_manifest_path = entry.join("Cargo.toml");
51			if member_manifest_path.is_file() {
52				// Parse the member's manifest to get its name
53				if let Ok(member_manifest) = from_path(&member_manifest_path) &&
54					let Some(package) = &member_manifest.package
55				{
56					result.push((package.name.clone(), entry));
57				}
58			}
59		}
60	}
61
62	Ok(result)
63}
64
65/// Adds a "production" profile to the Cargo.toml manifest if it doesn't already exist.
66///
67/// # Arguments
68/// * `project` - The path to the root of the Cargo project containing the Cargo.toml.
69pub fn add_production_profile(project: &Path) -> anyhow::Result<()> {
70	let root_toml_path = project.join("Cargo.toml");
71	let mut manifest = Manifest::from_path(&root_toml_path)?;
72	// Check if the `production` profile already exists.
73	if manifest.profile.custom.contains_key("production") {
74		return Ok(());
75	}
76	// Create the production profile with required fields.
77	let production_profile = Profile {
78		opt_level: None,
79		debug: None,
80		split_debuginfo: None,
81		rpath: None,
82		lto: Some(LtoSetting::Fat),
83		debug_assertions: None,
84		codegen_units: Some(1),
85		panic: None,
86		incremental: None,
87		overflow_checks: None,
88		strip: None,
89		package: std::collections::BTreeMap::new(),
90		build_override: None,
91		inherits: Some("release".to_string()),
92	};
93	// Insert the new profile into the custom profiles
94	manifest.profile.custom.insert("production".to_string(), production_profile);
95
96	// Serialize the updated manifest and write it back to the file
97	let toml_string = toml::to_string(&manifest)?;
98	write(&root_toml_path, toml_string)?;
99
100	Ok(())
101}
102
103/// Add a new feature to the Cargo.toml manifest if it doesn't already exist.
104///
105/// # Arguments
106/// * `project` - The path to the project directory.
107/// * `(key, items)` - The feature key and its associated items.
108pub fn add_feature(project: &Path, (key, items): (String, Vec<String>)) -> anyhow::Result<()> {
109	let root_toml_path = project.join("Cargo.toml");
110	let mut manifest = Manifest::from_path(&root_toml_path)?;
111	// Check if the feature already exists.
112	if manifest.features.contains_key(&key) {
113		return Ok(());
114	}
115	manifest.features.insert(key, items);
116
117	// Serialize the updated manifest and write it back to the file
118	let toml_string = toml::to_string(&manifest)?;
119	write(&root_toml_path, toml_string)?;
120
121	Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126	use super::*;
127	use std::fs::{File, read_to_string, write};
128	use tempfile::TempDir;
129
130	struct TestBuilder {
131		main_tempdir: TempDir,
132		workspace: Option<TempDir>,
133		workspace_cargo_toml: Option<PathBuf>,
134	}
135
136	impl Default for TestBuilder {
137		fn default() -> Self {
138			Self {
139				main_tempdir: TempDir::new().expect("Failed to create tempdir"),
140				workspace: None,
141				workspace_cargo_toml: None,
142			}
143		}
144	}
145
146	impl TestBuilder {
147		fn add_workspace(self) -> Self {
148			Self { workspace: TempDir::new_in(self.main_tempdir.as_ref()).ok(), ..self }
149		}
150
151		fn add_workspace_cargo_toml(self, cargo_toml_content: &str) -> Self {
152			let workspace_cargo_toml = self
153				.workspace
154				.as_ref()
155				.expect("add_workspace_cargo_toml is only callable if workspace has been created")
156				.path()
157				.join("Cargo.toml");
158			File::create(&workspace_cargo_toml).expect("Failed to create Cargo.toml");
159			write(&workspace_cargo_toml, cargo_toml_content).expect("Failed to write Cargo.toml");
160			Self { workspace_cargo_toml: Some(workspace_cargo_toml.to_path_buf()), ..self }
161		}
162	}
163
164	#[test]
165	fn from_path_works() -> anyhow::Result<()> {
166		// Workspace manifest from directory
167		from_path(Path::new("../../"))?;
168		// Workspace manifest from path
169		from_path(Path::new("../../Cargo.toml"))?;
170		// Package manifest from directory
171		from_path(Path::new("."))?;
172		// Package manifest from path
173		from_path(Path::new("./Cargo.toml"))?;
174		Ok(())
175	}
176
177	#[test]
178	fn from_path_ensures_manifest_exists() -> Result<(), Error> {
179		assert!(matches!(from_path(Path::new("./none.toml")), Err(super::Error::ManifestPath(..))));
180		Ok(())
181	}
182
183	#[test]
184	fn add_production_profile_works() {
185		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
186			r#"[profile.release]
187            opt-level = 3
188            "#,
189		);
190
191		let binding = test_builder.workspace.expect("Workspace should exist");
192		let project_path = binding.path();
193		let cargo_toml_path = test_builder.workspace_cargo_toml.clone().unwrap();
194
195		// Call the function to add the production profile
196		let result = add_production_profile(project_path);
197		assert!(result.is_ok());
198
199		// Verify the production profile is added
200		let manifest =
201			Manifest::from_path(&cargo_toml_path).expect("Should parse updated Cargo.toml");
202		let production_profile = manifest
203			.profile
204			.custom
205			.get("production")
206			.expect("Production profile should exist");
207		assert_eq!(production_profile.codegen_units, Some(1));
208		assert_eq!(production_profile.inherits.as_deref(), Some("release"));
209		assert_eq!(production_profile.lto, Some(LtoSetting::Fat));
210
211		// Test idempotency: Running the function again should not modify the manifest
212		let initial_toml_content =
213			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
214		let second_result = add_production_profile(project_path);
215		assert!(second_result.is_ok());
216		let final_toml_content =
217			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
218		assert_eq!(initial_toml_content, final_toml_content);
219	}
220
221	#[test]
222	fn add_feature_works() {
223		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
224			r#"[profile.release]
225            opt-level = 3
226            "#,
227		);
228
229		let expected_feature_key = "runtime-benchmarks";
230		let expected_feature_items =
231			vec!["feature-a".to_string(), "feature-b".to_string(), "feature-c".to_string()];
232		let binding = test_builder.workspace.expect("Workspace should exist");
233		let project_path = binding.path();
234		let cargo_toml_path = test_builder.workspace_cargo_toml.clone().unwrap();
235
236		// Call the function to add the production profile
237		let result = add_feature(
238			project_path,
239			(expected_feature_key.to_string(), expected_feature_items.clone()),
240		);
241		assert!(result.is_ok());
242
243		// Verify the feature is added
244		let manifest =
245			Manifest::from_path(&cargo_toml_path).expect("Should parse updated Cargo.toml");
246		let feature_items = manifest
247			.features
248			.get(expected_feature_key)
249			.expect("Production profile should exist");
250		assert_eq!(feature_items, &expected_feature_items);
251
252		// Test idempotency: Running the function again should not modify the manifest
253		let initial_toml_content =
254			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
255		let second_result = add_feature(
256			project_path,
257			(expected_feature_key.to_string(), expected_feature_items.clone()),
258		);
259		assert!(second_result.is_ok());
260		let final_toml_content =
261			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
262		assert_eq!(initial_toml_content, final_toml_content);
263	}
264
265	#[test]
266	fn get_workspace_project_names_works() {
267		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
268			r#"[workspace]
269members = ["crate1", "crate2"]
270
271[workspace.package]
272name = "test-workspace"
273"#,
274		);
275
276		let binding = test_builder.workspace.expect("Workspace should exist");
277		let workspace_path = binding.path();
278
279		// Create member crates
280		let crate1_path = workspace_path.join("crate1");
281		std::fs::create_dir(&crate1_path).expect("Should create crate1 directory");
282		write(
283			crate1_path.join("Cargo.toml"),
284			r#"[package]
285name = "crate1"
286version = "0.1.0"
287"#,
288		)
289		.expect("Should write crate1 Cargo.toml");
290
291		let crate2_path = workspace_path.join("crate2");
292		std::fs::create_dir(&crate2_path).expect("Should create crate2 directory");
293		write(
294			crate2_path.join("Cargo.toml"),
295			r#"[package]
296name = "crate2"
297version = "0.1.0"
298"#,
299		)
300		.expect("Should write crate2 Cargo.toml");
301
302		let result = get_workspace_project_names(workspace_path).expect("Should succeed");
303		assert_eq!(result.len(), 2);
304
305		// Check that both crates are found
306		let names: Vec<String> = result.iter().map(|(name, _)| name.clone()).collect();
307		assert!(names.contains(&"crate1".to_string()));
308		assert!(names.contains(&"crate2".to_string()));
309
310		// Check paths
311		let paths: Vec<PathBuf> = result.iter().map(|(_, path)| path.clone()).collect();
312		assert!(paths.contains(&crate1_path));
313		assert!(paths.contains(&crate2_path));
314	}
315
316	#[test]
317	fn get_workspace_project_names_with_glob_patterns_works() {
318		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
319			r#"[workspace]
320members = ["crates/*"]
321
322[workspace.package]
323name = "test-workspace"
324"#,
325		);
326
327		let binding = test_builder.workspace.expect("Workspace should exist");
328		let workspace_path = binding.path();
329
330		// Create crates directory
331		let crates_dir = workspace_path.join("crates");
332		std::fs::create_dir(&crates_dir).expect("Should create crates directory");
333
334		// Create member crates using glob pattern
335		let crate1_path = crates_dir.join("crate1");
336		std::fs::create_dir(&crate1_path).expect("Should create crate1 directory");
337		write(
338			crate1_path.join("Cargo.toml"),
339			r#"[package]
340name = "crate1"
341version = "0.1.0"
342"#,
343		)
344		.expect("Should write crate1 Cargo.toml");
345
346		let crate2_path = crates_dir.join("crate2");
347		std::fs::create_dir(&crate2_path).expect("Should create crate2 directory");
348		write(
349			crate2_path.join("Cargo.toml"),
350			r#"[package]
351name = "crate2"
352version = "0.1.0"
353"#,
354		)
355		.expect("Should write crate2 Cargo.toml");
356
357		let result = get_workspace_project_names(workspace_path).expect("Should succeed");
358		assert_eq!(result.len(), 2);
359
360		// Check that both crates are found
361		let names: Vec<String> = result.iter().map(|(name, _)| name.clone()).collect();
362		assert!(names.contains(&"crate1".to_string()));
363		assert!(names.contains(&"crate2".to_string()));
364	}
365
366	#[test]
367	fn get_workspace_project_names_fails_for_non_workspace() {
368		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
369			r#"[package]
370name = "not-a-workspace"
371version = "0.1.0"
372"#,
373		);
374
375		let binding = test_builder.workspace.expect("Workspace should exist");
376		let workspace_path = binding.path();
377
378		let result = get_workspace_project_names(workspace_path);
379		assert!(result.is_err());
380		assert!(matches!(result.unwrap_err(), Error::Config(_)));
381	}
382
383	#[test]
384	fn get_workspace_project_names_returns_empty_for_no_members() {
385		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
386			r#"[workspace]
387members = []
388
389[workspace.package]
390name = "test-workspace"
391"#,
392		);
393
394		let binding = test_builder.workspace.expect("Workspace should exist");
395		let workspace_path = binding.path();
396
397		let result = get_workspace_project_names(workspace_path).expect("Should succeed");
398		assert_eq!(result.len(), 0);
399	}
400}