Skip to content

Commit 38a6aff

Browse files
authored
Merge pull request #25 from pangenome/cache-binaries
fix: cache FastGA/wfmash binaries in ~/.cache/sweepga/
2 parents bb746ab + dfa3d56 commit 38a6aff

File tree

4 files changed

+296
-87
lines changed

4 files changed

+296
-87
lines changed

build.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,188 @@
1+
/// Build script for sweepga — caches FastGA and wfmash binaries so that
2+
/// `cargo install sweepga` produces a self-contained installation.
3+
///
4+
/// At build time the dependency crates (fastga-rs, wfmash-rs) compile their
5+
/// helper binaries into target/{profile}/build/{dep}-*/out/. We copy those
6+
/// into ~/.cache/sweepga/{cache_key}/ and remove any stale version dirs.
7+
///
8+
/// At runtime binary_paths.rs checks the cache first, so the binaries are
9+
/// always available regardless of whether a cargo target/ tree still exists.
10+
use std::env;
11+
use std::path::{Path, PathBuf};
12+
use std::process::Command;
13+
114
fn main() {
215
println!("cargo:rerun-if-changed=build.rs");
16+
println!("cargo:rerun-if-changed=Cargo.lock");
17+
18+
// OUT_DIR = target/{profile}/build/sweepga-{hash}/out
19+
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
20+
21+
let build_dir = out_dir
22+
.ancestors()
23+
.find(|p| p.file_name().map(|n| n == "build").unwrap_or(false))
24+
.map(|p| p.to_path_buf());
25+
26+
let build_dir = match build_dir {
27+
Some(d) => d,
28+
None => {
29+
println!("cargo:warning=Could not locate build directory");
30+
return;
31+
}
32+
};
33+
34+
// ── cache key ──────────────────────────────────────────────────────
35+
let cache_key = cache_key();
36+
println!("cargo:rustc-env=SWEEPGA_CACHE_KEY={cache_key}");
37+
38+
// ── cache directory ────────────────────────────────────────────────
39+
let cache_dir = cache_base().join(&cache_key);
40+
41+
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
42+
println!("cargo:warning=Failed to create cache dir: {e}");
43+
return;
44+
}
45+
46+
// ── copy binaries ──────────────────────────────────────────────────
47+
let mut installed = 0usize;
48+
49+
// FastGA utilities
50+
let fastga_bins = [
51+
"FastGA", "FAtoGDB", "GIXmake", "GIXrm", "ALNtoPAF", "PAFtoALN", "ONEview",
52+
];
53+
if let Some(dir) = find_dep_out(&build_dir, "fastga-rs", "FastGA") {
54+
installed += copy_binaries(&dir, &cache_dir, &fastga_bins);
55+
} else {
56+
println!("cargo:warning=fastga-rs build output not found");
57+
}
58+
59+
// wfmash
60+
if let Some(dir) = find_dep_out(&build_dir, "wfmash-rs", "wfmash") {
61+
installed += copy_binaries(&dir, &cache_dir, &["wfmash"]);
62+
} else {
63+
println!("cargo:warning=wfmash-rs build output not found (may not be built yet)");
64+
}
65+
66+
if installed > 0 {
67+
println!(
68+
"cargo:warning=Cached {installed} binaries in {}",
69+
cache_dir.display()
70+
);
71+
cleanup_old_versions(&cache_dir, &cache_key);
72+
}
73+
}
74+
75+
// ── helpers ────────────────────────────────────────────────────────────
76+
77+
/// Build a cache key: `git describe --always --dirty` or CARGO_PKG_VERSION.
78+
fn cache_key() -> String {
79+
if let Ok(out) = Command::new("git")
80+
.args(["describe", "--always", "--dirty"])
81+
.output()
82+
{
83+
if out.status.success() {
84+
let desc = String::from_utf8_lossy(&out.stdout).trim().to_string();
85+
if !desc.is_empty() {
86+
return desc;
87+
}
88+
}
89+
}
90+
env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into())
91+
}
92+
93+
/// $XDG_CACHE_HOME/sweepga or ~/.cache/sweepga
94+
fn cache_base() -> PathBuf {
95+
env::var("XDG_CACHE_HOME")
96+
.map(PathBuf::from)
97+
.unwrap_or_else(|_| {
98+
PathBuf::from(env::var("HOME").expect("HOME not set")).join(".cache")
99+
})
100+
.join("sweepga")
101+
}
102+
103+
/// Scan `build_dir` for `{prefix}-*/out/` containing `marker_bin`.
104+
fn find_dep_out(build_dir: &Path, prefix: &str, marker_bin: &str) -> Option<PathBuf> {
105+
let pat = format!("{prefix}-");
106+
for entry in std::fs::read_dir(build_dir).ok()?.flatten() {
107+
let path = entry.path();
108+
if path.is_dir() {
109+
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
110+
if name.starts_with(&pat) {
111+
let out = path.join("out");
112+
if out.join(marker_bin).exists() {
113+
return Some(out);
114+
}
115+
}
116+
}
117+
}
118+
}
119+
None
120+
}
121+
122+
/// Copy listed binaries from `src` to `dst`. Returns number of successes.
123+
///
124+
/// Uses atomic rename to avoid ETXTBSY ("Text file busy") when the
125+
/// destination binary is currently being executed by another process.
126+
fn copy_binaries(src: &Path, dst: &Path, names: &[&str]) -> usize {
127+
let mut n = 0;
128+
for name in names {
129+
let s = src.join(name);
130+
if !s.exists() {
131+
continue;
132+
}
133+
let d = dst.join(name);
134+
let tmp = dst.join(format!(".{name}.tmp"));
135+
match std::fs::copy(&s, &tmp) {
136+
Ok(_) => {
137+
make_executable(&tmp);
138+
match std::fs::rename(&tmp, &d) {
139+
Ok(_) => n += 1,
140+
Err(e) => {
141+
println!("cargo:warning=Failed to rename {name}: {e}");
142+
let _ = std::fs::remove_file(&tmp);
143+
}
144+
}
145+
}
146+
Err(e) => println!("cargo:warning=Failed to copy {name}: {e}"),
147+
}
148+
}
149+
n
150+
}
151+
152+
#[cfg(unix)]
153+
fn make_executable(path: &Path) {
154+
use std::os::unix::fs::PermissionsExt;
155+
if let Ok(m) = std::fs::metadata(path) {
156+
let mut p = m.permissions();
157+
p.set_mode(0o755);
158+
let _ = std::fs::set_permissions(path, p);
159+
}
160+
}
161+
162+
#[cfg(not(unix))]
163+
fn make_executable(_path: &Path) {}
164+
165+
/// Delete sibling directories under the sweepga cache that aren't `current_key`.
166+
fn cleanup_old_versions(current_dir: &Path, current_key: &str) {
167+
let parent = match current_dir.parent() {
168+
Some(p) => p,
169+
None => return,
170+
};
171+
let entries = match std::fs::read_dir(parent) {
172+
Ok(e) => e,
173+
Err(_) => return,
174+
};
175+
for entry in entries.flatten() {
176+
if entry.path().is_dir() {
177+
if let Some(name) = entry.file_name().to_str() {
178+
if name != current_key {
179+
println!(
180+
"cargo:warning=Removing old cache: {}",
181+
entry.path().display()
182+
);
183+
let _ = std::fs::remove_dir_all(entry.path());
184+
}
185+
}
186+
}
187+
}
3188
}

