1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::{
7 error::Error,
8 fmt, fs,
9 path::{Path, PathBuf},
10};
11
12use camino::{Utf8Path, Utf8PathBuf};
13use cargo_metadata::MetadataCommand;
14use serde::{Deserialize, Serialize};
15use toml_edit::{DocumentMut, Item};
16
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct ManifestPath(Utf8PathBuf);
20
21impl ManifestPath {
22 #[must_use]
24 pub fn as_path(&self) -> &Utf8Path {
25 &self.0
26 }
27}
28
29impl fmt::Display for ManifestPath {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 formatter.write_str(self.0.as_str())
32 }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct WorkspaceRoot(Utf8PathBuf);
38
39impl WorkspaceRoot {
40 #[must_use]
42 pub fn as_path(&self) -> &Utf8Path {
43 &self.0
44 }
45}
46
47impl fmt::Display for WorkspaceRoot {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter.write_str(self.0.as_str())
50 }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub enum CargoEdition {
56 E2015,
57 E2018,
58 E2021,
59 E2024,
60 Other(String),
61}
62
63impl CargoEdition {
64 #[must_use]
66 pub fn parse(value: &str) -> Self {
67 match value {
68 "2015" => Self::E2015,
69 "2018" => Self::E2018,
70 "2021" => Self::E2021,
71 "2024" => Self::E2024,
72 other => Self::Other(other.to_string()),
73 }
74 }
75
76 #[must_use]
78 pub fn as_str(&self) -> &str {
79 match self {
80 Self::E2015 => "2015",
81 Self::E2018 => "2018",
82 Self::E2021 => "2021",
83 Self::E2024 => "2024",
84 Self::Other(value) => value,
85 }
86 }
87}
88
89impl fmt::Display for CargoEdition {
90 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91 formatter.write_str(self.as_str())
92 }
93}
94
95#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
97pub struct CargoDependency {
98 pub name: String,
99 pub requirement: Option<String>,
100 pub optional: bool,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct CargoFeature {
106 pub name: String,
107 pub members: Vec<String>,
108}
109
110#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct CargoPackage {
113 pub name: String,
114 pub version: Option<String>,
115 pub manifest_path: ManifestPath,
116 pub publishable: bool,
117}
118
119#[derive(Clone, Debug)]
121pub struct CargoManifest {
122 path: ManifestPath,
123 document: DocumentMut,
124}
125
126impl CargoManifest {
127 pub fn read(path: impl AsRef<Path>) -> Result<Self, CargoManifestError> {
129 let manifest_path = resolve_manifest_path(path.as_ref())?;
130 let contents = fs::read_to_string(manifest_path.as_path().as_std_path())?;
131 let document = contents.parse::<DocumentMut>()?;
132
133 Ok(Self {
134 path: manifest_path,
135 document,
136 })
137 }
138
139 #[must_use]
141 pub fn path(&self) -> &ManifestPath {
142 &self.path
143 }
144
145 #[must_use]
147 pub fn package_name(&self) -> Option<&str> {
148 self.package_str("name")
149 }
150
151 #[must_use]
153 pub fn package_version(&self) -> Option<&str> {
154 self.package_str("version")
155 }
156
157 #[must_use]
159 pub fn edition(&self) -> Option<CargoEdition> {
160 self.package_str("edition").map(CargoEdition::parse)
161 }
162
163 #[must_use]
165 pub fn repository(&self) -> Option<&str> {
166 self.package_str("repository")
167 }
168
169 #[must_use]
171 pub fn documentation(&self) -> Option<&str> {
172 self.package_str("documentation")
173 }
174
175 #[must_use]
177 pub fn homepage(&self) -> Option<&str> {
178 self.package_str("homepage")
179 }
180
181 #[must_use]
183 pub fn description(&self) -> Option<&str> {
184 self.package_str("description")
185 }
186
187 #[must_use]
189 pub fn license(&self) -> Option<&str> {
190 self.package_str("license")
191 }
192
193 #[must_use]
195 pub fn readme(&self) -> Option<&str> {
196 self.package_str("readme")
197 }
198
199 #[must_use]
201 pub fn is_workspace(&self) -> bool {
202 self.document.get("workspace").is_some()
203 }
204
205 #[must_use]
207 pub fn workspace_members(&self) -> Vec<String> {
208 self.document
209 .get("workspace")
210 .and_then(Item::as_table_like)
211 .and_then(|workspace| workspace.get("members"))
212 .and_then(Item::as_value)
213 .and_then(|value| value.as_array())
214 .map(|members| {
215 members
216 .iter()
217 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
218 .collect()
219 })
220 .unwrap_or_default()
221 }
222
223 #[must_use]
225 pub fn dependencies(&self) -> Vec<CargoDependency> {
226 self.document
227 .get("dependencies")
228 .and_then(Item::as_table_like)
229 .map(|dependencies| {
230 dependencies
231 .iter()
232 .map(|(name, item)| CargoDependency {
233 name: name.to_string(),
234 requirement: dependency_requirement(item),
235 optional: dependency_optional(item),
236 })
237 .collect()
238 })
239 .unwrap_or_default()
240 }
241
242 #[must_use]
244 pub fn features(&self) -> Vec<CargoFeature> {
245 self.document
246 .get("features")
247 .and_then(Item::as_table_like)
248 .map(|features| {
249 features
250 .iter()
251 .map(|(name, item)| CargoFeature {
252 name: name.to_string(),
253 members: item
254 .as_value()
255 .and_then(|value| value.as_array())
256 .map(|values| {
257 values
258 .iter()
259 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
260 .collect()
261 })
262 .unwrap_or_default(),
263 })
264 .collect()
265 })
266 .unwrap_or_default()
267 }
268
269 #[must_use]
271 pub fn is_publishable(&self) -> bool {
272 match self.package_item("publish") {
273 None => true,
274 Some(item) => item
275 .as_value()
276 .and_then(|value| value.as_bool())
277 .or_else(|| {
278 item.as_value()
279 .and_then(|value| value.as_array())
280 .map(|items| !items.is_empty())
281 })
282 .unwrap_or(true),
283 }
284 }
285
286 fn package_item(&self, field: &str) -> Option<&Item> {
287 self.document
288 .get("package")
289 .and_then(Item::as_table_like)
290 .and_then(|package| package.get(field))
291 }
292
293 fn package_str(&self, field: &str) -> Option<&str> {
294 self.package_item(field)
295 .and_then(Item::as_value)
296 .and_then(|value| value.as_str())
297 }
298}
299
300#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302pub struct CargoWorkspace {
303 root: WorkspaceRoot,
304 members: Vec<CargoPackage>,
305}
306
307impl CargoWorkspace {
308 pub fn discover(start: impl AsRef<Path>) -> Result<Self, CargoWorkspaceError> {
310 let root = find_workspace_root(start)?;
311 let metadata = MetadataCommand::new()
312 .current_dir(root.as_path().as_std_path())
313 .no_deps()
314 .exec()?;
315
316 let mut members = metadata
317 .packages
318 .iter()
319 .filter(|package| metadata.workspace_members.contains(&package.id))
320 .map(CargoPackage::from_metadata_package)
321 .collect::<Result<Vec<_>, _>>()?;
322
323 members.sort_by(|left, right| left.name.cmp(&right.name));
324
325 Ok(Self { root, members })
326 }
327
328 #[must_use]
330 pub fn root(&self) -> &WorkspaceRoot {
331 &self.root
332 }
333
334 #[must_use]
336 pub fn members(&self) -> &[CargoPackage] {
337 &self.members
338 }
339}
340
341impl CargoPackage {
342 fn from_metadata_package(
343 package: &cargo_metadata::Package,
344 ) -> Result<Self, CargoWorkspaceError> {
345 let manifest = CargoManifest::read(package.manifest_path.as_std_path())?;
346
347 Ok(Self {
348 name: package.name.clone(),
349 version: Some(package.version.to_string()),
350 manifest_path: ManifestPath(package.manifest_path.clone()),
351 publishable: manifest.is_publishable(),
352 })
353 }
354}
355
356#[derive(Debug)]
358pub enum CargoManifestError {
359 Io(std::io::Error),
360 NotFound(PathBuf),
361 NonUtf8Path(PathBuf),
362 ParseToml(toml_edit::TomlError),
363}
364
365impl fmt::Display for CargoManifestError {
366 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
367 match self {
368 Self::Io(error) => write!(formatter, "failed to read Cargo manifest: {error}"),
369 Self::NotFound(path) => write!(formatter, "no Cargo.toml found at {}", path.display()),
370 Self::NonUtf8Path(path) => {
371 write!(formatter, "path is not valid UTF-8: {}", path.display())
372 },
373 Self::ParseToml(error) => write!(formatter, "failed to parse Cargo manifest: {error}"),
374 }
375 }
376}
377
378impl Error for CargoManifestError {
379 fn source(&self) -> Option<&(dyn Error + 'static)> {
380 match self {
381 Self::Io(error) => Some(error),
382 Self::ParseToml(error) => Some(error),
383 Self::NotFound(_) | Self::NonUtf8Path(_) => None,
384 }
385 }
386}
387
388impl From<std::io::Error> for CargoManifestError {
389 fn from(error: std::io::Error) -> Self {
390 Self::Io(error)
391 }
392}
393
394impl From<toml_edit::TomlError> for CargoManifestError {
395 fn from(error: toml_edit::TomlError) -> Self {
396 Self::ParseToml(error)
397 }
398}
399
400#[derive(Debug)]
402pub enum CargoWorkspaceError {
403 Manifest(CargoManifestError),
404 Metadata(cargo_metadata::Error),
405 NonUtf8Path(PathBuf),
406 NotFound(PathBuf),
407}
408
409impl fmt::Display for CargoWorkspaceError {
410 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 Self::Manifest(error) => write!(formatter, "failed to inspect manifest: {error}"),
413 Self::Metadata(error) => write!(formatter, "failed to query cargo metadata: {error}"),
414 Self::NonUtf8Path(path) => {
415 write!(formatter, "path is not valid UTF-8: {}", path.display())
416 },
417 Self::NotFound(path) => {
418 write!(formatter, "no workspace root found from {}", path.display())
419 },
420 }
421 }
422}
423
424impl Error for CargoWorkspaceError {
425 fn source(&self) -> Option<&(dyn Error + 'static)> {
426 match self {
427 Self::Manifest(error) => Some(error),
428 Self::Metadata(error) => Some(error),
429 Self::NonUtf8Path(_) | Self::NotFound(_) => None,
430 }
431 }
432}
433
434impl From<CargoManifestError> for CargoWorkspaceError {
435 fn from(error: CargoManifestError) -> Self {
436 match error {
437 CargoManifestError::NotFound(path) => Self::NotFound(path),
438 other => Self::Manifest(other),
439 }
440 }
441}
442
443impl From<cargo_metadata::Error> for CargoWorkspaceError {
444 fn from(error: cargo_metadata::Error) -> Self {
445 Self::Metadata(error)
446 }
447}
448
449pub fn find_manifest(start: impl AsRef<Path>) -> Result<ManifestPath, CargoManifestError> {
451 let original = start.as_ref().to_path_buf();
452 let mut current = normalize_search_start(start.as_ref());
453
454 loop {
455 let candidate = current.join("Cargo.toml");
456
457 if candidate.is_file() {
458 return to_manifest_path(candidate);
459 }
460
461 let Some(parent) = current.parent() else {
462 return Err(CargoManifestError::NotFound(original));
463 };
464
465 current = parent.to_path_buf();
466 }
467}
468
469pub fn find_workspace_root(start: impl AsRef<Path>) -> Result<WorkspaceRoot, CargoWorkspaceError> {
471 let original = start.as_ref().to_path_buf();
472 let mut current = normalize_search_start(start.as_ref());
473
474 loop {
475 let manifest_path = current.join("Cargo.toml");
476
477 if manifest_path.is_file() {
478 let manifest = CargoManifest::read(&manifest_path)?;
479 if manifest.is_workspace() {
480 return to_workspace_root(current);
481 }
482 }
483
484 let Some(parent) = current.parent() else {
485 return Err(CargoWorkspaceError::NotFound(original));
486 };
487
488 current = parent.to_path_buf();
489 }
490}
491
492pub fn load_manifest(path: impl AsRef<Path>) -> Result<CargoManifest, CargoManifestError> {
494 CargoManifest::read(path)
495}
496
497#[must_use]
499pub fn is_workspace(manifest: &CargoManifest) -> bool {
500 manifest.is_workspace()
501}
502
503pub fn workspace_members(
505 start: impl AsRef<Path>,
506) -> Result<Vec<CargoPackage>, CargoWorkspaceError> {
507 Ok(CargoWorkspace::discover(start)?.members)
508}
509
510pub fn package_names(start: impl AsRef<Path>) -> Result<Vec<String>, CargoWorkspaceError> {
512 let mut names = workspace_members(start)?
513 .into_iter()
514 .map(|package| package.name)
515 .collect::<Vec<_>>();
516 names.sort();
517 Ok(names)
518}
519
520pub fn publishable_packages(
522 start: impl AsRef<Path>,
523) -> Result<Vec<CargoPackage>, CargoWorkspaceError> {
524 Ok(workspace_members(start)?
525 .into_iter()
526 .filter(|package| package.publishable)
527 .collect())
528}
529
530fn resolve_manifest_path(path: &Path) -> Result<ManifestPath, CargoManifestError> {
531 let candidate = if path.is_dir() {
532 path.join("Cargo.toml")
533 } else {
534 path.to_path_buf()
535 };
536
537 if candidate.is_file() {
538 to_manifest_path(candidate)
539 } else {
540 Err(CargoManifestError::NotFound(candidate))
541 }
542}
543
544fn normalize_search_start(path: &Path) -> PathBuf {
545 if path.is_dir() {
546 return path.to_path_buf();
547 }
548
549 path.parent()
550 .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
551}
552
553fn to_manifest_path(path: PathBuf) -> Result<ManifestPath, CargoManifestError> {
554 let utf8 = Utf8PathBuf::from_path_buf(path.clone()).map_err(CargoManifestError::NonUtf8Path)?;
555 Ok(ManifestPath(utf8))
556}
557
558fn to_workspace_root(path: PathBuf) -> Result<WorkspaceRoot, CargoWorkspaceError> {
559 let utf8 =
560 Utf8PathBuf::from_path_buf(path.clone()).map_err(CargoWorkspaceError::NonUtf8Path)?;
561 Ok(WorkspaceRoot(utf8))
562}
563
564fn dependency_requirement(item: &Item) -> Option<String> {
565 item.as_value()
566 .and_then(|value| value.as_str())
567 .map(ToOwned::to_owned)
568 .or_else(|| {
569 item.as_table_like()
570 .and_then(|table| table.get("version"))
571 .and_then(Item::as_value)
572 .and_then(|value| value.as_str())
573 .map(ToOwned::to_owned)
574 })
575}
576
577fn dependency_optional(item: &Item) -> bool {
578 item.as_table_like()
579 .and_then(|table| table.get("optional"))
580 .and_then(Item::as_value)
581 .and_then(|value| value.as_bool())
582 .unwrap_or(false)
583}
584
585#[cfg(test)]
586mod tests {
587 use std::{
588 fs,
589 path::{Path, PathBuf},
590 process,
591 time::{SystemTime, UNIX_EPOCH},
592 };
593
594 use super::{
595 CargoEdition, CargoManifest, CargoWorkspace, find_manifest, find_workspace_root,
596 package_names, publishable_packages, workspace_members,
597 };
598
599 #[test]
600 fn reads_manifest_metadata_dependencies_and_features() {
601 let temp_dir = TestDir::new("manifest-read");
602 write_file(
603 &temp_dir.path().join("Cargo.toml"),
604 r#"[package]
605name = "use-demo"
606version = "0.1.0"
607edition = "2021"
608description = "demo crate"
609license = "MIT OR Apache-2.0"
610repository = "https://github.com/RustUse/use-demo"
611documentation = "https://docs.rs/use-demo"
612homepage = "https://rustuse.org"
613readme = "README.md"
614
615[dependencies]
616serde = { version = "1", optional = true }
617semver = "1"
618
619[features]
620default = ["serde"]
621"#,
622 );
623
624 let manifest = CargoManifest::read(temp_dir.path()).expect("manifest should parse");
625
626 assert_eq!(manifest.package_name(), Some("use-demo"));
627 assert_eq!(manifest.package_version(), Some("0.1.0"));
628 assert_eq!(manifest.edition(), Some(CargoEdition::E2021));
629 assert_eq!(
630 manifest.repository(),
631 Some("https://github.com/RustUse/use-demo")
632 );
633 assert_eq!(manifest.dependencies().len(), 2);
634 assert_eq!(manifest.features().len(), 1);
635 assert!(manifest.is_publishable());
636 }
637
638 #[test]
639 fn finds_manifest_and_workspace_roots() {
640 let temp_dir = TestDir::new("workspace-find");
641 write_file(
642 &temp_dir.path().join("Cargo.toml"),
643 r#"[workspace]
644members = ["crates/use-demo"]
645"#,
646 );
647 write_file(
648 &temp_dir
649 .path()
650 .join("crates")
651 .join("use-demo")
652 .join("Cargo.toml"),
653 r#"[package]
654name = "use-demo"
655version = "0.1.0"
656edition = "2021"
657repository = "https://github.com/RustUse/use-demo"
658"#,
659 );
660
661 let nested = temp_dir.path().join("crates").join("use-demo").join("src");
662 fs::create_dir_all(&nested).expect("nested directory should be created");
663
664 let manifest = find_manifest(&nested).expect("manifest should be found");
665 let root = find_workspace_root(&nested).expect("workspace root should be found");
666
667 assert!(manifest.as_path().ends_with("crates/use-demo/Cargo.toml"));
668 assert_eq!(root.as_path().as_std_path(), temp_dir.path());
669 }
670
671 #[test]
672 fn discovers_workspace_members_and_publishable_packages() {
673 let temp_dir = TestDir::new("workspace-members");
674 write_file(
675 &temp_dir.path().join("Cargo.toml"),
676 r#"[workspace]
677members = ["crates/use-one", "crates/use-two"]
678"#,
679 );
680 write_file(
681 &temp_dir
682 .path()
683 .join("crates")
684 .join("use-one")
685 .join("Cargo.toml"),
686 r#"[package]
687name = "use-one"
688version = "0.1.0"
689edition = "2021"
690repository = "https://github.com/RustUse/use-one"
691"#,
692 );
693 write_file(
694 &temp_dir
695 .path()
696 .join("crates")
697 .join("use-one")
698 .join("src")
699 .join("lib.rs"),
700 "pub fn sample() {}\n",
701 );
702 write_file(
703 &temp_dir
704 .path()
705 .join("crates")
706 .join("use-two")
707 .join("Cargo.toml"),
708 r#"[package]
709name = "use-two"
710version = "0.1.0"
711edition = "2021"
712repository = "https://github.com/RustUse/use-two"
713publish = false
714"#,
715 );
716 write_file(
717 &temp_dir
718 .path()
719 .join("crates")
720 .join("use-two")
721 .join("src")
722 .join("lib.rs"),
723 "pub fn sample() {}\n",
724 );
725
726 let workspace = CargoWorkspace::discover(temp_dir.path()).expect("workspace should load");
727 let names = package_names(temp_dir.path()).expect("package names should load");
728 let publishable =
729 publishable_packages(temp_dir.path()).expect("publishable packages should load");
730 let members = workspace_members(temp_dir.path()).expect("workspace members should load");
731
732 assert_eq!(workspace.members().len(), 2);
733 assert_eq!(members.len(), 2);
734 assert_eq!(
735 names,
736 vec![String::from("use-one"), String::from("use-two")]
737 );
738 assert_eq!(publishable.len(), 1);
739 assert_eq!(publishable[0].name, "use-one");
740 }
741
742 struct TestDir {
743 path: PathBuf,
744 }
745
746 impl TestDir {
747 fn new(label: &str) -> Self {
748 let mut path = std::env::temp_dir();
749 let nanos = SystemTime::now()
750 .duration_since(UNIX_EPOCH)
751 .expect("system clock should be after UNIX_EPOCH")
752 .as_nanos();
753 path.push(format!("use-rust-cargo-{label}-{}-{nanos}", process::id()));
754 fs::create_dir_all(&path).expect("temporary directory should be created");
755 Self { path }
756 }
757
758 fn path(&self) -> &Path {
759 &self.path
760 }
761 }
762
763 impl Drop for TestDir {
764 fn drop(&mut self) {
765 let _ = fs::remove_dir_all(&self.path);
766 }
767 }
768
769 fn write_file(path: &Path, contents: &str) {
770 if let Some(parent) = path.parent() {
771 fs::create_dir_all(parent).expect("parent directories should be created");
772 }
773
774 fs::write(path, contents).expect("file should be written");
775 }
776}