use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::compiler::{ArtifactSet, classify_by_filename};
pub struct CompileResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub artifacts: ArtifactSet,
}
pub fn run_rustc(
rustc: &Path,
inner_rustc: Option<&Path>,
args: &[String],
output_path: Option<&Path>,
out_dir: Option<&Path>,
crate_name: Option<&str>,
extra_filename: Option<&str>,
skip_remap: bool,
path_normalizer: &crate::path_normalizer::PathNormalizer,
) -> Result<CompileResult> {
pre_clean_outputs(output_path, out_dir, crate_name, extra_filename);
crate::opcounts::record_compiler_run();
let mut cmd = Command::new(rustc);
if let Some(inner) = inner_rustc {
cmd.arg(inner);
}
if !skip_remap {
for arg in path_normalizer.remap_args() {
cmd.arg(arg);
}
}
let filtered_args = strip_incremental_flags(args);
if filtered_args.len() < args.len() {
tracing::info!(
"[kache] stripped incremental flags for {} ({} args removed)",
crate_name.unwrap_or("unknown"),
args.len() - filtered_args.len()
);
}
cmd.args(&filtered_args);
tracing::debug!("running: {} {}", rustc.display(), args.join(" "));
let output = cmd
.output()
.with_context(|| format!("executing {}", rustc.display()))?;
let exit_code = output.status.code().unwrap_or(1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if exit_code != 0
&& (stderr.contains("failed to move dependency graph")
|| stderr.contains("failed to create query cache")
|| stderr.contains("incremental"))
{
tracing::warn!(
"[kache] incremental compilation failure detected for {} — \
this is an APFS bug in git worktrees. \
Run `cargo clean` in the affected project to recover.",
crate_name.unwrap_or("unknown")
);
}
let artifacts = if exit_code == 0 {
let from_json = resolve_artifacts(&parse_rustc_artifacts(&stderr));
if from_json.is_empty() {
tracing::debug!(
"[kache] no rustc artifact notifications for {}; falling back to directory scan",
crate_name.unwrap_or("unknown")
);
ArtifactSet::from_output_files(
discover_output_files(output_path, out_dir, crate_name, extra_filename)?,
classify_by_filename,
)
} else {
tracing::debug!(
"[kache] discovered {} output file(s) for {} from rustc artifact notifications",
from_json.len(),
crate_name.unwrap_or("unknown")
);
ArtifactSet::from_output_files(from_json, classify_by_filename)
}
} else {
ArtifactSet::empty()
};
Ok(CompileResult {
exit_code,
stdout,
stderr,
artifacts,
})
}
pub fn strip_incremental_flags(args: &[String]) -> Vec<&String> {
let mut filtered: Vec<&String> = Vec::with_capacity(args.len());
let mut i = 0;
while i < args.len() {
if args[i].starts_with("-Cincremental=") {
i += 1;
continue;
}
if args[i] == "-C"
&& args
.get(i + 1)
.is_some_and(|next| next.starts_with("incremental="))
{
i += 2;
continue;
}
filtered.push(&args[i]);
i += 1;
}
filtered
}
fn parse_rustc_artifacts(stderr: &str) -> Vec<PathBuf> {
let mut artifacts = Vec::new();
for line in stderr.lines() {
let line = line.trim();
if !line.starts_with('{') {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if value.get("$message_type").and_then(|v| v.as_str()) != Some("artifact") {
continue;
}
if let Some(path) = value.get("artifact").and_then(|v| v.as_str()) {
artifacts.push(PathBuf::from(path));
}
}
artifacts
}
fn resolve_artifacts(artifacts: &[PathBuf]) -> Vec<(PathBuf, String)> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut files = Vec::new();
for artifact in artifacts {
let abs = if artifact.is_absolute() {
artifact.clone()
} else {
cwd.join(artifact)
};
let Some(name) = abs.file_name().map(|n| n.to_string_lossy().to_string()) else {
continue;
};
if !abs.exists() {
tracing::warn!(
"[kache] rustc reported artifact missing on disk, skipping: {}",
abs.display()
);
continue;
}
files.push((abs, name));
}
files
}
fn discover_output_files(
output_path: Option<&Path>,
out_dir: Option<&Path>,
crate_name: Option<&str>,
extra_filename: Option<&str>,
) -> Result<Vec<(PathBuf, String)>> {
let mut files = Vec::new();
if let Some(output) = output_path {
if output.exists() {
let filename = output
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
files.push((output.to_path_buf(), filename));
}
if let Some(parent) = output.parent()
&& let Some(stem) = output.file_stem()
{
let stem_str = stem.to_string_lossy();
let output_filename = output
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name == output_filename {
continue;
}
if name.starts_with(&*stem_str) {
files.push((path, name));
}
}
}
}
if let (Some(parent), Some(name), Some(extra)) =
(output.parent(), crate_name, extra_filename)
{
let d_file = parent.join(format!("{name}{extra}.d"));
if d_file.exists() && !files.iter().any(|(p, _)| p == &d_file) {
let filename = d_file
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
files.push((d_file, filename));
}
}
} else if let (Some(dir), Some(name)) = (out_dir, crate_name) {
let extra = extra_filename.unwrap_or("");
let prefixes = [format!("lib{name}{extra}"), format!("{name}{extra}")];
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let fname = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let matches = prefixes
.iter()
.any(|prefix| fname == *prefix || fname.starts_with(&format!("{prefix}.")));
if matches {
files.push((path, fname));
}
}
}
}
Ok(files)
}
pub(crate) fn pre_clean_outputs(
output_path: Option<&Path>,
out_dir: Option<&Path>,
crate_name: Option<&str>,
extra_filename: Option<&str>,
) {
if let Some(output) = output_path {
remove_if_readonly(output);
if let (Some(parent), Some(stem)) = (output.parent(), output.file_stem()) {
let stem_str = stem.to_string_lossy();
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let path = entry.path();
if path == *output {
continue;
}
if let Some(name) = path.file_name()
&& name.to_string_lossy().starts_with(&*stem_str)
{
remove_if_readonly(&path);
}
}
}
}
if let (Some(parent), Some(name), Some(extra)) =
(output.parent(), crate_name, extra_filename)
{
remove_if_readonly(&parent.join(format!("{name}{extra}.d")));
}
} else if let (Some(dir), Some(name)) = (out_dir, crate_name) {
let extra = extra_filename.unwrap_or("");
let prefixes = [format!("lib{name}{extra}"), format!("{name}{extra}")];
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(fname) = path.file_name() {
let fname = fname.to_string_lossy();
if prefixes
.iter()
.any(|prefix| *fname == *prefix || fname.starts_with(&format!("{prefix}.")))
{
remove_if_readonly(&path);
}
}
}
}
}
}
fn remove_if_readonly(path: &Path) {
if let Ok(meta) = std::fs::metadata(path)
&& meta.permissions().readonly()
{
#[cfg(windows)]
{
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = std::fs::set_permissions(path, perms);
}
let _ = std::fs::remove_file(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
#[test]
fn test_pre_clean_removes_readonly_output() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("libfoo-abc123.rlib");
fs::write(&output, b"cached content").unwrap();
fs::set_permissions(&output, fs::Permissions::from_mode(0o444)).unwrap();
assert!(fs::metadata(&output).unwrap().permissions().readonly());
pre_clean_outputs(Some(&output), None, None, None);
assert!(!output.exists(), "read-only file should have been removed");
}
#[test]
fn test_pre_clean_removes_readonly_siblings() {
let dir = tempfile::tempdir().unwrap();
let rlib = dir.path().join("libfoo-abc123.rlib");
let rmeta = dir.path().join("libfoo-abc123.rmeta");
let dep = dir.path().join("foo-abc123.d");
for path in [&rlib, &rmeta, &dep] {
fs::write(path, b"cached").unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o444)).unwrap();
}
pre_clean_outputs(Some(&rlib), None, Some("foo"), Some("-abc123"));
assert!(!rlib.exists());
assert!(!rmeta.exists());
assert!(!dep.exists());
}
#[test]
fn test_pre_clean_skips_writable_files() {
let dir = tempfile::tempdir().unwrap();
let output = dir.path().join("libfoo-abc123.rlib");
fs::write(&output, b"fresh content").unwrap();
assert!(!fs::metadata(&output).unwrap().permissions().readonly());
pre_clean_outputs(Some(&output), None, None, None);
assert!(output.exists(), "writable file should NOT be removed");
}
#[test]
fn test_pre_clean_out_dir_mode() {
let dir = tempfile::tempdir().unwrap();
let rlib = dir.path().join("libmycrate-def456.rlib");
let rmeta = dir.path().join("libmycrate-def456.rmeta");
let unrelated = dir.path().join("libother-xyz.rlib");
for path in [&rlib, &rmeta, &unrelated] {
fs::write(path, b"cached").unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o444)).unwrap();
}
pre_clean_outputs(None, Some(dir.path()), Some("mycrate"), Some("-def456"));
assert!(!rlib.exists());
assert!(!rmeta.exists());
assert!(
unrelated.exists(),
"unrelated crate files should not be removed"
);
}
#[cfg(unix)]
#[test]
fn test_pre_clean_removes_hardlink_without_mutating_store_blob() {
let dir = tempfile::tempdir().unwrap();
let blob = dir.path().join("blob.rlib");
let output = dir.path().join("libfoo-abc123.rlib");
fs::write(&blob, b"cached content").unwrap();
fs::set_permissions(&blob, fs::Permissions::from_mode(0o444)).unwrap();
fs::hard_link(&blob, &output).unwrap();
pre_clean_outputs(Some(&output), None, None, None);
assert!(
!output.exists(),
"restored hardlink should have been removed"
);
assert!(blob.exists(), "store blob should remain");
assert!(
fs::metadata(&blob).unwrap().permissions().readonly(),
"removing the output must not make the shared blob writable"
);
}
#[test]
fn test_strip_incremental_joined_form() {
let args: Vec<String> = vec![
"--crate-name".into(),
"foo".into(),
"-Cincremental=/tmp/incr".into(),
"-Copt-level=3".into(),
];
let filtered = strip_incremental_flags(&args);
assert_eq!(filtered.len(), 3);
assert!(!filtered.iter().any(|a| a.contains("incremental")));
}
#[test]
fn test_strip_incremental_two_arg_form() {
let args: Vec<String> = vec![
"--crate-name".into(),
"foo".into(),
"-C".into(),
"incremental=/tmp/incr".into(),
"-C".into(),
"opt-level=3".into(),
];
let filtered = strip_incremental_flags(&args);
assert_eq!(filtered.len(), 4); assert!(!filtered.iter().any(|a| a.contains("incremental")));
}
#[test]
fn test_strip_incremental_preserves_other_flags() {
let args: Vec<String> = vec![
"-C".into(),
"opt-level=3".into(),
"-C".into(),
"metadata=abc".into(),
];
let filtered = strip_incremental_flags(&args);
assert_eq!(filtered.len(), args.len());
}
#[test]
fn test_strip_incremental_empty_args() {
let args: Vec<String> = vec![];
let filtered = strip_incremental_flags(&args);
assert!(filtered.is_empty());
}
#[test]
fn test_strip_incremental_multiple() {
let args: Vec<String> = vec![
"-Cincremental=/a".into(),
"-C".into(),
"incremental=/b".into(),
"src/lib.rs".into(),
];
let filtered = strip_incremental_flags(&args);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0], "src/lib.rs");
}
#[test]
fn test_strip_incremental_c_without_incremental() {
let args: Vec<String> = vec!["-C".into(), "debuginfo=2".into()];
let filtered = strip_incremental_flags(&args);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_remove_if_readonly_nonexistent_file() {
remove_if_readonly(Path::new("/nonexistent/path"));
}
#[test]
fn test_discover_output_files_missing_dir() {
let result = discover_output_files(
Some(Path::new("/nonexistent/output.rlib")),
None,
None,
None,
)
.unwrap();
assert!(result.is_empty());
}
#[test]
fn test_discover_output_files_out_dir_mode() {
let dir = tempfile::tempdir().unwrap();
let rlib = dir.path().join("libfoo-abc.rlib");
let rmeta = dir.path().join("libfoo-abc.rmeta");
let dep = dir.path().join("foo-abc.d");
let unrelated = dir.path().join("libbar-xyz.rlib");
for path in [&rlib, &rmeta, &dep, &unrelated] {
fs::write(path, b"content").unwrap();
}
let files =
discover_output_files(None, Some(dir.path()), Some("foo"), Some("-abc")).unwrap();
let names: Vec<&str> = files.iter().map(|(_, n)| n.as_str()).collect();
assert!(names.contains(&"libfoo-abc.rlib"));
assert!(names.contains(&"libfoo-abc.rmeta"));
assert!(names.contains(&"foo-abc.d"));
assert!(!names.contains(&"libbar-xyz.rlib"));
}
#[test]
fn parse_rustc_artifacts_extracts_paths_and_skips_noise() {
let stream = concat!(
r#"{"$message_type":"diagnostic","message":"unused","level":"warning"}"#,
"\n",
r#"{"$message_type":"artifact","artifact":"target/debug/deps/libfoo-9a.rmeta","emit":"metadata"}"#,
"\n",
"warning: 1 warning emitted\n",
r#"{"$message_type":"artifact","artifact":"target/debug/deps/libfoo-9a.rlib","emit":"link"}"#,
"\n",
"not json at all\n",
);
assert_eq!(
parse_rustc_artifacts(stream),
vec![
PathBuf::from("target/debug/deps/libfoo-9a.rmeta"),
PathBuf::from("target/debug/deps/libfoo-9a.rlib"),
]
);
}
#[test]
fn parse_rustc_artifacts_empty_when_no_artifact_messages() {
let stream = "warning: unused variable: `x`\nerror: aborting due to 1 error\n";
assert!(parse_rustc_artifacts(stream).is_empty());
}
#[test]
fn parse_rustc_artifacts_empty_input() {
assert!(parse_rustc_artifacts("").is_empty());
}
#[test]
fn resolve_artifacts_keeps_existing_and_drops_missing() {
let dir = tempfile::tempdir().unwrap();
let present = dir.path().join("libfoo-9a.rlib");
fs::write(&present, b"rlib bytes").unwrap();
let missing = dir.path().join("ghost-9a.rmeta");
let resolved = resolve_artifacts(&[present.clone(), missing]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].0, present);
assert_eq!(resolved[0].1, "libfoo-9a.rlib");
}
}