Skip to main content

cargo_context_core/collect/
meta.rs

1//! Cargo workspace metadata.
2//!
3//! Wraps the `cargo_metadata` crate into a slimmer shape that the pack
4//! renderer actually uses — dropping resolver fields, artifact targets, and
5//! transitive graph data that would just burn tokens.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum DepKind {
16    Normal,
17    Dev,
18    Build,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DependencyInfo {
23    pub name: String,
24    pub req: String,
25    pub kind: DepKind,
26    pub optional: bool,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct WorkspaceMember {
31    pub name: String,
32    pub version: String,
33    pub manifest_path: PathBuf,
34    pub dependencies: Vec<DependencyInfo>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct WorkspaceMap {
39    pub workspace_root: PathBuf,
40    pub target_directory: PathBuf,
41    pub root_package: Option<String>,
42    pub members: Vec<WorkspaceMember>,
43}
44
45impl WorkspaceMap {
46    /// Names of all workspace members.
47    pub fn member_names(&self) -> Vec<&str> {
48        self.members.iter().map(|m| m.name.as_str()).collect()
49    }
50
51    /// Top-level non-workspace dependencies across all members, deduplicated.
52    pub fn external_dep_names(&self) -> Vec<&str> {
53        let mut names: Vec<&str> = self
54            .members
55            .iter()
56            .flat_map(|m| m.dependencies.iter())
57            .filter(|d| d.kind == DepKind::Normal)
58            .map(|d| d.name.as_str())
59            .collect();
60        names.sort_unstable();
61        names.dedup();
62        // Drop workspace-internal deps (members by name).
63        let member_names: std::collections::HashSet<&str> =
64            self.member_names().into_iter().collect();
65        names.retain(|n| !member_names.contains(n));
66        names
67    }
68}
69
70/// Query the workspace metadata rooted at `root`.
71///
72/// Accepts either the workspace root or any package dir inside it.
73pub fn cargo_metadata(root: &Path) -> Result<WorkspaceMap> {
74    let meta = cargo_metadata::MetadataCommand::new()
75        .current_dir(root)
76        .no_deps()
77        .exec()
78        .map_err(|e| Error::Tool(format!("cargo metadata failed: {e}")))?;
79
80    let root_package = meta.root_package().map(|p| p.name.to_string());
81    let members: Vec<WorkspaceMember> = meta
82        .workspace_packages()
83        .into_iter()
84        .map(|p| WorkspaceMember {
85            name: p.name.to_string(),
86            version: p.version.to_string(),
87            manifest_path: p.manifest_path.clone().into_std_path_buf(),
88            dependencies: p
89                .dependencies
90                .iter()
91                .map(|d| DependencyInfo {
92                    name: d.name.clone(),
93                    req: d.req.to_string(),
94                    kind: map_dep_kind(&d.kind),
95                    optional: d.optional,
96                })
97                .collect(),
98        })
99        .collect();
100
101    Ok(WorkspaceMap {
102        workspace_root: meta.workspace_root.clone().into_std_path_buf(),
103        target_directory: meta.target_directory.clone().into_std_path_buf(),
104        root_package,
105        members,
106    })
107}
108
109fn map_dep_kind(k: &cargo_metadata::DependencyKind) -> DepKind {
110    use cargo_metadata::DependencyKind as K;
111    match k {
112        K::Normal => DepKind::Normal,
113        K::Development => DepKind::Dev,
114        K::Build => DepKind::Build,
115        _ => DepKind::Normal,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// Smoke test: we're always inside a Cargo workspace when tests run.
124    /// Members should at least include cargo-context-core itself.
125    #[test]
126    fn reads_own_workspace() {
127        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
128        let map = cargo_metadata(root).expect("read metadata");
129        assert!(
130            map.members.iter().any(|m| m.name == "cargo-context-core"),
131            "expected cargo-context-core in members, got: {:?}",
132            map.member_names()
133        );
134    }
135
136    #[test]
137    fn external_deps_exclude_workspace_members() {
138        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
139        let map = cargo_metadata(root).expect("read metadata");
140        let ext = map.external_dep_names();
141        for member in map.member_names() {
142            assert!(
143                !ext.contains(&member),
144                "workspace member {member} leaked into external deps"
145            );
146        }
147    }
148}