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 std::{
7	fs::{read_to_string, write},
8	path::{Path, PathBuf},
9};
10use toml_edit::{value, Array, DocumentMut, Item, Value};
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: Option<&Path>) -> Result<Manifest, Error> {
18	// Resolve manifest path
19	let path = match path {
20		Some(path) => match path.ends_with("Cargo.toml") {
21			true => path.to_path_buf(),
22			false => path.join("Cargo.toml"),
23		},
24		None => PathBuf::from("./Cargo.toml"),
25	};
26	if !path.exists() {
27		return Err(Error::ManifestPath(path.display().to_string()));
28	}
29	Ok(Manifest::from_path(path.canonicalize()?)?)
30}
31
32/// This function is used to determine if a Path is contained inside a workspace, and returns a
33/// PathBuf to the workspace Cargo.toml if found.
34///
35/// # Arguments
36/// * `target_dir` - A directory that may be contained inside a workspace
37pub fn find_workspace_toml(target_dir: &Path) -> Option<PathBuf> {
38	let mut dir = target_dir;
39	while let Some(parent) = dir.parent() {
40		// This condition is necessary to avoid that calling the function from a workspace using a
41		// path which isn't contained in a workspace returns `Some(Cargo.toml)` refering the
42		// workspace from where the function has been called instead of the expected `None`.
43		if parent.to_str() == Some("") {
44			return None;
45		}
46		let cargo_toml = parent.join("Cargo.toml");
47		if cargo_toml.exists() {
48			if let Ok(contents) = read_to_string(&cargo_toml) {
49				if contents.contains("[workspace]") {
50					return Some(cargo_toml);
51				}
52			}
53		}
54		dir = parent;
55	}
56	None
57}
58
59/// This function is used to add a crate to a workspace.
60/// # Arguments
61///
62/// * `workspace_toml` - The path to the workspace `Cargo.toml`
63/// * `crate_path`: The path to the crate that should be added to the workspace
64pub fn add_crate_to_workspace(workspace_toml: &Path, crate_path: &Path) -> anyhow::Result<()> {
65	let toml_contents = read_to_string(workspace_toml)?;
66	let mut doc = toml_contents.parse::<DocumentMut>()?;
67
68	// Find the workspace dir
69	let workspace_dir = workspace_toml.parent().expect("A file always lives inside a dir; qed");
70	// Find the relative path to the crate from the workspace root
71	let crate_relative_path = crate_path.strip_prefix(workspace_dir)?;
72
73	if let Some(Item::Table(workspace_table)) = doc.get_mut("workspace") {
74		if let Some(Item::Value(members_array)) = workspace_table.get_mut("members") {
75			if let Value::Array(array) = members_array {
76				let crate_relative_path =
77					crate_relative_path.to_str().expect("target's always a valid string; qed");
78				let already_in_array = array
79					.iter()
80					.any(|member| matches!(member.as_str(), Some(s) if s == crate_relative_path));
81				if !already_in_array {
82					array.push(crate_relative_path);
83				}
84			} else {
85				return Err(anyhow::anyhow!("Corrupted workspace"));
86			}
87		} else {
88			let mut toml_array = Array::new();
89			toml_array
90				.push(crate_relative_path.to_str().expect("target's always a valid string; qed"));
91			workspace_table["members"] = value(toml_array);
92		}
93	} else {
94		return Err(anyhow::anyhow!("Corrupted workspace"));
95	}
96
97	write(workspace_toml, doc.to_string())?;
98	Ok(())
99}
100
101/// Adds a "production" profile to the Cargo.toml manifest if it doesn't already exist.
102///
103/// # Arguments
104/// * `project` - The path to the root of the Cargo project containing the Cargo.toml.
105pub fn add_production_profile(project: &Path) -> anyhow::Result<()> {
106	let root_toml_path = project.join("Cargo.toml");
107	let mut manifest = Manifest::from_path(&root_toml_path)?;
108	// Check if the `production` profile already exists.
109	if manifest.profile.custom.contains_key("production") {
110		return Ok(());
111	}
112	// Create the production profile with required fields.
113	let production_profile = Profile {
114		opt_level: None,
115		debug: None,
116		split_debuginfo: None,
117		rpath: None,
118		lto: Some(LtoSetting::Fat),
119		debug_assertions: None,
120		codegen_units: Some(1),
121		panic: None,
122		incremental: None,
123		overflow_checks: None,
124		strip: None,
125		package: std::collections::BTreeMap::new(),
126		build_override: None,
127		inherits: Some("release".to_string()),
128	};
129	// Insert the new profile into the custom profiles
130	manifest.profile.custom.insert("production".to_string(), production_profile);
131
132	// Serialize the updated manifest and write it back to the file
133	let toml_string = toml::to_string(&manifest)?;
134	write(&root_toml_path, toml_string)?;
135
136	Ok(())
137}
138
139/// Add a new feature to the Cargo.toml manifest if it doesn't already exist.
140///
141/// # Arguments
142/// * `project` - The path to the project directory.
143/// * `(key, items)` - The feature key and its associated items.
144pub fn add_feature(project: &Path, (key, items): (String, Vec<String>)) -> anyhow::Result<()> {
145	let root_toml_path = project.join("Cargo.toml");
146	let mut manifest = Manifest::from_path(&root_toml_path)?;
147	// Check if the feature already exists.
148	if manifest.features.contains_key(&key) {
149		return Ok(());
150	}
151	manifest.features.insert(key, items);
152
153	// Serialize the updated manifest and write it back to the file
154	let toml_string = toml::to_string(&manifest)?;
155	write(&root_toml_path, toml_string)?;
156
157	Ok(())
158}
159
160#[cfg(test)]
161mod tests {
162	use super::*;
163	use std::fs::{write, File};
164	use tempfile::TempDir;
165
166	struct TestBuilder {
167		main_tempdir: TempDir,
168		workspace: Option<TempDir>,
169		inside_workspace_dir: Option<TempDir>,
170		workspace_cargo_toml: Option<PathBuf>,
171		outside_workspace_dir: Option<TempDir>,
172	}
173
174	impl Default for TestBuilder {
175		fn default() -> Self {
176			Self {
177				main_tempdir: TempDir::new().expect("Failed to create tempdir"),
178				workspace: None,
179				inside_workspace_dir: None,
180				workspace_cargo_toml: None,
181				outside_workspace_dir: None,
182			}
183		}
184	}
185
186	impl TestBuilder {
187		fn add_workspace(self) -> Self {
188			Self { workspace: TempDir::new_in(self.main_tempdir.as_ref()).ok(), ..self }
189		}
190
191		fn add_inside_workspace_dir(self) -> Self {
192			Self {
193				inside_workspace_dir: TempDir::new_in(self.workspace.as_ref().expect(
194					"add_inside_workspace_dir is only callable if workspace has been created",
195				))
196				.ok(),
197				..self
198			}
199		}
200
201		fn add_workspace_cargo_toml(self, cargo_toml_content: &str) -> Self {
202			let workspace_cargo_toml = self
203				.workspace
204				.as_ref()
205				.expect("add_workspace_cargo_toml is only callable if workspace has been created")
206				.path()
207				.join("Cargo.toml");
208			File::create(&workspace_cargo_toml).expect("Failed to create Cargo.toml");
209			write(&workspace_cargo_toml, cargo_toml_content).expect("Failed to write Cargo.toml");
210			Self { workspace_cargo_toml: Some(workspace_cargo_toml.to_path_buf()), ..self }
211		}
212
213		fn add_outside_workspace_dir(self) -> Self {
214			Self { outside_workspace_dir: TempDir::new_in(self.main_tempdir.as_ref()).ok(), ..self }
215		}
216	}
217
218	#[test]
219	fn from_path_works() -> anyhow::Result<()> {
220		// Workspace manifest from directory
221		from_path(Some(Path::new("../../")))?;
222		// Workspace manifest from path
223		from_path(Some(Path::new("../../Cargo.toml")))?;
224		// Package manifest from directory
225		from_path(Some(Path::new(".")))?;
226		// Package manifest from path
227		from_path(Some(Path::new("./Cargo.toml")))?;
228		// None
229		from_path(None)?;
230		Ok(())
231	}
232
233	#[test]
234	fn from_path_ensures_manifest_exists() -> Result<(), Error> {
235		assert!(matches!(
236			from_path(Some(Path::new("./none.toml"))),
237			Err(super::Error::ManifestPath(..))
238		));
239		Ok(())
240	}
241
242	#[test]
243	fn find_workspace_toml_works_well() {
244		let test_builder = TestBuilder::default()
245			.add_workspace()
246			.add_inside_workspace_dir()
247			.add_workspace_cargo_toml(
248				r#"[workspace]
249                resolver = "2"
250                members = ["member1"]
251                "#,
252			)
253			.add_outside_workspace_dir();
254		assert!(find_workspace_toml(
255			test_builder
256				.inside_workspace_dir
257				.as_ref()
258				.expect("Inside workspace dir should exist")
259				.path()
260		)
261		.is_some());
262		assert_eq!(
263			find_workspace_toml(
264				test_builder
265					.inside_workspace_dir
266					.as_ref()
267					.expect("Inside workspace dir should exist")
268					.path()
269			)
270			.expect("The Cargo.toml should exist at this point"),
271			test_builder.workspace_cargo_toml.expect("Cargo.toml should exist")
272		);
273		assert!(find_workspace_toml(
274			test_builder
275				.outside_workspace_dir
276				.as_ref()
277				.expect("Outside workspace dir should exist")
278				.path()
279		)
280		.is_none());
281		// Calling the function from a relative path which parent is "" returns None
282		assert!(find_workspace_toml(&PathBuf::from("..")).is_none());
283	}
284
285	#[test]
286	fn add_crate_to_workspace_works_well_if_members_exists() {
287		let test_builder = TestBuilder::default()
288			.add_workspace()
289			.add_workspace_cargo_toml(
290				r#"[workspace]
291                resolver = "2"
292                members = ["member1"]
293                "#,
294			)
295			.add_inside_workspace_dir();
296		let add_crate = add_crate_to_workspace(
297			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
298			test_builder
299				.inside_workspace_dir
300				.as_ref()
301				.expect("Inside workspace dir should exist")
302				.path(),
303		);
304		assert!(add_crate.is_ok());
305		let content = read_to_string(
306			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
307		)
308		.expect("Cargo.toml should be readable");
309		let doc = content.parse::<DocumentMut>().expect("This should work");
310		if let Some(Item::Table(workspace_table)) = doc.get("workspace") {
311			if let Some(Item::Value(Value::Array(array))) = workspace_table.get("members") {
312				assert!(array.iter().any(|item| {
313					if let Value::String(item) = item {
314						// item is only the relative path from the Cargo.toml manifest, while
315						// test_buildder.insider_workspace_dir is the absolute path, so we can only
316						// test with contains
317						test_builder
318							.inside_workspace_dir
319							.as_ref()
320							.expect("Inside workspace should exist")
321							.path()
322							.to_str()
323							.expect("Dir should be mapped to a str")
324							.contains(item.value())
325					} else {
326						false
327					}
328				}));
329			} else {
330				panic!("This shouldn't be reached");
331			}
332		} else {
333			panic!("This shouldn't be reached");
334		}
335
336		// Calling with a crate that's already in the workspace doesn't include it twice
337		let add_crate = add_crate_to_workspace(
338			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
339			test_builder
340				.inside_workspace_dir
341				.as_ref()
342				.expect("Inside workspace dir should exist")
343				.path(),
344		);
345		assert!(add_crate.is_ok());
346		let doc = content.parse::<DocumentMut>().expect("This should work");
347		if let Some(Item::Table(workspace_table)) = doc.get("workspace") {
348			if let Some(Item::Value(Value::Array(array))) = workspace_table.get("members") {
349				assert_eq!(
350					array
351						.iter()
352						.filter(|item| {
353							if let Value::String(item) = item {
354								test_builder
355									.inside_workspace_dir
356									.as_ref()
357									.expect("Inside workspace should exist")
358									.path()
359									.to_str()
360									.expect("Dir should be mapped to a str")
361									.contains(item.value())
362							} else {
363								false
364							}
365						})
366						.count(),
367					1
368				);
369			} else {
370				panic!("This shouldn't be reached");
371			}
372		} else {
373			panic!("This shouldn't be reached");
374		}
375	}
376
377	#[test]
378	fn add_crate_to_workspace_works_well_if_members_doesnt_exist() {
379		let test_builder = TestBuilder::default()
380			.add_workspace()
381			.add_workspace_cargo_toml(
382				r#"[workspace]
383                resolver = "2"
384                "#,
385			)
386			.add_inside_workspace_dir();
387		let add_crate = add_crate_to_workspace(
388			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
389			test_builder
390				.inside_workspace_dir
391				.as_ref()
392				.expect("Inside workspace dir should exist")
393				.path(),
394		);
395		assert!(add_crate.is_ok());
396		let content = read_to_string(
397			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
398		)
399		.expect("Cargo.toml should be readable");
400		let doc = content.parse::<DocumentMut>().expect("This should work");
401		if let Some(Item::Table(workspace_table)) = doc.get("workspace") {
402			if let Some(Item::Value(Value::Array(array))) = workspace_table.get("members") {
403				assert!(array.iter().any(|item| {
404					if let Value::String(item) = item {
405						test_builder
406							.inside_workspace_dir
407							.as_ref()
408							.expect("Inside workspace should exist")
409							.path()
410							.to_str()
411							.expect("Dir should be mapped to a str")
412							.contains(item.value())
413					} else {
414						false
415					}
416				}));
417			} else {
418				panic!("This shouldn't be reached");
419			}
420		} else {
421			panic!("This shouldn't be reached");
422		}
423	}
424
425	#[test]
426	fn add_crate_to_workspace_fails_if_crate_path_not_inside_workspace() {
427		let test_builder = TestBuilder::default()
428			.add_workspace()
429			.add_workspace_cargo_toml(
430				r#"[workspace]
431                resolver = "2"
432                members = ["member1"]
433                "#,
434			)
435			.add_outside_workspace_dir();
436		let add_crate = add_crate_to_workspace(
437			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
438			test_builder
439				.outside_workspace_dir
440				.expect("Inside workspace dir should exist")
441				.path(),
442		);
443		assert!(add_crate.is_err());
444	}
445
446	#[test]
447	fn add_crate_to_workspace_fails_if_members_not_an_array() {
448		let test_builder = TestBuilder::default()
449			.add_workspace()
450			.add_workspace_cargo_toml(
451				r#"[workspace]
452                resolver = "2"
453                members = "member1"
454                "#,
455			)
456			.add_inside_workspace_dir();
457		let add_crate = add_crate_to_workspace(
458			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
459			test_builder
460				.inside_workspace_dir
461				.expect("Inside workspace dir should exist")
462				.path(),
463		);
464		assert!(add_crate.is_err());
465	}
466
467	#[test]
468	fn add_crate_to_workspace_fails_if_workspace_isnt_workspace() {
469		let test_builder = TestBuilder::default()
470			.add_workspace()
471			.add_workspace_cargo_toml(r#""#)
472			.add_inside_workspace_dir();
473		let add_crate = add_crate_to_workspace(
474			test_builder.workspace_cargo_toml.as_ref().expect("Workspace should exist"),
475			test_builder
476				.inside_workspace_dir
477				.expect("Inside workspace dir should exist")
478				.path(),
479		);
480		assert!(add_crate.is_err());
481	}
482
483	#[test]
484	fn add_production_profile_works() {
485		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
486			r#"[profile.release]
487            opt-level = 3
488            "#,
489		);
490
491		let binding = test_builder.workspace.expect("Workspace should exist");
492		let project_path = binding.path();
493		let cargo_toml_path = project_path.join("Cargo.toml");
494
495		// Call the function to add the production profile
496		let result = add_production_profile(project_path);
497		assert!(result.is_ok());
498
499		// Verify the production profile is added
500		let manifest =
501			Manifest::from_path(&cargo_toml_path).expect("Should parse updated Cargo.toml");
502		let production_profile = manifest
503			.profile
504			.custom
505			.get("production")
506			.expect("Production profile should exist");
507		assert_eq!(production_profile.codegen_units, Some(1));
508		assert_eq!(production_profile.inherits.as_deref(), Some("release"));
509		assert_eq!(production_profile.lto, Some(LtoSetting::Fat));
510
511		// Test idempotency: Running the function again should not modify the manifest
512		let initial_toml_content =
513			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
514		let second_result = add_production_profile(project_path);
515		assert!(second_result.is_ok());
516		let final_toml_content =
517			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
518		assert_eq!(initial_toml_content, final_toml_content);
519	}
520
521	#[test]
522	fn add_feature_works() {
523		let test_builder = TestBuilder::default().add_workspace().add_workspace_cargo_toml(
524			r#"[profile.release]
525            opt-level = 3
526            "#,
527		);
528
529		let expected_feature_key = "runtime-benchmarks";
530		let expected_feature_items =
531			vec!["feature-a".to_string(), "feature-b".to_string(), "feature-c".to_string()];
532		let binding = test_builder.workspace.expect("Workspace should exist");
533		let project_path = binding.path();
534		let cargo_toml_path = project_path.join("Cargo.toml");
535
536		// Call the function to add the production profile
537		let result = add_feature(
538			project_path,
539			(expected_feature_key.to_string(), expected_feature_items.clone()),
540		);
541		assert!(result.is_ok());
542
543		// Verify the feature is added
544		let manifest =
545			Manifest::from_path(&cargo_toml_path).expect("Should parse updated Cargo.toml");
546		let feature_items = manifest
547			.features
548			.get(expected_feature_key)
549			.expect("Production profile should exist");
550		assert_eq!(feature_items, &expected_feature_items);
551
552		// Test idempotency: Running the function again should not modify the manifest
553		let initial_toml_content =
554			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
555		let second_result = add_feature(
556			project_path,
557			(expected_feature_key.to_string(), expected_feature_items.clone()),
558		);
559		assert!(second_result.is_ok());
560		let final_toml_content =
561			read_to_string(&cargo_toml_path).expect("Cargo.toml should be readable");
562		assert_eq!(initial_toml_content, final_toml_content);
563	}
564}