use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use super::manifest::{Section, package_meta, valid_name};
use super::{ChestError, hold_lock, invalid, io};
use crate::packages::resolve::{ResourceType, selected_files};
use crate::packages::source::{self, Source};
pub(super) struct Work {
dir: PathBuf,
next: usize,
}
impl Work {
pub(super) fn new(cache: &Path) -> Result<Work, ChestError> {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let id = NEXT.fetch_add(1, Ordering::Relaxed);
let work = cache.join("work");
for entry in std::fs::read_dir(&work).into_iter().flatten().flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let pid = name.split_once('-').and_then(|(pid, _)| pid.parse().ok());
if pid.is_some_and(|pid| !running(pid)) {
let _ = std::fs::remove_dir_all(entry.path());
}
}
let dir = work.join(format!("{}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(io(&dir))?;
Ok(Work { dir, next: 0 })
}
pub(super) fn path(&mut self) -> PathBuf {
self.next += 1;
self.dir.join(self.next.to_string())
}
}
fn running(pid: i32) -> bool {
#[cfg(unix)]
{
let Some(pid) = rustix::process::Pid::from_raw(pid) else {
return false;
};
!matches!(
rustix::process::test_kill_process(pid),
Err(rustix::io::Errno::SRCH)
)
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
impl Drop for Work {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
async fn git(args: &[&str], dir: Option<&Path>) -> Result<String, ChestError> {
crate::packages::run_git(args, dir)
.await
.map_err(|err| ChestError::Failed(err.to_string()))
}
async fn clean_git(args: &[&str], dir: Option<&Path>) -> Result<String, ChestError> {
let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
let env = [
("GIT_CONFIG_GLOBAL", null),
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_CONFIG_COUNT", "0"),
("GIT_CONFIG_PARAMETERS", ""),
];
let clean = ["-c", "core.autocrlf=false", "-c", "core.eol=lf"];
let args: Vec<&str> = clean.into_iter().chain(args.iter().copied()).collect();
crate::packages::run_git_with(&args, dir, &env)
.await
.map_err(|err| ChestError::Failed(err.to_string()))
}
fn text(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
const HEAD: &str = "refs/chest/head";
async fn mirror(cache: &Path, repo: &str, refresh: bool) -> Result<PathBuf, ChestError> {
let Source::Git {
repo: url,
host,
path,
..
} = source::parse(&format!("git:{repo}"))
else {
return Err(invalid(format!("Not a git repository: {repo}")));
};
let dir = cache.join("git").join(host).join(path);
let parent = dir.parent().unwrap_or(cache);
std::fs::create_dir_all(parent).map_err(io(parent))?;
let _held = hold_lock(&dir, &mut || {}).await?;
if !dir.join("HEAD").is_file() {
let _ = std::fs::remove_dir_all(&dir);
git(
&["clone", "--bare", "--quiet", "--", &url, &text(&dir)],
None,
)
.await?;
let _ = git(&["update-ref", HEAD, "HEAD"], Some(&dir)).await;
} else if refresh {
let heads = "+refs/heads/*:refs/heads/*";
let tags = "+refs/tags/*:refs/tags/*";
let head = format!("+HEAD:{HEAD}");
let fetch = [
"fetch", "--quiet", "--prune", "--force", "--", &url, heads, tags, &head,
];
git(&fetch, Some(&dir)).await?;
}
Ok(dir)
}
async fn commit_of(mirror: &Path, rev: &str) -> Option<String> {
let spec = format!("{rev}^{{commit}}");
let out = git(&["rev-parse", "--verify", "--quiet", &spec], Some(mirror)).await;
out.ok().map(|out| out.trim().to_owned())
}
pub(super) async fn resolve(
cache: &Path,
repo: &str,
rev: Option<&str>,
) -> Result<String, ChestError> {
let mirror = mirror(cache, repo, true).await?;
commit_of(&mirror, rev.unwrap_or(HEAD))
.await
.ok_or_else(|| {
let rev = rev.unwrap_or("HEAD");
invalid(format!("{repo} has no revision {rev}"))
})
}
pub(super) async fn checkout(
cache: &Path,
repo: &str,
commit: &str,
dest: &Path,
) -> Result<(), ChestError> {
if commit.len() != 40 || !commit.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(invalid(format!("Not a commit: {commit}")));
}
let mut cached = mirror(cache, repo, false).await?;
if commit_of(&cached, commit).await.is_none() {
cached = mirror(cache, repo, true).await?;
if commit_of(&cached, commit).await.is_none() {
return Err(invalid(format!("{repo} no longer has commit {commit}")));
}
}
let clone = [
"clone",
"--quiet",
"--no-checkout",
"--template=",
"--",
&text(&cached),
&text(dest),
];
clean_git(&clone, None).await?;
clean_git(&["checkout", "--quiet", "--detach", commit], Some(dest)).await?;
let dot_git = dest.join(".git");
std::fs::remove_dir_all(&dot_git).map_err(io(&dot_git))
}
pub(super) type Item = (String, PathBuf);
pub(super) fn placed_name(section: Section, name: &str) -> String {
if section == Section::Agents && !name.ends_with(".md") {
format!("{name}.md")
} else {
name.to_owned()
}
}
pub(super) fn items(
package: &Path,
section: Section,
subpath: Option<&str>,
name: &str,
) -> Result<(Vec<Item>, Vec<String>), ChestError> {
if let Some(subpath) = subpath {
let item = package.join(subpath);
inside(package, &item)?;
check_item(section, &item, Path::new(subpath))?;
return Ok((vec![(placed_name(section, name), item)], Vec::new()));
}
let key = section.key();
let meta = package_meta(package)?;
let listed = meta.resources.get(key);
let files = if meta.manifest && listed.is_none() {
Vec::new()
} else {
let kind = match section {
Section::Skills => ResourceType::Skills,
_ => ResourceType::Prompts,
};
selected_files(package, listed.map(Vec::as_slice), key, kind)
};
let mut single = Vec::new();
let mut items: Vec<(String, PathBuf)> = files
.into_iter()
.filter_map(|file| match section {
Section::Skills if file.file_name()? == "SKILL.md" => {
file.parent().map(Path::to_path_buf)
}
Section::Skills => {
single.push(file);
None
}
_ => Some(file),
})
.filter_map(|item| Some((item.file_name()?.to_string_lossy().into_owned(), item)))
.collect();
items.sort();
items.dedup();
for (_, item) in &items {
inside(package, item)?;
}
let shown = |path: &Path| {
path.strip_prefix(package)
.unwrap_or(path)
.display()
.to_string()
};
let mut warnings: Vec<String> = single
.iter()
.map(|file| {
format!(
"{} was skipped: it is a single-file skill, and chest installs only skill folders that hold a SKILL.md",
shown(file)
)
})
.collect();
let mut kept: Vec<Item> = Vec::new();
for (item, path) in items {
let shown = shown(&path);
if !valid_name(&item) {
warnings.push(format!("{shown} was skipped: `{item}` is not a valid name"));
} else if kept.iter().any(|(other, _)| *other == item) {
warnings.push(format!(
"{shown} was skipped: another item is named `{item}`"
));
} else {
kept.push((item, path));
}
}
if let (true, Some(file)) = (kept.is_empty(), single.first()) {
return Err(invalid(format!(
"the package provides no {key} that chest installs: {} is a single-file skill, and chest installs only skill folders that hold a SKILL.md",
shown(file)
)));
}
if kept.is_empty() {
return Err(invalid(format!("the package provides no {key}")));
}
Ok((kept, warnings))
}
pub(super) fn inside(package: &Path, path: &Path) -> Result<(), ChestError> {
let root = package.canonicalize().map_err(io(package))?;
let shown = path.strip_prefix(package).unwrap_or(path).display();
let real = path
.canonicalize()
.map_err(|_| invalid(format!("{shown} does not exist")))?;
if real.starts_with(&root) {
Ok(())
} else {
Err(invalid(format!("{shown} is outside the package")))
}
}
pub(super) fn is_package(section: Section, source: &Path) -> bool {
let skill = section == Section::Skills && source.join("SKILL.md").is_file();
section != Section::Extensions && source.is_dir() && !skill
}
pub(super) fn check_item(section: Section, item: &Path, shown: &Path) -> Result<(), ChestError> {
let metadata = item
.symlink_metadata()
.map_err(|_| invalid(format!("{} does not exist", shown.display())))?;
let fits = match section {
Section::Skills => metadata.is_dir() && item.join("SKILL.md").is_file(),
Section::Agents => metadata.is_file() && item.extension().is_some_and(|ext| ext == "md"),
_ => true,
};
if fits {
Ok(())
} else {
let what = match section {
Section::Skills => "a skill folder with a SKILL.md",
_ => "a subagent .md file",
};
Err(invalid(format!("{} is not {what}", shown.display())))
}
}