src/binary_paths.rs

Lines changed: 103 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,129 @@
1-
//! Binary path resolution for embedded FastGA tools
1+
//! Binary path resolution for FastGA and wfmash tools.
22
//!
3-
//! Scans target/build/fastga-rs-*/out/ for binaries built by the fastga-rs
4-
//! dependency. Falls back to PATH if not found.
3+
//! Search order:
4+
//! 1. ~/.cache/sweepga/{cache_key}/ (installed by build.rs)
5+
//! 2. target/{profile}/build/{dep}-*/out/ (development fallback)
6+
//! 3. PATH (system fallback)
57
68
use anyhow::{anyhow, Result};
79
use std::env;
810
use std::path::PathBuf;
911

10-
/// Get the path to an embedded FastGA binary.
11-
///
12-
/// Search order:
13-
/// 1. target/{debug,release}/build/fastga-rs-*/out/{binary_name}
14-
/// 2. PATH (system fallback)
12+
/// Cache key baked in at compile time by build.rs.
13+
/// `None` only if build.rs didn't run (shouldn't happen in normal builds).
14+
const CACHE_KEY: Option<&str> = option_env!("SWEEPGA_CACHE_KEY");
15+
16+
/// Return the versioned cache directory if it exists on disk.
17+
pub fn cache_dir() -> Option<PathBuf> {
18+
let key = CACHE_KEY?;
19+
let base = if let Ok(xdg) = env::var("XDG_CACHE_HOME") {
20+
PathBuf::from(xdg)
21+
} else {
22+
PathBuf::from(env::var("HOME").ok()?).join(".cache")
23+
};
24+
let dir = base.join("sweepga").join(key);
25+
if dir.is_dir() {
26+
Some(dir)
27+
} else {
28+
None
29+
}
30+
}
31+
32+
/// Locate a binary by name.
1533
pub fn get_embedded_binary_path(binary_name: &str) -> Result<PathBuf> {
16-
// Get the executable's directory to find target/
17-
let exe_dir = env::current_exe()
18-
.ok()
19-
.and_then(|p| p.canonicalize().ok())
20-
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()));
21-
22-
if let Some(mut target_dir) = exe_dir {
23-
// Navigate up to find the target/ directory
24-
// Executable might be in target/release/ or target/release/deps/
25-
while target_dir.file_name().is_some_and(|n| n != "target") {
26-
if !target_dir.pop() {
27-
break;
28-
}
34+
// 1. Cache directory
35+
if let Some(dir) = cache_dir() {
36+
let p = dir.join(binary_name);
37+
if p.exists() {
38+
return Ok(p);
2939
}
40+
}
3041

31-
// Now target_dir should point to target/
32-
if target_dir.ends_with("target") {
33-
// Search in target/{debug,release}/build/fastga-rs-*/out/
34-
for profile in &["release", "debug"] {
35-
let build_dir = target_dir.join(profile).join("build");
36-
if build_dir.exists() {
37-
if let Ok(entries) = std::fs::read_dir(&build_dir) {
38-
for entry in entries.flatten() {
39-
let path = entry.path();
40-
if path.is_dir()
41-
&& path
42-
.file_name()
43-
.and_then(|n| n.to_str())
44-
.is_some_and(|n| n.starts_with("fastga-rs-"))
45-
{
46-
let binary_path = path.join("out").join(binary_name);
47-
if binary_path.exists() {
48-
return Ok(binary_path.canonicalize().unwrap_or(binary_path));
49-
}
50-
}
51-
}
52-
}
53-
}
54-
}
55-
}
42+
// 2. Cargo build tree (development)
43+
if let Some(path) = scan_build_tree(binary_name) {
44+
return Ok(path);
5645
}
5746

58-
// Last resort: check PATH (system binaries)
47+
// 3. System PATH
5948
if let Ok(output) = std::process::Command::new("which")
6049
.arg(binary_name)
6150
.output()
6251
{
6352
if output.status.success() {
64-
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
65-
let path = PathBuf::from(path);
66-
return Ok(path);
53+
let p = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
54+
if p.exists() {
55+
return Ok(p);
56+
}
6757
}
6858
}
6959

7060
Err(anyhow!(
71-
"FastGA binary '{binary_name}' not found in embedded build directories or PATH.\n\
72-
Try running 'cargo build' or 'cargo build --release' first."
61+
"Binary '{binary_name}' not found.\n\
62+
Searched: cache (~/.cache/sweepga/), build tree, PATH.\n\
63+
Try running 'cargo build --release' first."
7364
))
7465
}
7566

67+
/// Prepend the binary cache/build directory to PATH and set WFMASH_BIN_DIR.
68+
///
69+
/// Call once at startup so that:
70+
/// - FastGA's internal `system()` calls find GIXmake, FAtoGDB, etc.
71+
/// - wfmash-rs finds the wfmash binary via WFMASH_BIN_DIR.
72+
pub fn setup_binary_env() {
73+
let dir = cache_dir().or_else(|| {
74+
// Fall back: use whatever directory we find FastGA in
75+
get_embedded_binary_path("FastGA")
76+
.ok()
77+
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
78+
});
79+
80+
if let Some(dir) = dir {
81+
let dir_str = dir.display().to_string();
82+
83+
// Prepend to PATH for FastGA's system() calls
84+
let current_path = env::var("PATH").unwrap_or_default();
85+
env::set_var("PATH", format!("{dir_str}:{current_path}"));
86+
87+
// Tell wfmash-rs where to find its binary
88+
env::set_var("WFMASH_BIN_DIR", &dir_str);
89+
}
90+
}
91+
92+
/// Walk the cargo build tree to find a binary in any dependency's out/ directory.
93+
fn scan_build_tree(binary_name: &str) -> Option<PathBuf> {
94+
let exe_dir = env::current_exe()
95+
.ok()
96+
.and_then(|p| p.canonicalize().ok())
97+
.and_then(|e| e.parent().map(|p| p.to_path_buf()))?;
98+
99+
// Walk up to target/
100+
let mut target_dir = exe_dir;
101+
while target_dir.file_name().is_some_and(|n| n != "target") {
102+
if !target_dir.pop() {
103+
return None;
104+
}
105+
}
106+
if !target_dir.ends_with("target") {
107+
return None;
108+
}
109+
110+
for profile in &["release", "debug"] {
111+
let build_dir = target_dir.join(profile).join("build");
112+
if let Ok(entries) = std::fs::read_dir(&build_dir) {
113+
for entry in entries.flatten() {
114+
let path = entry.path();
115+
if path.is_dir() {
116+
let binary_path = path.join("out").join(binary_name);
117+
if binary_path.exists() {
118+
return Some(binary_path.canonicalize().unwrap_or(binary_path));
119+
}
120+
}
121+
}
122+
}
123+
}
124+
None
125+
}
126+
76127
#[cfg(test)]
77128
mod tests {
78129
use super::*;

0 commit comments

Comments
 (0)