use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use qk_config::env::Environment;
pub fn path(
environment: &mut Environment,
inherited: Option<&OsString>,
workspace_root: &Path,
project_root: &str,
) -> Result<OsString> {
let search: Vec<PathBuf> = inherited
.map(|path| std::env::split_paths(path).collect())
.unwrap_or_default();
let mut found = Vec::new();
let mut binaries = BTreeMap::new();
for name in environment.bins() {
let path = search
.iter()
.find_map(|directory| executable(&workspace_root.join(directory), name))
.with_context(|| {
format!("the command {name} of the task's environment is not on PATH")
})?;
let real = path
.canonicalize()
.with_context(|| format!("cannot resolve {}", path.display()))?;
let keyed = if environment.keyed_by_content(name) {
format!("content:{}", content_digest(&real)?)
} else {
real.to_string_lossy().into_owned()
};
binaries.insert(name.clone(), keyed);
found.push((name.clone(), path));
}
let mut entries = Vec::new();
if !found.is_empty() {
entries.extend(links(&found)?);
}
for directory in environment.dirs() {
let expanded = directory
.replace("{workspaceRoot}", &workspace_root.to_string_lossy())
.replace("{projectRoot}", project_root);
entries.push(workspace_root.join(expanded));
}
if let Some(provided) = environment.provided_path() {
entries.extend(std::env::split_paths(provided));
}
environment.resolved(binaries);
std::env::join_paths(entries).context("cannot build the task's PATH")
}
fn content_digest(path: &Path) -> Result<String> {
let metadata = path
.metadata()
.with_context(|| format!("cannot read {}", path.display()))?;
let modified = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |modified| modified.as_nanos());
#[cfg(unix)]
let inode = std::os::unix::fs::MetadataExt::ino(&metadata);
#[cfg(not(unix))]
let inode = 0u64;
let stamp = format!("{} {modified} {inode}", metadata.len());
let kept = cache_directory().ok().map(|directory| {
let name = blake3::hash(path.as_os_str().as_encoded_bytes()).to_hex();
directory.join("digests").join(&name[..32])
});
if let Some(digest) = kept
.as_ref()
.and_then(|file| std::fs::read_to_string(file).ok())
.and_then(|text| {
let (kept_stamp, digest) = text.trim_end().rsplit_once(' ')?;
(kept_stamp == stamp).then(|| digest.to_owned())
})
{
return Ok(digest);
}
let mut hasher = blake3::Hasher::new();
let mut file =
std::fs::File::open(path).with_context(|| format!("cannot read {}", path.display()))?;
std::io::copy(&mut file, &mut hasher)
.with_context(|| format!("cannot read {}", path.display()))?;
let digest = hasher.finalize().to_hex().to_string();
if let Some(file) = kept
&& path.metadata().is_ok_and(|now| {
now.len() == metadata.len() && now.modified().ok() == metadata.modified().ok()
})
&& let Some(directory) = file.parent()
&& std::fs::create_dir_all(directory).is_ok()
{
let partial = file.with_extension(format!("{}.partial", std::process::id()));
if std::fs::write(&partial, format!("{stamp} {digest}\n")).is_ok()
&& std::fs::rename(&partial, &file).is_err()
{
let _ = std::fs::remove_file(&partial);
}
}
Ok(digest)
}
fn executable(directory: &Path, name: &str) -> Option<PathBuf> {
let candidates: Vec<PathBuf> = if cfg!(windows) {
let extensions = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
std::iter::once(directory.join(name))
.chain(
extensions
.split(';')
.filter(|extension| !extension.is_empty())
.map(|extension| directory.join(format!("{name}{extension}"))),
)
.collect()
} else {
vec![directory.join(name)]
};
candidates.into_iter().find(|candidate| {
let Ok(metadata) = candidate.metadata() else {
return false;
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
metadata.is_file()
}
})
}
#[cfg(unix)]
fn links(found: &[(String, PathBuf)]) -> Result<Vec<PathBuf>> {
let mut hasher = blake3::Hasher::new();
for (name, path) in found {
hasher.update(name.as_bytes());
hasher.update(&[0]);
hasher.update(path.as_os_str().as_encoded_bytes());
hasher.update(&[0]);
}
let digest = hasher.finalize().to_hex();
let parent = cache_directory()?.join("bin");
let directory = parent.join(&digest[..32]);
if !directory.is_dir() {
std::fs::create_dir_all(&parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
let building = parent.join(format!(
".{}.{}.{}",
&digest[..32],
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos())
));
std::fs::create_dir(&building)
.with_context(|| format!("cannot create {}", building.display()))?;
for (name, path) in found {
std::os::unix::fs::symlink(path, building.join(name))
.with_context(|| format!("cannot link {name} to {}", path.display()))?;
}
if let Err(error) = std::fs::rename(&building, &directory) {
let _ = std::fs::remove_dir_all(&building);
if !directory.is_dir() {
return Err(error)
.with_context(|| format!("cannot create {}", directory.display()));
}
}
}
Ok(vec![directory])
}
#[cfg(not(unix))]
fn links(found: &[(String, PathBuf)]) -> Result<Vec<PathBuf>> {
let mut directories: Vec<PathBuf> = Vec::new();
for (_, path) in found {
let directory = path
.parent()
.context("a command has no directory")?
.to_owned();
if !directories.contains(&directory) {
directories.push(directory);
}
}
Ok(directories)
}
#[cfg(unix)]
pub(crate) fn cache_directory() -> Result<PathBuf> {
let home = || std::env::var_os("HOME").filter(|home| !home.is_empty());
let base = if cfg!(target_os = "macos") {
home().map(|home| PathBuf::from(home).join("Library/Caches"))
} else {
std::env::var_os("XDG_CACHE_HOME")
.filter(|cache| Path::new(cache).is_absolute())
.map(PathBuf::from)
.or_else(|| home().map(|home| PathBuf::from(home).join(".cache")))
};
match base {
Some(base) => Ok(base.join("qk")),
None => anyhow::bail!(
"HOME is not set, so qk has nowhere to link the commands of a task's environment"
),
}
}
#[cfg(not(unix))]
pub(crate) fn cache_directory() -> Result<PathBuf> {
std::env::var_os("LOCALAPPDATA")
.filter(|directory| !directory.is_empty())
.map(|directory| PathBuf::from(directory).join("qk"))
.context("LOCALAPPDATA is not set, so qk has nowhere to keep a task environment's output")
}