1use crate::error::{Error, Result};
8use crate::version::Version;
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use toml_edit::{DocumentMut, Item, Value};
13
14pub struct CargoManifest {
16 root: PathBuf,
17}
18
19impl CargoManifest {
20 #[must_use]
22 pub fn new(root: &Path) -> Self {
23 Self {
24 root: root.to_path_buf(),
25 }
26 }
27
28 fn root_manifest_path(&self) -> PathBuf {
30 self.root.join("Cargo.toml")
31 }
32
33 fn read_root_manifest(&self) -> Result<DocumentMut> {
35 let path = self.root_manifest_path();
36 let content = fs::read_to_string(&path).map_err(|e| {
37 Error::manifest(
38 format!("Failed to read root Cargo.toml: {e}"),
39 Some(path.clone()),
40 )
41 })?;
42 content.parse::<DocumentMut>().map_err(|e| {
43 Error::manifest(format!("Failed to parse root Cargo.toml: {e}"), Some(path))
44 })
45 }
46
47 pub fn read_workspace_version(&self) -> Result<Version> {
55 let doc = self.read_root_manifest()?;
56
57 let version_str = doc
58 .get("workspace")
59 .and_then(|w| w.get("package"))
60 .and_then(|p| p.get("version"))
61 .and_then(|v| v.as_str())
62 .ok_or_else(|| {
63 Error::manifest(
64 "No [workspace.package].version found",
65 Some(self.root_manifest_path()),
66 )
67 })?;
68
69 version_str.parse::<Version>()
70 }
71
72 fn discover_members(&self) -> Result<Vec<PathBuf>> {
74 let doc = self.read_root_manifest()?;
75
76 let members = doc
77 .get("workspace")
78 .and_then(|w| w.get("members"))
79 .and_then(|m| m.as_array())
80 .ok_or_else(|| {
81 Error::manifest(
82 "No [workspace].members found",
83 Some(self.root_manifest_path()),
84 )
85 })?;
86
87 let mut paths = Vec::new();
88 for member in members {
89 if let Some(pattern) = member.as_str() {
90 if pattern.contains('*') {
92 let full_pattern = self.root.join(pattern);
93 let pattern_str = full_pattern.to_str().ok_or_else(|| {
94 Error::manifest(
95 format!(
96 "Workspace member glob pattern contains invalid UTF-8: {}",
97 full_pattern.display()
98 ),
99 Some(full_pattern.clone()),
100 )
101 })?;
102 let matches = glob::glob(pattern_str).map_err(|e| {
103 Error::manifest(
104 format!("Invalid glob pattern: {e}"),
105 Some(full_pattern.clone()),
106 )
107 })?;
108 for entry in matches.flatten() {
109 if entry.is_dir() {
110 paths.push(entry);
111 }
112 }
113 } else {
114 paths.push(self.root.join(pattern));
115 }
116 }
117 }
118
119 Ok(paths)
120 }
121
122 pub fn get_package_paths(&self) -> Result<HashMap<String, PathBuf>> {
130 let members = self.discover_members()?;
131 let mut paths_map = HashMap::new();
132
133 for member_path in members {
134 let manifest_path = member_path.join("Cargo.toml");
135 if !manifest_path.exists() {
136 continue;
137 }
138
139 let content = fs::read_to_string(&manifest_path).map_err(|e| {
140 Error::manifest(
141 format!("Failed to read {}: {e}", manifest_path.display()),
142 Some(manifest_path.clone()),
143 )
144 })?;
145
146 let doc: toml::Value = toml::from_str(&content).map_err(|e| {
147 Error::manifest(
148 format!("Failed to parse {}: {e}", manifest_path.display()),
149 Some(manifest_path.clone()),
150 )
151 })?;
152
153 if let Some(name) = doc
154 .get("package")
155 .and_then(|p| p.get("name"))
156 .and_then(|n| n.as_str())
157 {
158 paths_map.insert(name.to_string(), member_path);
159 }
160 }
161
162 Ok(paths_map)
163 }
164
165 pub fn get_package_names(&self) -> Result<Vec<String>> {
171 let members = self.discover_members()?;
172 let mut names = Vec::new();
173
174 for member_path in members {
175 let manifest_path = member_path.join("Cargo.toml");
176 if !manifest_path.exists() {
177 continue;
178 }
179
180 let content = fs::read_to_string(&manifest_path).map_err(|e| {
181 Error::manifest(
182 format!("Failed to read {}: {e}", manifest_path.display()),
183 Some(manifest_path.clone()),
184 )
185 })?;
186
187 let doc: toml::Value = toml::from_str(&content).map_err(|e| {
188 Error::manifest(
189 format!("Failed to parse {}: {e}", manifest_path.display()),
190 Some(manifest_path.clone()),
191 )
192 })?;
193
194 if let Some(name) = doc
195 .get("package")
196 .and_then(|p| p.get("name"))
197 .and_then(|n| n.as_str())
198 {
199 names.push(name.to_string());
200 }
201 }
202
203 Ok(names)
204 }
205
206 pub fn read_package_versions(&self) -> Result<HashMap<String, Version>> {
214 let workspace_version = self.read_workspace_version()?;
215 let members = self.discover_members()?;
216 let mut versions = HashMap::new();
217
218 for member_path in members {
219 let manifest_path = member_path.join("Cargo.toml");
220 if !manifest_path.exists() {
221 continue;
222 }
223
224 let content = fs::read_to_string(&manifest_path).map_err(|e| {
225 Error::manifest(
226 format!("Failed to read {}: {e}", manifest_path.display()),
227 Some(manifest_path.clone()),
228 )
229 })?;
230
231 let doc: toml::Value = toml::from_str(&content).map_err(|e| {
232 Error::manifest(
233 format!("Failed to parse {}: {e}", manifest_path.display()),
234 Some(manifest_path.clone()),
235 )
236 })?;
237
238 let Some(package) = doc.get("package") else {
239 continue;
240 };
241
242 let Some(name) = package.get("name").and_then(|n| n.as_str()) else {
243 continue;
244 };
245
246 let version = if let Some(version_table) =
248 package.get("version").and_then(|v| v.as_table())
249 && version_table
250 .get("workspace")
251 .is_some_and(|w| w.as_bool() == Some(true))
252 {
253 workspace_version.clone()
254 } else if package.get("version").and_then(|v| v.as_table()).is_some() {
255 continue;
258 } else if let Some(version_str) = package.get("version").and_then(|v| v.as_str()) {
259 version_str.parse::<Version>()?
260 } else {
261 continue;
262 };
263
264 versions.insert(name.to_string(), version);
265 }
266
267 Ok(versions)
268 }
269
270 pub fn update_workspace_version(&self, new_version: &Version) -> Result<()> {
278 let path = self.root_manifest_path();
279 let content = fs::read_to_string(&path).map_err(|e| {
280 Error::manifest(
281 format!("Failed to read root Cargo.toml: {e}"),
282 Some(path.clone()),
283 )
284 })?;
285
286 let mut doc = content.parse::<DocumentMut>().map_err(|e| {
287 Error::manifest(
288 format!("Failed to parse root Cargo.toml: {e}"),
289 Some(path.clone()),
290 )
291 })?;
292
293 if let Some(workspace) = doc.get_mut("workspace")
295 && let Some(package) = workspace.get_mut("package")
296 && let Item::Table(pkg_table) = package
297 {
298 pkg_table["version"] = toml_edit::value(new_version.to_string());
299 } else {
300 return Err(Error::manifest(
301 "Root manifest is missing [workspace.package] table".to_string(),
302 Some(path),
303 ));
304 }
305
306 fs::write(&path, doc.to_string()).map_err(|e| {
307 Error::manifest(format!("Failed to write root Cargo.toml: {e}"), Some(path))
308 })?;
309
310 Ok(())
311 }
312
313 pub fn read_package_dependencies(&self) -> Result<HashMap<String, Vec<String>>> {
323 let internal_packages = self.get_internal_package_names()?;
326
327 let members = self.discover_members()?;
329 let mut dependencies_map = HashMap::new();
330
331 for member_path in members {
332 let manifest_path = member_path.join("Cargo.toml");
333 if !manifest_path.exists() {
334 continue;
335 }
336
337 let content = fs::read_to_string(&manifest_path).map_err(|e| {
338 Error::manifest(
339 format!("Failed to read {}: {e}", manifest_path.display()),
340 Some(manifest_path.clone()),
341 )
342 })?;
343
344 let doc: toml::Value = toml::from_str(&content).map_err(|e| {
345 Error::manifest(
346 format!("Failed to parse {}: {e}", manifest_path.display()),
347 Some(manifest_path.clone()),
348 )
349 })?;
350
351 let Some(package) = doc.get("package") else {
352 continue;
353 };
354
355 let Some(name) = package.get("name").and_then(|n| n.as_str()) else {
356 continue;
357 };
358
359 let mut deps = Vec::new();
361
362 if let Some(dependencies) = doc.get("dependencies").and_then(|d| d.as_table()) {
364 for (dep_name, dep_value) in dependencies {
365 if let Some(dep_table) = dep_value.as_table() {
367 let is_workspace =
368 dep_table.get("workspace") == Some(&toml::Value::Boolean(true));
369 let has_path = dep_table.contains_key("path");
370 let is_internal = internal_packages.contains(dep_name);
371
372 if has_path || (is_workspace && is_internal) {
374 deps.push(dep_name.clone());
375 }
376 }
377 }
378 }
379
380 dependencies_map.insert(name.to_string(), deps);
381 }
382
383 Ok(dependencies_map)
384 }
385
386 fn get_internal_package_names(&self) -> Result<std::collections::HashSet<String>> {
390 use std::collections::HashSet;
391
392 let doc = self.read_root_manifest()?;
393 let mut internal_packages = HashSet::new();
394
395 if let Some(workspace) = doc.get("workspace")
396 && let Some(deps) = workspace.get("dependencies")
397 && let Some(deps_table) = deps.as_table()
398 {
399 for (name, value) in deps_table {
400 if let Some(inline) = value.as_inline_table()
402 && inline.contains_key("path")
403 {
404 internal_packages.insert(name.to_string());
405 }
406 if let Some(table) = value.as_table()
408 && table.contains_key("path")
409 {
410 internal_packages.insert(name.to_string());
411 }
412 }
413 }
414
415 Ok(internal_packages)
416 }
417
418 pub fn update_workspace_dependency_versions(
426 &self,
427 packages: &HashMap<String, Version>,
428 ) -> Result<()> {
429 let path = self.root_manifest_path();
430 let content = fs::read_to_string(&path).map_err(|e| {
431 Error::manifest(
432 format!("Failed to read root Cargo.toml: {e}"),
433 Some(path.clone()),
434 )
435 })?;
436
437 let mut doc = content.parse::<DocumentMut>().map_err(|e| {
438 Error::manifest(
439 format!("Failed to parse root Cargo.toml: {e}"),
440 Some(path.clone()),
441 )
442 })?;
443
444 if let Some(workspace) = doc.get_mut("workspace")
446 && let Some(deps) = workspace.get_mut("dependencies")
447 && let Item::Table(deps_table) = deps
448 {
449 for (pkg_name, version) in packages {
450 if let Some(dep) = deps_table.get_mut(pkg_name) {
451 match dep {
453 Item::Value(Value::InlineTable(table)) => {
454 table.insert("version", new_version_value(version));
455 }
456 Item::Table(table) => {
457 table["version"] = toml_edit::value(version.to_string());
458 }
459 _ => {}
460 }
461 }
462 }
463 }
464 fs::write(&path, doc.to_string()).map_err(|e| {
467 Error::manifest(format!("Failed to write root Cargo.toml: {e}"), Some(path))
468 })?;
469
470 Ok(())
471 }
472}
473
474fn new_version_value(version: &Version) -> Value {
476 Value::from(version.to_string())
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use tempfile::TempDir;
483
484 fn create_test_workspace(temp: &TempDir) -> PathBuf {
485 let root = temp.path().to_path_buf();
486
487 let root_manifest = r#"[workspace]
489resolver = "2"
490members = ["crates/foo", "crates/bar"]
491
492[workspace.package]
493version = "1.2.3"
494edition = "2021"
495
496[workspace.dependencies]
497foo = { path = "crates/foo", version = "1.2.3" }
498bar = { path = "crates/bar", version = "1.2.3" }
499"#;
500 fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
501
502 fs::create_dir_all(root.join("crates/foo")).unwrap();
504 fs::create_dir_all(root.join("crates/bar")).unwrap();
505
506 let foo_manifest = r#"[package]
507name = "foo"
508version.workspace = true
509"#;
510 fs::write(root.join("crates/foo/Cargo.toml"), foo_manifest).unwrap();
511
512 let bar_manifest = r#"[package]
513name = "bar"
514version.workspace = true
515"#;
516 fs::write(root.join("crates/bar/Cargo.toml"), bar_manifest).unwrap();
517
518 root
519 }
520
521 #[test]
526 fn test_cargo_manifest_new() {
527 let temp = TempDir::new().unwrap();
528 let manifest = CargoManifest::new(temp.path());
529 let _ = manifest;
531 }
532
533 #[test]
538 fn test_read_workspace_version() {
539 let temp = TempDir::new().unwrap();
540 let root = create_test_workspace(&temp);
541
542 let manifest = CargoManifest::new(&root);
543 let version = manifest.read_workspace_version().unwrap();
544
545 assert_eq!(version, Version::new(1, 2, 3));
546 }
547
548 #[test]
549 fn test_read_workspace_version_no_file() {
550 let temp = TempDir::new().unwrap();
551 let manifest = CargoManifest::new(temp.path());
552
553 let result = manifest.read_workspace_version();
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn test_read_workspace_version_no_workspace_package() {
559 let temp = TempDir::new().unwrap();
560 let root = temp.path().to_path_buf();
561
562 let manifest_content = r#"[workspace]
564members = ["crates/foo"]
565"#;
566 fs::write(root.join("Cargo.toml"), manifest_content).unwrap();
567
568 let manifest = CargoManifest::new(&root);
569 let result = manifest.read_workspace_version();
570 assert!(result.is_err());
571 }
572
573 #[test]
578 fn test_get_package_names() {
579 let temp = TempDir::new().unwrap();
580 let root = create_test_workspace(&temp);
581
582 let manifest = CargoManifest::new(&root);
583 let mut names = manifest.get_package_names().unwrap();
584 names.sort();
585
586 assert_eq!(names, vec!["bar", "foo"]);
587 }
588
589 #[test]
590 fn test_get_package_names_no_members() {
591 let temp = TempDir::new().unwrap();
592 let root = temp.path().to_path_buf();
593
594 let manifest_content = r#"[workspace]
596resolver = "2"
597
598[workspace.package]
599version = "1.0.0"
600"#;
601 fs::write(root.join("Cargo.toml"), manifest_content).unwrap();
602
603 let manifest = CargoManifest::new(&root);
604 let result = manifest.get_package_names();
605 assert!(result.is_err());
606 }
607
608 #[test]
609 fn test_get_package_names_missing_member_cargo_toml() {
610 let temp = TempDir::new().unwrap();
611 let root = temp.path().to_path_buf();
612
613 let manifest_content = r#"[workspace]
615resolver = "2"
616members = ["crates/nonexistent"]
617
618[workspace.package]
619version = "1.0.0"
620"#;
621 fs::write(root.join("Cargo.toml"), manifest_content).unwrap();
622 fs::create_dir_all(root.join("crates/nonexistent")).unwrap();
623 let manifest = CargoManifest::new(&root);
626 let names = manifest.get_package_names().unwrap();
627 assert!(names.is_empty());
629 }
630
631 #[test]
636 fn test_get_package_paths() {
637 let temp = TempDir::new().unwrap();
638 let root = create_test_workspace(&temp);
639
640 let manifest = CargoManifest::new(&root);
641 let paths = manifest.get_package_paths().unwrap();
642
643 assert!(paths.contains_key("foo"));
644 assert!(paths.contains_key("bar"));
645 assert!(paths.get("foo").unwrap().ends_with("crates/foo"));
646 }
647
648 #[test]
653 fn test_read_package_versions() {
654 let temp = TempDir::new().unwrap();
655 let root = create_test_workspace(&temp);
656
657 let manifest = CargoManifest::new(&root);
658 let versions = manifest.read_package_versions().unwrap();
659
660 assert_eq!(versions.get("foo"), Some(&Version::new(1, 2, 3)));
661 assert_eq!(versions.get("bar"), Some(&Version::new(1, 2, 3)));
662 }
663
664 #[test]
665 fn test_read_package_versions_explicit_version() {
666 let temp = TempDir::new().unwrap();
667 let root = temp.path().to_path_buf();
668
669 let root_manifest = r#"[workspace]
671resolver = "2"
672members = ["crates/explicit"]
673
674[workspace.package]
675version = "1.0.0"
676"#;
677 fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
678
679 fs::create_dir_all(root.join("crates/explicit")).unwrap();
681 let member_manifest = r#"[package]
682name = "explicit"
683version = "2.0.0"
684"#;
685 fs::write(root.join("crates/explicit/Cargo.toml"), member_manifest).unwrap();
686
687 let manifest = CargoManifest::new(&root);
688 let versions = manifest.read_package_versions().unwrap();
689
690 assert_eq!(versions.get("explicit"), Some(&Version::new(2, 0, 0)));
691 }
692
693 #[test]
698 fn test_update_workspace_version() {
699 let temp = TempDir::new().unwrap();
700 let root = create_test_workspace(&temp);
701
702 let manifest = CargoManifest::new(&root);
703 manifest
704 .update_workspace_version(&Version::new(2, 0, 0))
705 .unwrap();
706
707 let new_version = manifest.read_workspace_version().unwrap();
709 assert_eq!(new_version, Version::new(2, 0, 0));
710 }
711
712 #[test]
713 fn test_update_workspace_version_no_workspace_package() {
714 let temp = TempDir::new().unwrap();
715 let root = temp.path().to_path_buf();
716
717 let manifest_content = r#"[workspace]
719members = ["crates/foo"]
720"#;
721 fs::write(root.join("Cargo.toml"), manifest_content).unwrap();
722
723 let manifest = CargoManifest::new(&root);
724 let result = manifest.update_workspace_version(&Version::new(2, 0, 0));
725 assert!(result.is_err());
726 }
727
728 #[test]
733 fn test_update_workspace_dependency_versions() {
734 let temp = TempDir::new().unwrap();
735 let root = create_test_workspace(&temp);
736
737 let manifest = CargoManifest::new(&root);
738 let packages = HashMap::from([
739 ("foo".to_string(), Version::new(2, 0, 0)),
740 ("bar".to_string(), Version::new(2, 0, 0)),
741 ]);
742 manifest
743 .update_workspace_dependency_versions(&packages)
744 .unwrap();
745
746 let content = fs::read_to_string(root.join("Cargo.toml")).unwrap();
748 assert!(content.contains("version = \"2.0.0\""));
749 }
750
751 #[test]
752 fn test_update_workspace_dependency_versions_no_deps() {
753 let temp = TempDir::new().unwrap();
754 let root = temp.path().to_path_buf();
755
756 let manifest_content = r#"[workspace]
758resolver = "2"
759members = []
760
761[workspace.package]
762version = "1.0.0"
763"#;
764 fs::write(root.join("Cargo.toml"), manifest_content).unwrap();
765
766 let manifest = CargoManifest::new(&root);
767 let packages = HashMap::from([("foo".to_string(), Version::new(2, 0, 0))]);
768
769 manifest
771 .update_workspace_dependency_versions(&packages)
772 .unwrap();
773 }
774
775 #[test]
776 fn test_update_workspace_dependency_versions_partial_update() {
777 let temp = TempDir::new().unwrap();
778 let root = create_test_workspace(&temp);
779
780 let manifest = CargoManifest::new(&root);
781 let packages = HashMap::from([("foo".to_string(), Version::new(3, 0, 0))]);
783 manifest
784 .update_workspace_dependency_versions(&packages)
785 .unwrap();
786
787 let content = fs::read_to_string(root.join("Cargo.toml")).unwrap();
789 assert!(content.contains("version = \"3.0.0\""));
790 assert!(content.contains("version = \"1.2.3\""));
792 }
793
794 #[test]
799 fn test_read_package_dependencies() {
800 let temp = TempDir::new().unwrap();
801 let root = temp.path().to_path_buf();
802
803 let root_manifest = r#"[workspace]
805resolver = "2"
806members = ["crates/app", "crates/lib"]
807
808[workspace.package]
809version = "1.0.0"
810
811[workspace.dependencies]
812lib = { path = "crates/lib", version = "1.0.0" }
813"#;
814 fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
815
816 fs::create_dir_all(root.join("crates/lib")).unwrap();
818 let lib_manifest = r#"[package]
819name = "lib"
820version.workspace = true
821"#;
822 fs::write(root.join("crates/lib/Cargo.toml"), lib_manifest).unwrap();
823
824 fs::create_dir_all(root.join("crates/app")).unwrap();
826 let app_manifest = r#"[package]
827name = "app"
828version.workspace = true
829
830[dependencies]
831lib = { workspace = true }
832"#;
833 fs::write(root.join("crates/app/Cargo.toml"), app_manifest).unwrap();
834
835 let manifest = CargoManifest::new(&root);
836 let deps = manifest.read_package_dependencies().unwrap();
837
838 assert!(deps.contains_key("app"));
839 assert!(deps.contains_key("lib"));
840 assert!(deps.get("app").unwrap().contains(&"lib".to_string()));
841 assert!(deps.get("lib").unwrap().is_empty());
842 }
843
844 #[test]
849 fn test_discover_members_glob_pattern() {
850 let temp = TempDir::new().unwrap();
851 let root = temp.path().to_path_buf();
852
853 let root_manifest = r#"[workspace]
855resolver = "2"
856members = ["crates/*"]
857
858[workspace.package]
859version = "1.0.0"
860"#;
861 fs::write(root.join("Cargo.toml"), root_manifest).unwrap();
862
863 fs::create_dir_all(root.join("crates/alpha")).unwrap();
865 fs::create_dir_all(root.join("crates/beta")).unwrap();
866
867 let alpha_manifest = r#"[package]
868name = "alpha"
869version.workspace = true
870"#;
871 fs::write(root.join("crates/alpha/Cargo.toml"), alpha_manifest).unwrap();
872
873 let beta_manifest = r#"[package]
874name = "beta"
875version.workspace = true
876"#;
877 fs::write(root.join("crates/beta/Cargo.toml"), beta_manifest).unwrap();
878
879 let manifest = CargoManifest::new(&root);
880 let mut names = manifest.get_package_names().unwrap();
881 names.sort();
882
883 assert_eq!(names, vec!["alpha", "beta"]);
884 }
885
886 #[test]
891 fn test_new_version_value() {
892 let version = Version::new(1, 2, 3);
893 let value = new_version_value(&version);
894 assert_eq!(value.as_str(), Some("1.2.3"));
895 }
896
897 #[test]
898 fn test_new_version_value_prerelease() {
899 let version = "1.0.0-alpha.1".parse::<Version>().unwrap();
900 let value = new_version_value(&version);
901 assert_eq!(value.as_str(), Some("1.0.0-alpha.1"));
902 }
903}