1use std::{
2 path::{Path, PathBuf},
3 process::Stdio,
4};
5
6use miette::{IntoDiagnostic, Result, miette};
7use serde::Deserialize;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10
11use super::hash_file;
12use crate::builders::{ArtifactPath, Builder};
13
14#[derive(Debug, Default, Clone, Deserialize)]
16pub struct CargoBuilderConfig {
17 #[serde(default)]
22 pub targets: Vec<String>,
23
24 pub manifest_path: Option<PathBuf>,
29
30 #[serde(default)]
42 pub bins: Vec<String>,
43}
44
45pub struct CargoBuilder;
47
48impl Builder for CargoBuilder {
49 type ConfigType = CargoBuilderConfig;
50
51 async fn build(&self, config: Self::ConfigType) -> Result<Vec<ArtifactPath>> {
52 let version = read_crate_version(config.manifest_path.as_deref()).await?;
53
54 if config.targets.is_empty() {
55 let host = get_host_target().await?;
56 run_cargo_build(&config, None, &host, &version).await
57 } else {
58 let mut all_artifacts = Vec::new();
59 for target in &config.targets {
60 let artifacts =
61 run_cargo_build(&config, Some(target.as_str()), target, &version).await?;
62 all_artifacts.extend(artifacts);
63 }
64 Ok(all_artifacts)
65 }
66 }
67}
68
69#[derive(Deserialize)]
74struct CargoMessage {
75 reason: String,
76 package_id: Option<String>,
80 target: Option<CargoMessageTarget>,
81 filenames: Option<Vec<String>>,
82}
83
84#[derive(Deserialize)]
85struct CargoMessageTarget {
86 name: String,
87}
88
89async fn run_cargo_build(
93 config: &CargoBuilderConfig,
94 target: Option<&str>,
95 triple: &str,
96 version: &str,
97) -> Result<Vec<ArtifactPath>> {
98 let mut cmd = Command::new("cargo");
99 cmd.args(["build", "--release", "--message-format=json"]);
100
101 if let Some(t) = target {
102 cmd.args(["--target", t]);
103 }
104
105 if let Some(manifest) = &config.manifest_path {
106 cmd.arg("--manifest-path").arg(manifest);
107 }
108
109 let mut child = cmd
110 .stdout(Stdio::piped())
111 .stderr(Stdio::inherit()) .spawn()
113 .into_diagnostic()?;
114
115 let stdout = child.stdout.take().expect("stdout was piped");
116 let mut lines = BufReader::new(stdout).lines();
117
118 let mut artifacts = Vec::new();
119
120 while let Some(line) = lines.next_line().await.into_diagnostic()? {
121 let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
122 continue;
123 };
124
125 if msg.reason != "compiler-artifact" {
126 continue;
127 }
128
129 if !msg
134 .package_id
135 .as_deref()
136 .is_some_and(|id| id.contains("path+file://"))
137 {
138 continue;
139 }
140
141 if !config.bins.is_empty() {
143 let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
144 if !config.bins.iter().any(|b| b == target_name) {
145 continue;
146 }
147 }
148
149 for filename in msg.filenames.unwrap_or_default() {
150 let path = PathBuf::from(&filename);
151
152 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
154 if matches!(ext, "rlib" | "rmeta" | "d") {
155 continue;
156 }
157
158 if !path.exists() {
159 continue;
160 }
161
162 let stem = path
165 .file_stem()
166 .map(|s| s.to_string_lossy().into_owned())
167 .unwrap_or_default();
168 let dot_ext = path
169 .extension()
170 .map(|e| format!(".{}", e.to_string_lossy()))
171 .unwrap_or_default();
172 let name = format!("{stem}-{version}-{triple}{dot_ext}");
173
174 let hash = Some(hash_file(&path).await?);
175
176 artifacts.push(ArtifactPath { path, name, hash });
177 }
178 }
179
180 let status = child.wait().await.into_diagnostic()?;
181
182 if !status.success() {
183 return Err(miette!(
184 "cargo build --release failed with exit status: {status}"
185 ));
186 }
187
188 Ok(artifacts)
189}
190
191async fn get_host_target() -> Result<String> {
194 let output = Command::new("rustc")
195 .args(["-vV"])
196 .output()
197 .await
198 .into_diagnostic()?;
199
200 if !output.status.success() {
201 return Err(miette!("rustc -vV failed"));
202 }
203
204 let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
205 stdout
206 .lines()
207 .find(|l| l.starts_with("host:"))
208 .and_then(|l| l.split_whitespace().nth(1))
209 .map(str::to_owned)
210 .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
211}
212
213async fn read_crate_version(manifest_path: Option<&Path>) -> Result<String> {
216 let path = manifest_path.unwrap_or(Path::new("Cargo.toml"));
217 let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
218
219 #[derive(Deserialize)]
220 struct Manifest {
221 package: Option<Package>,
222 }
223 #[derive(Deserialize)]
224 struct Package {
225 version: Option<String>,
226 }
227
228 let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
229 manifest
230 .package
231 .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
232 .version
233 .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
234}
235
236#[derive(Debug, Default, Clone, Deserialize)]
240pub struct CargoDocBuilderConfig {
241 pub manifest_path: Option<PathBuf>,
246
247 #[serde(default)]
249 pub no_deps: bool,
250}
251
252pub struct CargoDocBuilder;
255
256impl Builder for CargoDocBuilder {
257 type ConfigType = CargoDocBuilderConfig;
258
259 async fn build(&self, config: Self::ConfigType) -> Result<Vec<ArtifactPath>> {
260 let mut cmd = Command::new("cargo");
261 cmd.arg("doc");
262
263 if config.no_deps {
264 cmd.arg("--no-deps");
265 }
266
267 if let Some(manifest) = &config.manifest_path {
268 cmd.arg("--manifest-path").arg(manifest);
269 }
270
271 let status = cmd
272 .stderr(Stdio::inherit())
273 .status()
274 .await
275 .into_diagnostic()?;
276
277 if !status.success() {
278 return Err(miette!("cargo doc failed with exit status: {status}"));
279 }
280
281 let doc_dir = config
284 .manifest_path
285 .as_deref()
286 .and_then(|p| p.parent())
287 .unwrap_or_else(|| std::path::Path::new("."))
288 .join("target/doc");
289
290 if !doc_dir.exists() {
291 return Err(miette!("doc directory not found at {}", doc_dir.display()));
292 }
293
294 Ok(vec![ArtifactPath {
298 path: doc_dir,
299 name: "doc".to_owned(),
300 hash: None,
301 }])
302 }
303}