Skip to main content

wowlab_docgen_cli/infra/
cargo_metadata.rs

1use std::process::Command;
2
3use serde::Deserialize;
4use wowlab_fs::{
5    directory::{self, EntryKind},
6    path::{Path, PathBuf},
7};
8
9#[derive(Debug, Deserialize)]
10pub(crate) struct CargoMetadata {
11    pub(crate) packages: Vec<CargoPackage>,
12    pub(crate) workspace_members: Vec<Box<str>>,
13    pub(crate) resolve: Option<CargoResolve>,
14}
15
16#[derive(Debug, Deserialize)]
17pub(crate) struct CargoPackage {
18    pub(crate) id: String,
19    pub(crate) name: String,
20    #[serde(default)]
21    pub(crate) description: Option<String>,
22    #[serde(default)]
23    pub(crate) repository: Option<String>,
24    #[serde(default)]
25    pub(crate) targets: Vec<CargoTarget>,
26}
27
28#[derive(Debug, Deserialize)]
29pub(crate) struct CargoTarget {
30    pub(crate) name: String,
31    #[serde(default)]
32    pub(crate) kind: Box<[Box<str>]>,
33    #[serde(default)]
34    pub(crate) doc: bool,
35}
36
37#[derive(Debug, Deserialize)]
38pub(crate) struct CargoResolve {
39    pub(crate) nodes: Vec<CargoNode>,
40}
41
42#[derive(Debug, Deserialize)]
43pub(crate) struct CargoNode {
44    pub(crate) id: Box<str>,
45    #[serde(default)]
46    pub(crate) deps: Vec<CargoNodeDependency>,
47}
48
49#[derive(Debug, Deserialize)]
50pub(crate) struct CargoNodeDependency {
51    pub(crate) pkg: Box<str>,
52}
53
54impl CargoMetadata {
55    pub(crate) fn workspace_repository(&self) -> Option<&str> {
56        self.workspace_members.iter().find_map(|member| {
57            self.packages
58                .iter()
59                .find(|package| package.id == member.as_ref())
60                .and_then(|package| package.repository.as_deref())
61        })
62    }
63}
64
65#[derive(Debug, thiserror::Error)]
66#[error(transparent)]
67pub(crate) struct Error(#[from] ErrorKind);
68
69#[derive(Debug, thiserror::Error)]
70enum ErrorKind {
71    #[error("Cargo workspace manifest not found below {}", root.display())]
72    MissingManifest { root: PathBuf },
73    #[error("failed to execute cargo metadata: {0}")]
74    Execute(std::io::Error),
75    #[error("cargo metadata failed: {stderr}")]
76    Cargo { stderr: Box<str> },
77    #[error("failed to parse cargo metadata: {0}")]
78    Parse(serde_json::Error),
79}
80
81impl From<std::io::Error> for Error {
82    fn from(source: std::io::Error) -> Self {
83        ErrorKind::Execute(source).into()
84    }
85}
86
87impl From<serde_json::Error> for Error {
88    fn from(source: serde_json::Error) -> Self {
89        ErrorKind::Parse(source).into()
90    }
91}
92
93pub(crate) fn load(root: &Path) -> Result<CargoMetadata, Error> {
94    let manifest = workspace_manifest(root).ok_or_else(|| ErrorKind::MissingManifest {
95        root: root.to_path_buf(),
96    })?;
97    let output = Command::new("cargo")
98        .arg("metadata")
99        .arg("--format-version")
100        .arg("1")
101        .arg("--locked")
102        .arg("--manifest-path")
103        .arg(manifest)
104        .output()?;
105
106    if !output.status.success() {
107        return Err(ErrorKind::Cargo {
108            stderr: String::from_utf8_lossy(&output.stderr)
109                .trim()
110                .to_owned()
111                .into_boxed_str(),
112        }
113        .into());
114    }
115
116    Ok(serde_json::from_slice(&output.stdout)?)
117}
118
119fn workspace_manifest(root: &Path) -> Option<PathBuf> {
120    [root.join("Cargo.toml"), root.join("crates/Cargo.toml")]
121        .into_iter()
122        .find(|path| {
123            directory::inspect(path)
124                .is_ok_and(|entry| entry.is_some_and(|entry| entry.kind() == EntryKind::File))
125        })
126}
127
128#[cfg(test)]
129mod tests {
130    use googletest::prelude::*;
131
132    use super::*;
133
134    #[gtest]
135    fn metadata_json_preserves_catalog_and_dependency_fields() -> Result<()> {
136        let metadata: CargoMetadata = serde_json::from_str(
137            r#"{
138                "packages": [{
139                    "id": "dependency 1.0.0",
140                    "name": "dependency",
141                    "repository": "https://example.com/dependency",
142                    "targets": []
143                }, {
144                    "id": "engine 0.1.0",
145                    "name": "wowlab-engine",
146                    "description": "Simulation engine",
147                    "repository": "https://github.com/legacy3/wowlab",
148                    "targets": [{"name": "wowlab_engine", "kind": ["lib"], "doc": true}]
149                }],
150                "workspace_members": ["engine 0.1.0"],
151                "resolve": {"nodes": [{"id": "engine 0.1.0", "deps": []}]}
152            }"#,
153        )
154        .or_fail()?;
155
156        verify_eq!(metadata.packages[1].name, "wowlab-engine")?;
157        verify_eq!(metadata.packages[1].targets[0].name, "wowlab_engine")?;
158        verify_true!(metadata.packages[1].targets[0].doc)?;
159        verify_eq!(
160            metadata.workspace_repository(),
161            Some("https://github.com/legacy3/wowlab")
162        )?;
163
164        verify_that!(metadata.resolve.or_fail()?.nodes, len(eq(1)))
165    }
166}