wowlab_docgen_cli/infra/
cargo_metadata.rs1use 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}