1use std::fmt;
17
18use camino::Utf8PathBuf;
19
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22
23use crate::compiler::CompilerVersion;
24use crate::toolchain::ToolSpec;
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct CompilerWrapperKind(String);
30
31impl CompilerWrapperKind {
32 pub fn from_spec(spec: &ToolSpec) -> Self {
34 let display = spec.display();
35 let basename = camino::Utf8Path::new(&display)
36 .file_name()
37 .unwrap_or(&display);
38 let kind = basename.strip_suffix(".exe").unwrap_or(basename);
39 Self(kind.to_owned())
40 }
41
42 pub fn as_key(&self) -> &str {
44 &self.0
45 }
46}
47
48impl fmt::Display for CompilerWrapperKind {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 f.write_str(self.as_key())
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case", tag = "kind")]
63pub enum CompilerWrapperRequest {
64 Disabled,
68 Use { wrapper: ToolSpec },
70}
71
72impl CompilerWrapperRequest {
73 pub fn parse(raw: &str) -> Result<Self, CompilerWrapperParseError> {
82 let trimmed = raw.trim();
83 if trimmed.is_empty() {
84 return Err(CompilerWrapperParseError::Empty);
85 }
86 match trimmed.to_ascii_lowercase().as_str() {
87 "none" | "off" | "disabled" => Ok(Self::Disabled),
88 _ => Ok(Self::Use {
89 wrapper: ToolSpec::parse(trimmed.to_owned()),
90 }),
91 }
92 }
93
94 pub fn as_key(&self) -> String {
96 match self {
97 CompilerWrapperRequest::Disabled => "none".to_owned(),
98 CompilerWrapperRequest::Use { wrapper } => wrapper.display(),
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Error)]
105pub enum CompilerWrapperParseError {
106 #[error("compiler-wrapper value must not be empty")]
107 Empty,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum CompilerWrapperSource {
116 Cli,
119 Env,
121 UserConfig,
123 WorkspaceConfig,
125 PackageConfig,
128 ExplicitConfig,
131 Manifest,
133}
134
135impl CompilerWrapperSource {
136 pub const fn as_key(self) -> &'static str {
139 match self {
140 CompilerWrapperSource::Cli => "cli",
141 CompilerWrapperSource::Env => "env",
142 CompilerWrapperSource::UserConfig => "user-config",
143 CompilerWrapperSource::WorkspaceConfig => "workspace-config",
144 CompilerWrapperSource::PackageConfig => "package-config",
145 CompilerWrapperSource::ExplicitConfig => "explicit-config",
146 CompilerWrapperSource::Manifest => "manifest",
147 }
148 }
149}
150
151impl fmt::Display for CompilerWrapperSource {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 f.write_str(self.as_key())
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct CompilerWrapperIdentity {
162 pub kind: CompilerWrapperKind,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub version: Option<CompilerVersion>,
167 pub raw_version_line: String,
171}
172
173impl CompilerWrapperIdentity {
174 pub fn unknown_version(kind: CompilerWrapperKind, raw_version_line: impl Into<String>) -> Self {
177 Self {
178 kind,
179 version: None,
180 raw_version_line: raw_version_line.into(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct ResolvedCompilerWrapper {
194 pub kind: CompilerWrapperKind,
195 pub path: Utf8PathBuf,
196 pub spec: String,
198 pub source: CompilerWrapperSource,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub identity: Option<CompilerWrapperIdentity>,
204}
205
206impl ResolvedCompilerWrapper {
207 pub fn as_json(&self) -> serde_json::Value {
211 let version = self
212 .identity
213 .as_ref()
214 .and_then(|id| id.version.as_ref())
215 .map_or(serde_json::Value::Null, |v| {
216 serde_json::Value::String(v.to_display_string())
217 });
218 let raw = self
219 .identity
220 .as_ref()
221 .map_or(serde_json::Value::Null, |id| {
222 serde_json::Value::String(id.raw_version_line.clone())
223 });
224 serde_json::json!({
225 "kind": self.kind.as_key(),
226 "spec": self.spec,
227 "source": self.source.as_key(),
228 "version": version,
229 "raw_version_line": raw,
230 })
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct CompilerWrapperSummary {
240 pub kind: CompilerWrapperKind,
242 pub spec: String,
244 pub source: CompilerWrapperSource,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub version: Option<String>,
252}
253
254impl CompilerWrapperSummary {
255 pub fn from_resolved(resolved: &ResolvedCompilerWrapper) -> Self {
257 Self {
258 kind: resolved.kind.clone(),
259 spec: resolved.spec.clone(),
260 source: resolved.source,
261 version: resolved
262 .identity
263 .as_ref()
264 .and_then(|id| id.version.as_ref())
265 .map(super::compiler::CompilerVersion::to_display_string),
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn wrapper_kind(name: &str) -> CompilerWrapperKind {
275 CompilerWrapperKind::from_spec(&ToolSpec::parse(name))
276 }
277
278 #[test]
279 fn parse_accepts_documented_values() {
280 assert_eq!(
281 CompilerWrapperRequest::parse("none").unwrap(),
282 CompilerWrapperRequest::Disabled
283 );
284 assert_eq!(
285 CompilerWrapperRequest::parse("None").unwrap(),
286 CompilerWrapperRequest::Disabled
287 );
288 assert_eq!(
289 CompilerWrapperRequest::parse("ccache").unwrap(),
290 CompilerWrapperRequest::Use {
291 wrapper: ToolSpec::Name("ccache".into()),
292 }
293 );
294 assert_eq!(
295 CompilerWrapperRequest::parse("sccache").unwrap(),
296 CompilerWrapperRequest::Use {
297 wrapper: ToolSpec::Name("sccache".into()),
298 }
299 );
300 }
301
302 #[test]
303 fn parse_accepts_any_executable_name() {
304 let request = CompilerWrapperRequest::parse("icecc");
305 assert!(
306 request.is_ok(),
307 "expected arbitrary executable name: {request:?}"
308 );
309 }
310
311 #[test]
312 fn parse_does_not_shell_split_executable_value() {
313 assert_eq!(
314 CompilerWrapperRequest::parse("wrapper with spaces").unwrap(),
315 CompilerWrapperRequest::Use {
316 wrapper: ToolSpec::Name("wrapper with spaces".into()),
317 }
318 );
319 }
320
321 #[test]
322 fn parse_accepts_executable_paths() {
323 let request = CompilerWrapperRequest::parse("/usr/local/bin/icecc");
324 assert!(request.is_ok(), "expected executable path: {request:?}");
325 }
326
327 #[test]
328 fn parse_rejects_empty() {
329 assert_eq!(
330 CompilerWrapperRequest::parse("").unwrap_err(),
331 CompilerWrapperParseError::Empty
332 );
333 assert_eq!(
334 CompilerWrapperRequest::parse(" ").unwrap_err(),
335 CompilerWrapperParseError::Empty
336 );
337 }
338
339 #[test]
340 fn as_key_round_trips_through_parse() {
341 for value in ["none", "ccache", "sccache"] {
342 let parsed = CompilerWrapperRequest::parse(value).unwrap();
343 assert_eq!(parsed.as_key(), value);
344 }
345 }
346
347 #[test]
348 fn kind_is_derived_from_executable_basename() {
349 assert_eq!(wrapper_kind("/opt/bin/icecc").as_key(), "icecc");
350 assert_eq!(wrapper_kind("sccache.exe").as_key(), "sccache");
351 }
352
353 #[test]
354 fn source_keys_are_stable() {
355 for (source, key) in [
356 (CompilerWrapperSource::Cli, "cli"),
357 (CompilerWrapperSource::Env, "env"),
358 (CompilerWrapperSource::Manifest, "manifest"),
359 ] {
360 assert_eq!(source.as_key(), key);
361 }
362 }
363
364 #[test]
365 fn resolved_as_json_includes_kind_spec_source_and_optional_version() {
366 let resolved = ResolvedCompilerWrapper {
367 kind: wrapper_kind("ccache"),
368 path: Utf8PathBuf::from("/usr/local/bin/ccache"),
369 spec: "ccache".into(),
370 source: CompilerWrapperSource::Cli,
371 identity: Some(CompilerWrapperIdentity {
372 kind: wrapper_kind("ccache"),
373 version: CompilerVersion::parse("4.10.2"),
374 raw_version_line: "ccache version 4.10.2".into(),
375 }),
376 };
377 let json = resolved.as_json();
378 assert_eq!(json["kind"], "ccache");
379 assert_eq!(json["spec"], "ccache");
380 assert_eq!(json["source"], "cli");
381 assert_eq!(json["version"], "4.10.2");
382 assert!(json["raw_version_line"].is_string());
383 }
384
385 #[test]
386 fn resolved_as_json_emits_null_version_when_missing() {
387 let resolved = ResolvedCompilerWrapper {
388 kind: wrapper_kind("sccache"),
389 path: Utf8PathBuf::from("/usr/local/bin/sccache"),
390 spec: "sccache".into(),
391 source: CompilerWrapperSource::Manifest,
392 identity: None,
393 };
394 let json = resolved.as_json();
395 assert_eq!(json["version"], serde_json::Value::Null);
396 assert_eq!(json["raw_version_line"], serde_json::Value::Null);
397 }
398
399 #[test]
400 fn summary_from_resolved_keeps_display_version() {
401 let resolved = ResolvedCompilerWrapper {
402 kind: wrapper_kind("ccache"),
403 path: Utf8PathBuf::from("/usr/local/bin/ccache"),
404 spec: "ccache".into(),
405 source: CompilerWrapperSource::Env,
406 identity: Some(CompilerWrapperIdentity {
407 kind: wrapper_kind("ccache"),
408 version: CompilerVersion::parse("4.10.2"),
409 raw_version_line: "ccache version 4.10.2".into(),
410 }),
411 };
412 let summary = CompilerWrapperSummary::from_resolved(&resolved);
413 assert_eq!(summary.kind.as_key(), "ccache");
414 assert_eq!(summary.source, CompilerWrapperSource::Env);
415 assert_eq!(summary.version.as_deref(), Some("4.10.2"));
416 }
417}