Skip to main content

use_rust_cargo/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Composable Cargo project and workspace primitives.
5
6use 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/// A UTF-8 `Cargo.toml` path.
18#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct ManifestPath(Utf8PathBuf);
20
21impl ManifestPath {
22    /// Returns the manifest path.
23    #[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/// A UTF-8 Cargo workspace root path.
36#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct WorkspaceRoot(Utf8PathBuf);
38
39impl WorkspaceRoot {
40    /// Returns the workspace root path.
41    #[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/// Known Cargo editions plus a fallback for unknown future values.
54#[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    /// Parses a Cargo edition string.
65    #[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    /// Returns the edition as a string slice.
77    #[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/// A lightweight dependency description from `Cargo.toml`.
96#[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/// A lightweight Cargo feature definition.
104#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
105pub struct CargoFeature {
106    pub name: String,
107    pub members: Vec<String>,
108}
109
110/// A lightweight workspace package description.
111#[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/// A parsed Cargo manifest.
120#[derive(Clone, Debug)]
121pub struct CargoManifest {
122    path: ManifestPath,
123    document: DocumentMut,
124}
125
126impl CargoManifest {
127    /// Reads and parses a `Cargo.toml` manifest.
128    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    /// Returns the parsed manifest path.
140    #[must_use]
141    pub fn path(&self) -> &ManifestPath {
142        &self.path
143    }
144
145    /// Returns `package.name` when present.
146    #[must_use]
147    pub fn package_name(&self) -> Option<&str> {
148        self.package_str("name")
149    }
150
151    /// Returns `package.version` when present.
152    #[must_use]
153    pub fn package_version(&self) -> Option<&str> {
154        self.package_str("version")
155    }
156
157    /// Returns `package.edition` when present.
158    #[must_use]
159    pub fn edition(&self) -> Option<CargoEdition> {
160        self.package_str("edition").map(CargoEdition::parse)
161    }
162
163    /// Returns `package.repository` when present.
164    #[must_use]
165    pub fn repository(&self) -> Option<&str> {
166        self.package_str("repository")
167    }
168
169    /// Returns `package.documentation` when present.
170    #[must_use]
171    pub fn documentation(&self) -> Option<&str> {
172        self.package_str("documentation")
173    }
174
175    /// Returns `package.homepage` when present.
176    #[must_use]
177    pub fn homepage(&self) -> Option<&str> {
178        self.package_str("homepage")
179    }
180
181    /// Returns `package.description` when present.
182    #[must_use]
183    pub fn description(&self) -> Option<&str> {
184        self.package_str("description")
185    }
186
187    /// Returns `package.license` when present.
188    #[must_use]
189    pub fn license(&self) -> Option<&str> {
190        self.package_str("license")
191    }
192
193    /// Returns `package.readme` when present.
194    #[must_use]
195    pub fn readme(&self) -> Option<&str> {
196        self.package_str("readme")
197    }
198
199    /// Returns `true` when the manifest contains a `[workspace]` table.
200    #[must_use]
201    pub fn is_workspace(&self) -> bool {
202        self.document.get("workspace").is_some()
203    }
204
205    /// Returns the workspace members declared in `[workspace].members`.
206    #[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    /// Returns dependency entries from `[dependencies]`.
224    #[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    /// Returns feature entries from `[features]`.
243    #[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    /// Returns `true` when the package should be considered publishable.
270    #[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/// A discovered Cargo workspace.
301#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
302pub struct CargoWorkspace {
303    root: WorkspaceRoot,
304    members: Vec<CargoPackage>,
305}
306
307impl CargoWorkspace {
308    /// Discovers a workspace from a starting path.
309    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    /// Returns the workspace root.
329    #[must_use]
330    pub fn root(&self) -> &WorkspaceRoot {
331        &self.root
332    }
333
334    /// Returns the discovered workspace members.
335    #[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/// Errors that can occur while working with `Cargo.toml` manifests.
357#[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/// Errors that can occur while discovering a Cargo workspace.
401#[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
449/// Finds the nearest `Cargo.toml` relative to a starting path.
450pub 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
469/// Finds the nearest Cargo workspace root relative to a starting path.
470pub 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
492/// Loads a `Cargo.toml` manifest from a file or directory path.
493pub fn load_manifest(path: impl AsRef<Path>) -> Result<CargoManifest, CargoManifestError> {
494    CargoManifest::read(path)
495}
496
497/// Returns `true` when a manifest contains a `[workspace]` table.
498#[must_use]
499pub fn is_workspace(manifest: &CargoManifest) -> bool {
500    manifest.is_workspace()
501}
502
503/// Returns workspace members from a starting path.
504pub fn workspace_members(
505    start: impl AsRef<Path>,
506) -> Result<Vec<CargoPackage>, CargoWorkspaceError> {
507    Ok(CargoWorkspace::discover(start)?.members)
508}
509
510/// Returns workspace package names from a starting path.
511pub 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
520/// Returns publishable workspace packages from a starting path.
521pub 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}