use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use stow_types::error::Context;
use toml_edit::{DocumentMut, Item, Table, Value};
use crate::cli_args::{
CheckArtifactArgs, FetchArtifactArgs, IndexRefreshArgs, PurgeCacheDirArgs, SetupArgs, StatsArgs,
};
use crate::config::{self, StowConfig};
use crate::fetch;
use crate::index;
use crate::resolve;
use crate::rustc_args::{detect_rustc_host_target, detect_rustc_version};
use crate::stats;
use crate::wrapper_shim;
use crate::write_stdout;
pub async fn setup_project(args: SetupArgs) -> stow_types::error::Result<()> {
if args.github_env {
return print_setup_env();
}
let current_dir = std::env::current_dir().wrap_err("resolve current directory")?;
let cargo_dir = current_dir.join(".cargo");
let wrappers = detect_wrapper_commands()?;
let config_path = write_cargo_config(
&cargo_dir,
&wrappers,
&real_c_compiler(),
&real_cxx_compiler(),
)
.await?;
tracing::info!(
path = %config_path.display(),
rustc_wrapper = %wrappers.rustc,
cc_compiler = %wrappers.cc_compiler,
cxx_compiler = %wrappers.cxx_compiler,
cc_launcher = %wrappers.cc_launcher,
"configured project for stow"
);
write_stdout(&format!(
"configured {}\nrustc-wrapper: {}\ncc: {}\ncxx: {}\ncc-launcher: {}\n",
config_path.display(),
wrappers.rustc,
wrappers.cc_compiler,
wrappers.cxx_compiler,
wrappers.cc_launcher,
))?;
Ok(())
}
async fn write_cargo_config(
cargo_dir: &Path,
wrappers: &WrapperCommands,
real_cc: &str,
real_cxx: &str,
) -> stow_types::error::Result<PathBuf> {
let config_path = cargo_dir.join("config.toml");
async_fs::create_dir_all(cargo_dir)
.await
.wrap_err("create .cargo directory")?;
let mut document = if async_fs::metadata(&config_path).await.is_ok() {
async_fs::read_to_string(&config_path)
.await
.wrap_err("read existing .cargo/config.toml")?
.parse::<DocumentMut>()
.wrap_err("parse existing .cargo/config.toml")?
} else {
DocumentMut::new()
};
configure_document(&mut document, wrappers, real_cc, real_cxx);
async_fs::write(&config_path, document.to_string())
.await
.wrap_err("write .cargo/config.toml")?;
Ok(config_path)
}
fn configure_document(
document: &mut DocumentMut,
wrappers: &WrapperCommands,
real_cc: &str,
real_cxx: &str,
) {
set_build_wrapper(document, &wrappers.rustc);
for (key, value) in compiler_env_entries(wrappers, real_cc, real_cxx) {
set_env_wrapper(document, key, value);
}
}
fn print_setup_env() -> stow_types::error::Result<()> {
let wrappers = detect_wrapper_commands()?;
let config = StowConfig::load()?;
write_stdout(&setup_env_output(
&wrappers,
&real_c_compiler(),
&real_cxx_compiler(),
&config,
))
}
fn compiler_env_entries<'a>(
wrappers: &'a WrapperCommands,
real_cc: &'a str,
real_cxx: &'a str,
) -> [(&'static str, &'a str); 6] {
[
("STOW_REAL_CC", real_cc),
("STOW_REAL_CXX", real_cxx),
("CC", &wrappers.cc_compiler),
("CXX", &wrappers.cxx_compiler),
("CMAKE_C_COMPILER_LAUNCHER", &wrappers.cc_launcher),
("CMAKE_CXX_COMPILER_LAUNCHER", &wrappers.cc_launcher),
]
}
fn setup_env_output(
wrappers: &WrapperCommands,
real_cc: &str,
real_cxx: &str,
config: &StowConfig,
) -> String {
let rustc_wrapper = [("RUSTC_WRAPPER", wrappers.rustc.as_str())];
let edge = [
("STOW_EDGE_URL", config.edge_url.as_str()),
("STOW_VERIFY_MODE", config.verify_mode.as_str()),
];
rustc_wrapper
.into_iter()
.chain(compiler_env_entries(wrappers, real_cc, real_cxx))
.chain(edge)
.fold(String::new(), |mut output, (key, value)| {
let _ = writeln!(output, "{key}={value}");
output
})
}
pub async fn status_project() -> stow_types::error::Result<()> {
let current_dir = std::env::current_dir().wrap_err("resolve current directory")?;
let config_path = current_dir.join(".cargo").join("config.toml");
if async_fs::metadata(&config_path).await.is_err() {
write_stdout("not configured: .cargo/config.toml is missing\n")?;
return Ok(());
}
let document = async_fs::read_to_string(&config_path)
.await
.wrap_err("read .cargo/config.toml")?
.parse::<DocumentMut>()
.wrap_err("parse .cargo/config.toml")?;
let rustc_wrapper = document
.get("build")
.and_then(Item::as_table)
.and_then(|table| table.get("rustc-wrapper"))
.and_then(Item::as_str)
.unwrap_or("<missing>");
let cc_compiler = env_value(&document, "CC").unwrap_or("<missing>");
let cxx_compiler = env_value(&document, "CXX").unwrap_or("<missing>");
let cc_launcher = env_value(&document, "CMAKE_C_COMPILER_LAUNCHER").unwrap_or("<missing>");
let (edge_url, stats_summary) = match StowConfig::load() {
Ok(config) => {
let edge_url = config.edge_url.clone();
(
edge_url,
stats::read_summary(&config).await.unwrap_or_default(),
)
}
Err(_) => ("<missing>".to_owned(), stats::StatsSummary::default()),
};
write_stdout(&format!(
"config: {}\nrustc-wrapper: {}\ncc: {}\ncxx: {}\ncc-launcher: {}\nedge-url: {}\nrust-cache: hits={} misses={} errors={}\ncc-cache: hits={} misses={} errors={}\n",
config_path.display(),
rustc_wrapper,
cc_compiler,
cxx_compiler,
cc_launcher,
edge_url,
stats_summary.rust_hits,
stats_summary.rust_misses,
stats_summary.rust_errors,
stats_summary.cc_hits,
stats_summary.cc_misses,
stats_summary.cc_errors,
))?;
Ok(())
}
pub async fn stats_command(args: StatsArgs) -> stow_types::error::Result<()> {
let config = StowConfig::load()?;
let report = stats_report(&config).await?;
if args.json {
let body = serde_json::to_string(&report).wrap_err("serialize local stats")?;
write_stdout(&format!("{body}\n"))
} else {
write_stdout(&local_stats_table(&report))
}
}
#[derive(Debug, serde::Serialize)]
struct StatsReport {
hits: u64,
misses: u64,
errors: u64,
cpu_millis_saved: u64,
bytes_downloaded: u64,
}
async fn stats_report(config: &StowConfig) -> stow_types::error::Result<StatsReport> {
let local = stats::read_local_stats(config).await?;
let summary = stats::read_summary(config).await?;
Ok(StatsReport {
hits: local.hits,
misses: summary.rust_misses.saturating_add(summary.cc_misses),
errors: summary.rust_errors.saturating_add(summary.cc_errors),
cpu_millis_saved: local.cpu_millis_saved,
bytes_downloaded: local.bytes_downloaded,
})
}
fn grouped_count(value: u64) -> String {
let digits = value.to_string();
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
#[expect(
clippy::cast_precision_loss,
reason = "saved CPU time is far below 2^53 milliseconds"
)]
fn format_cpu_millis(millis: u64) -> String {
if millis >= 3_600_000 {
format!("{:.1} h", millis as f64 / 3_600_000.0)
} else if millis >= 60_000 {
format!("{:.1} min", millis as f64 / 60_000.0)
} else {
format!("{:.1} s", millis as f64 / 1_000.0)
}
}
#[expect(
clippy::cast_precision_loss,
reason = "downloaded bytes are far below 2^53"
)]
fn format_bytes(bytes: u64) -> String {
const GIB: u64 = 1024 * 1024 * 1024;
const MIB: u64 = 1024 * 1024;
const KIB: u64 = 1024;
if bytes >= GIB {
format!("{:.1} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.1} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.1} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
fn local_stats_table(report: &StatsReport) -> String {
format!(
"cache hits {}\ncache misses {}\ncache errors {}\nCPU time saved {}\nbytes downloaded {}\n",
grouped_count(report.hits),
grouped_count(report.misses),
grouped_count(report.errors),
format_cpu_millis(report.cpu_millis_saved),
format_bytes(report.bytes_downloaded),
)
}
pub async fn clean_project() -> stow_types::error::Result<()> {
let cache_dir = config::cache_dir()?;
if cache_dir.exists() {
async_fs::remove_dir_all(&cache_dir)
.await
.wrap_err_with(|| format!("remove cache dir {}", cache_dir.display()))?;
write_stdout(&format!("removed {}\n", cache_dir.display()))?;
} else {
write_stdout(&format!("cache dir missing: {}\n", cache_dir.display()))?;
}
Ok(())
}
pub async fn check_artifact(args: CheckArtifactArgs) -> stow_types::error::Result<()> {
let config = StowConfig::load()?;
let slice = index::ensure_slice(&config, &args.target, &args.rustc_version).await?;
match resolve::find_exact_artifact(&slice.index.rows, &args.c_metadata) {
Some(row) => {
write_stdout(&format!(
"status: present\ncrate: {} {}\nbundle-digest: {}\nindex-manifest: {}\n",
row.crate_name.as_str(),
row.version.as_semver(),
row.bundle_digest,
slice.manifest_digest,
))?;
}
None => {
write_stdout(&format!(
"status: absent\nc-metadata: {}\nindex-manifest: {}\n",
args.c_metadata, slice.manifest_digest,
))?;
}
}
Ok(())
}
pub async fn fetch_artifact(args: FetchArtifactArgs) -> stow_types::error::Result<()> {
let config = StowConfig::load()?;
let slice = index::ensure_slice(&config, &args.target, &args.rustc_version).await?;
let row =
resolve::find_exact_artifact(&slice.index.rows, &args.c_metadata).ok_or_else(|| {
stow_types::stow_error!(
"index slice for {} {} carries no artifact {}",
args.target,
args.rustc_version,
args.c_metadata
)
})?;
let bundle_ref = fetch::BundleRef::from_index_row(&args.target, &args.rustc_version, row);
let bytes = fetch::download_bundle_bytes(&config, &bundle_ref)
.await
.map_err(|error| stow_types::stow_error!("download artifact bundle: {error}"))?;
if let Some(parent) = args.output_path.parent()
&& !parent.as_os_str().is_empty()
{
async_fs::create_dir_all(parent)
.await
.wrap_err_with(|| format!("create parent directory {}", parent.display()))?;
}
async_fs::write(&args.output_path, &bytes)
.await
.wrap_err_with(|| format!("write artifact to {}", args.output_path.display()))?;
write_stdout(&format!(
"downloaded {}\nbytes: {}\n",
args.output_path.display(),
bytes.len()
))?;
Ok(())
}
pub async fn index_refresh(args: IndexRefreshArgs) -> stow_types::error::Result<()> {
let config = StowConfig::load()?;
let target = resolve_index_target(args.target).await?;
let rustc_version = resolve_index_rustc_version(args.rustc_version).await?;
let slice = index::refresh_slice(&config, &target, &rustc_version).await?;
write_stdout(&format!(
"index: {}\ntarget: {}\nrustc-version: {}\nrows: {}\nmanifest-digest: {}\n",
stow_types::index::index_tag(&target, &rustc_version),
target,
rustc_version,
slice.index.rows.len(),
slice.manifest_digest,
))?;
Ok(())
}
pub async fn index_status() -> stow_types::error::Result<()> {
let config = StowConfig::load()?;
let slices = index::cached_slices(&config).await?;
if slices.is_empty() {
write_stdout("no cached index slices\n")?;
return Ok(());
}
let mut output = String::new();
for slice in slices {
let _ = writeln!(
output,
"index: {}\ntarget: {}\nrustc-version: {}\nrows: {}\nfetched-at: {}\nmanifest-digest: {}\n",
stow_types::index::index_tag(&slice.target, &slice.rustc_version),
slice.target,
slice.rustc_version,
slice.row_count,
slice.fetched_at,
slice.manifest_digest,
);
}
write_stdout(&output)?;
Ok(())
}
async fn resolve_index_target(overridden: Option<String>) -> stow_types::error::Result<String> {
match overridden {
Some(target) => Ok(target),
None => detect_rustc_host_target(std::ffi::OsStr::new("rustc"))
.await
.map_err(|error| stow_types::stow_error!("detect rustc host target: {error}")),
}
}
async fn resolve_index_rustc_version(
overridden: Option<String>,
) -> stow_types::error::Result<String> {
match overridden {
Some(rustc_version) => Ok(rustc_version),
None => detect_rustc_version(std::ffi::OsStr::new("rustc"))
.await
.map_err(|error| stow_types::stow_error!("detect rustc version: {error}")),
}
}
pub async fn purge_cache_dirs(args: PurgeCacheDirArgs) -> stow_types::error::Result<()> {
smol::unblock(move || {
for path in args.paths {
if !path.exists() {
continue;
}
std::fs::remove_dir_all(&path)
.wrap_err_with(|| format!("purge stale cache directory {}", path.display()))?;
}
Ok(())
})
.await
}
fn set_build_wrapper(document: &mut DocumentMut, wrapper_command: &str) {
let build = ensure_table(document, "build");
build["rustc-wrapper"] = Item::Value(Value::from(wrapper_command));
}
fn set_env_wrapper(document: &mut DocumentMut, key: &str, value: &str) {
let env = ensure_table(document, "env");
let mut table = Table::new();
table["value"] = Item::Value(Value::from(value));
table["force"] = Item::Value(Value::from(true));
env[key] = Item::Table(table);
}
fn ensure_table<'a>(document: &'a mut DocumentMut, key: &str) -> &'a mut Table {
if !document.get(key).is_some_and(Item::is_table) {
document.insert(key, Item::Table(Table::new()));
}
document
.get_mut(key)
.expect("table inserted above must exist")
.as_table_mut()
.expect("table inserted above must exist")
}
fn env_value<'a>(document: &'a DocumentMut, key: &str) -> Option<&'a str> {
document
.get("env")
.and_then(Item::as_table)
.and_then(|table| table.get(key))
.and_then(Item::as_table_like)
.and_then(|entry| entry.get("value"))
.and_then(Item::as_str)
}
pub struct WrapperCommands {
pub(crate) rustc: String,
pub(crate) cc_launcher: String,
pub(crate) cc_compiler: String,
pub(crate) cxx_compiler: String,
}
pub fn detect_wrapper_commands() -> stow_types::error::Result<WrapperCommands> {
let current_exe = std::env::current_exe().wrap_err("resolve current executable")?;
let runtime_executable =
std::env::var_os("STOW_WRAPPER_PATH").map_or_else(|| current_exe.clone(), PathBuf::from);
let runtime_executable = if runtime_executable.exists() {
runtime_executable
} else {
current_exe
};
let capture_exe = sibling_binary(&runtime_executable, "stow-build");
let capture_exe = if capture_exe.exists() {
capture_exe
} else {
runtime_executable.clone()
};
let file_name = runtime_executable
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or_default();
let runtime_executable = if matches!(file_name, "stow-cli" | "stow" | "cargo-stow") {
runtime_executable
} else {
let sibling = sibling_binary(&runtime_executable, "stow-cli");
if sibling.exists() {
sibling
} else {
runtime_executable
}
};
let capture_executable = {
let sibling_capture = sibling_binary(&runtime_executable, "stow-build");
if sibling_capture.exists() {
sibling_capture
} else {
capture_exe
}
};
let shims = wrapper_shim::materialize_wrapper_shims(
&config::tools_dir()?,
&runtime_executable,
&capture_executable,
)?;
Ok(WrapperCommands {
rustc: shim_path(&shims.rustc_wrapper, "rustc wrapper")?,
cc_launcher: shim_path(&shims.cc_launcher, "cc launcher")?,
cc_compiler: shim_path(&shims.cc_compiler, "cc compiler")?,
cxx_compiler: shim_path(&shims.cxx_compiler, "cxx compiler")?,
})
}
fn shim_path(path: &std::path::Path, what: &str) -> stow_types::error::Result<String> {
path.to_str()
.map(str::to_owned)
.ok_or_else(|| stow_types::stow_error!("{what} path {} is not UTF-8", path.display()))
}
fn sibling_binary(current_exe: &Path, name: &str) -> PathBuf {
current_exe
.parent()
.map_or_else(|| PathBuf::from(name), |parent| parent.join(name))
}
pub fn real_c_compiler() -> String {
std::env::var("STOW_REAL_CC")
.or_else(|_| std::env::var("CC"))
.unwrap_or_else(|_| "cc".to_owned())
}
pub fn real_cxx_compiler() -> String {
std::env::var("STOW_REAL_CXX")
.or_else(|_| std::env::var("CXX"))
.unwrap_or_else(|_| "c++".to_owned())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::Duration;
use super::{WrapperCommands, setup_env_output};
use crate::config::{StowConfig, VerifyMode};
fn test_config(verify_mode: VerifyMode) -> StowConfig {
StowConfig {
edge_url: "https://stow.waterui.dev".to_owned(),
registry_base_url: stow_types::registry::GHCR_V2_BASE_URL.to_owned(),
cache_dir: PathBuf::from("/tmp/stow-cache"),
request_timeout: Duration::from_secs(300),
negative_cache_ttl: Duration::from_secs(300),
circuit_reset_after: Duration::from_secs(60),
circuit_trip_threshold: 5,
artifact_cache_max_bytes: 1024,
index_refresh_interval: Duration::from_secs(300),
verify_mode,
admission_drain_timeout: crate::config::DEFAULT_ADMISSION_DRAIN_TIMEOUT,
state_db_pool: StowConfig::default_state_db_pool(),
trust_material: std::sync::Arc::default(),
}
}
const TEST_TOOLS_DIR: &str = "/home/user/.local/share/stow/tools";
fn test_wrappers() -> WrapperCommands {
WrapperCommands {
rustc: format!("{TEST_TOOLS_DIR}/stow-rustc-wrapper"),
cc_launcher: format!("{TEST_TOOLS_DIR}/stow-cc-launcher"),
cc_compiler: format!("{TEST_TOOLS_DIR}/stow-cc"),
cxx_compiler: format!("{TEST_TOOLS_DIR}/stow-cxx"),
}
}
#[test]
fn github_env_output_emits_every_wrapper_key() {
let output = setup_env_output(
&test_wrappers(),
"clang",
"clang++",
&test_config(VerifyMode::GithubCi),
);
assert_eq!(
output,
"RUSTC_WRAPPER=/home/user/.local/share/stow/tools/stow-rustc-wrapper\n\
STOW_REAL_CC=clang\n\
STOW_REAL_CXX=clang++\n\
CC=/home/user/.local/share/stow/tools/stow-cc\n\
CXX=/home/user/.local/share/stow/tools/stow-cxx\n\
CMAKE_C_COMPILER_LAUNCHER=/home/user/.local/share/stow/tools/stow-cc-launcher\n\
CMAKE_CXX_COMPILER_LAUNCHER=/home/user/.local/share/stow/tools/stow-cc-launcher\n\
STOW_EDGE_URL=https://stow.waterui.dev\n\
STOW_VERIFY_MODE=github-ci\n"
);
}
#[tokio::test]
async fn setup_rewrites_stale_wrapper_entries_without_duplicating() {
let tempdir = tempfile::tempdir().expect("tempdir");
let cargo_dir = tempdir.path().join(".cargo");
std::fs::create_dir_all(&cargo_dir).expect("create .cargo");
std::fs::write(
cargo_dir.join("config.toml"),
"[build]\nrustc-wrapper = \"/tmp/stow-tools/stow-rustc-wrapper\"\n\
\n[env]\n\
CC = { value = \"/tmp/stow-tools/stow-cc\", force = true }\n",
)
.expect("seed stale cargo config");
let wrappers = test_wrappers();
let config_path = super::write_cargo_config(&cargo_dir, &wrappers, "clang", "clang++")
.await
.expect("first setup");
let once = std::fs::read_to_string(&config_path).expect("read written config");
super::write_cargo_config(&cargo_dir, &wrappers, "clang", "clang++")
.await
.expect("second setup");
let twice = std::fs::read_to_string(&config_path).expect("read rewritten config");
assert_eq!(once, twice, "re-running setup changed the file");
assert!(
!once.contains("/tmp/stow-tools"),
"stale /tmp/stow-tools entry survived:\n{once}"
);
assert!(
once.contains(&wrappers.rustc),
"config does not point at {}:\n{once}",
wrappers.rustc
);
assert_eq!(once.matches("rustc-wrapper =").count(), 1);
}
#[cfg(feature = "mock-verify")]
#[test]
fn github_env_output_serializes_verify_mode_as_wire_string() {
let output = setup_env_output(
&test_wrappers(),
"cc",
"c++",
&test_config(VerifyMode::MockKey {
public_key_path: std::path::PathBuf::from("/keys/mock.pub"),
public_key_sha256: "00".repeat(32),
}),
);
assert!(output.contains("STOW_VERIFY_MODE=mock-key\n"));
}
}