//! What a tool installed from a packslip declares beyond its executables.
//!
//! The backend keeps the verified statement beside each install. This
//! module reads it back and turns the `resources` it lists into things
//! mise can hand a shell: a completion script for whichever version of the
//! tool is active, from the most verifiable source the vendor offered.
pub(crate) mod completions;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use eyre::{Result, WrapErr, bail, eyre};
use packslip::model::{Artifact, Resource, ResourceSource, Statement, resource_fits};
use reqwest::header::{HeaderMap, HeaderValue};
use crate::backend::packslip::{
STATEMENT_FILE, is_safe_relative, locate_dir_in_install, locate_in_install, selected_artifact,
};
use crate::backend::{Backend, MISE_BINS_DIR};
use crate::cmd::CmdLineRunner;
use crate::config::{Config, Settings};
use crate::file;
use crate::github;
use crate::http::{HTTP, HTTP_FETCH};
use crate::toolset::{ToolVersion, Toolset};
use crate::ui::progress_report::SingleReport;
/// Resources fetched from outside the artifact live here in the install.
pub(crate) const RESOURCES_DIR: &str = ".mise-packslip";
pub(crate) const MANPAGES_DIR: &str = "man";
/// The statement kept beside an install, if the tool came from a packslip.
pub(crate) fn statement(install_path: &Path) -> Result<Option<Statement>> {
let path = install_path.join(STATEMENT_FILE);
if !path.is_file() {
return Ok(None);
}
let text = file::read_to_string(&path)?;
let statement: Statement =
serde_json::from_str(&text).wrap_err_with(|| format!("reading {}", path.display()))?;
statement
.validate()
.wrap_err_with(|| format!("{} is not a valid packslip statement", path.display()))?;
Ok(Some(statement))
}
/// Where a resource's file is inside the install, if it is there: in the
/// unpacked artifact, or where [`fetch_files`] put it.
pub(crate) fn resource_path(install_path: &Path, resource: &Resource) -> Option<PathBuf> {
let fetched = |sub: &str, rel: &str| {
Some(install_path.join(RESOURCES_DIR).join(sub).join(rel)).filter(|p| p.is_file())
};
match resource.source()? {
ResourceSource::Archive => locate_in_install(install_path, resource.archive.as_deref()?),
ResourceSource::Asset => fetched("assets", asset_name(resource)?),
ResourceSource::Repo => fetched("repo", repo_path(resource)?),
ResourceSource::Exec => None,
}
}
/// The asset an entry names, if it is a plain file name. A verified
/// statement is still the vendor's data: nothing in it may name a path
/// outside the install.
fn asset_name(resource: &Resource) -> Option<&str> {
resource
.asset
.as_deref()
.filter(|name| file::is_plain_file_name(name))
}
/// The repository path an entry names, if it is safe to join.
fn repo_path(resource: &Resource) -> Option<&str> {
resource.repo.as_deref().filter(|rel| is_safe_relative(rel))
}
/// The name of a skill, if it is a plain file name and not the file
/// `sync_skills` keeps its own state in.
fn skill_name(resource: &Resource) -> Option<&str> {
resource
.name
.as_deref()
.filter(|name| file::is_plain_file_name(name) && *name != SYNC_STATE)
}
/// Where a directory resource, a skill, is inside the install, if it is
/// there: in the unpacked artifact, or where [`fetch_files`] put it.
pub(crate) fn resource_dir(install_path: &Path, resource: &Resource) -> Option<PathBuf> {
let fetched = |sub: &str, rel: &str| {
Some(install_path.join(RESOURCES_DIR).join(sub).join(rel)).filter(|p| p.is_dir())
};
let dir = match resource.source()? {
ResourceSource::Archive => {
locate_dir_in_install(install_path, resource.archive.as_deref()?)
}
ResourceSource::Asset | ResourceSource::Exec => fetched("skills", skill_name(resource)?),
ResourceSource::Repo => fetched("repo", repo_path(resource)?),
}?;
// A directory without SKILL.md is not a skill. Fetching already treats
// one as unfinished, and a directory an interrupted attempt left behind
// must not pass for the skill and hide the sources below it.
dir.join("SKILL.md").is_file().then_some(dir)
}
/// The `owner/repo` of a release built from a github.com repository.
fn github_repo(statement: &Statement) -> Option<String> {
let repo = statement.predicate.source.as_ref()?.repo.as_str();
let path = repo
.trim_end_matches('/')
.trim_end_matches(".git")
.strip_prefix("https://github.com/")?;
(path.matches('/').count() == 1).then(|| path.to_string())
}
/// Where to fetch a repository file at the release's commit, and with what
/// headers, for the forges mise knows how to read. GitHub goes through the
/// contents API, so a token applies to a private repository and a missing
/// file is an error rather than a login page; GitLab's raw URL serves
/// public repositories.
pub(crate) fn repo_file_request(statement: &Statement, rel: &str) -> Option<(String, HeaderMap)> {
let source = statement.predicate.source.as_ref()?;
let commit = source.commit.as_deref()?;
let repo = source.repo.trim_end_matches('/').trim_end_matches(".git");
let rel = url_path(rel);
if let Some(path) = repo.strip_prefix("https://github.com/") {
let url = format!("https://api.github.com/repos/{path}/contents/{rel}?ref={commit}");
let mut headers = github::get_headers(&url).ok()?;
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/vnd.github.raw+json"),
);
Some((url, headers))
} else {
repo.strip_prefix("https://gitlab.com/").map(|path| {
(
format!("https://gitlab.com/{path}/-/raw/{commit}/{rel}"),
HeaderMap::new(),
)
})
}
}
/// A repository path as URL path segments: each segment percent-encoded,
/// so a `?` or `#` in a name cannot rewrite the query or fragment and
/// reach past the commit the URL pins.
pub(crate) fn url_path(rel: &str) -> String {
rel.split('/')
.map(|segment| urlencoding::encode(segment).into_owned())
.collect::<Vec<_>>()
.join("/")
}
fn headers_for(url: &str) -> Result<HeaderMap> {
if url.starts_with("https://github.com/")
|| url.starts_with("https://api.github.com/")
|| url.starts_with("https://raw.githubusercontent.com/")
{
github::get_headers(url)
} else {
Ok(HeaderMap::new())
}
}
/// Fetch the files the statement sources from separate release assets and
/// from the source repository, so they are on disk before a shell asks for
/// one. An asset must match the digest the statement signed; a repository
/// file is pinned by the commit it is fetched at. Skills are directories
/// and are not fetched here.
pub(crate) async fn fetch_files(
tv: &ToolVersion,
statement: &Statement,
artifact: Option<&Artifact>,
pr: &dyn SingleReport,
) -> Result<()> {
let base = tv.install_path().join(RESOURCES_DIR);
let fetch_skills = Settings::get().skills.fetch;
let mut resources = selected_resources(statement, artifact);
resources.sort_by_key(|r| match r.source() {
Some(ResourceSource::Archive) => 0,
Some(ResourceSource::Asset) => 1,
Some(ResourceSource::Repo) => 2,
Some(ResourceSource::Exec) => 3,
None => 4,
});
for (index, resource) in resources.iter().copied().enumerate() {
// Sources are alternatives, not a set to collect: once a higher
// source has the skill on disk, the ones below it are not fetched
// and, in particular, a shipped skill never runs the tool.
if resource.kind == "skill"
&& resources[..index].iter().any(|higher| {
higher.kind == "skill"
&& skill_name(higher) == skill_name(resource)
&& resource_dir(&tv.install_path(), higher).is_some()
})
{
continue;
}
// An entry scoped to another platform is not for this install.
if let Some(artifact) = artifact
&& !resource_fits(resource, artifact)
{
continue;
}
if resource.kind == "skill" && !fetch_skills {
debug!(
"{}: skills are not fetched (skills.fetch is off)",
tv.style()
);
continue;
}
match resource.source() {
Some(ResourceSource::Asset) => {
let Some(name) = asset_name(resource) else {
warn!(
"{}: the packslip names an asset {:?}, which is not a plain file name",
tv.style(),
resource.asset.as_deref().unwrap_or_default()
);
continue;
};
let dest = base.join("assets").join(name);
if !dest.exists() {
let Some(url) = &resource.url else {
warn!("{}: asset {name} has no download URL", tv.style());
continue;
};
pr.set_message(format!("download {name}"));
file::create_dir_all(dest.parent().unwrap_or(&base))?;
// The tool is installed by now and the asset is an extra:
// one that cannot be fetched is reported, not fatal. One
// that arrives with the wrong digest is another matter.
if let Err(err) = HTTP
.download_file_with_headers(url, &dest, &headers_for(url)?, Some(pr))
.await
{
let _ = file::remove_all(&dest);
warn!("{}: could not fetch {name}: {err}", tv.style());
continue;
}
let (actual, _) = packslip::digest_file(&dest)?;
let expected = statement.digest_of(name);
if expected != Some(actual.as_str()) {
let _ = file::remove_all(&dest);
bail!(
"{name}: sha256 is {actual}, the packslip says {}",
expected.unwrap_or("it is not a subject")
);
}
}
// The archive and the unpacked skill are separate: an archive
// left by an earlier attempt still needs unpacking.
if resource.kind == "skill"
&& let Some(skill) = skill_name(resource)
{
let dir = base.join("skills").join(skill);
// Like the other skill sources: a skill that cannot be
// unpacked is reported, and the tool still installs. The
// digest check above stays fatal.
if !dir.join("SKILL.md").is_file()
&& let Err(err) = unpack_skill(&dest, &dir, pr)
{
warn!("{}: could not unpack skill {skill}: {err}", tv.style());
}
}
}
Some(ResourceSource::Repo) if resource.kind == "skill" => {
let commit = statement
.predicate
.source
.as_ref()
.and_then(|s| s.commit.as_deref());
let (Some(rel), Some(commit)) = (repo_path(resource), commit) else {
warn!(
"{}: skill {:?} in the source repository is not pinned by a commit, or its path is not safe to fetch",
tv.style(),
resource.repo.as_deref().unwrap_or_default()
);
continue;
};
let dest = base.join("repo").join(rel);
// A finished skill holds SKILL.md; a bare directory may be
// no more than a parent that fetching a file created.
if dest.join("SKILL.md").is_file() {
continue;
}
let Some(repo) = github_repo(statement) else {
warn!(
"{}: skill {rel} lives in the source repository, which mise can only read on github.com",
tv.style()
);
continue;
};
// Built beside its final place and moved there whole, so a
// half-fetched skill never passes for a finished one.
let fetched = match staging_dir(&dest) {
Ok(staging) => {
let built = fetch_repo_dir(&repo, commit, rel, &staging, pr).await;
into_place(&staging, &dest, built)
}
Err(err) => Err(err),
};
if let Err(err) = fetched {
warn!(
"{}: could not fetch skill {rel} from the source repository: {err}",
tv.style()
);
}
}
Some(ResourceSource::Exec) if resource.kind == "skill" => {
let Some(skill) = skill_name(resource) else {
continue;
};
let dir = base.join("skills").join(skill);
if dir.join("SKILL.md").is_file() {
continue;
}
if !Settings::get().packslip.exec {
debug!(
"{}: skill {skill} is generated by running the tool; packslip.exec is off",
tv.style()
);
continue;
}
let Some((program, args)) = resource.exec.split_first() else {
continue;
};
let Some(path) = installed_bin(&tv.install_path(), program) else {
warn!(
"{}: skill {skill} is generated by {program}, which the install does not hold",
tv.style()
);
continue;
};
pr.set_message(format!("generate skill {skill}"));
let generated = match run_resource_command(
&path,
args,
&resource.env,
&tv.install_path(),
std::time::Duration::from_secs(5),
)
.await
{
// Written beside its place and moved whole, like a fetched skill.
Ok(text) => staging_dir(&dir).and_then(|staging| {
let written = file::write(staging.join("SKILL.md"), text);
into_place(&staging, &dir, written)
}),
Err(err) => Err(err),
};
if let Err(err) = generated {
warn!("{}: could not generate skill {skill}: {err}", tv.style());
}
}
Some(ResourceSource::Repo) => {
let Some(rel) = repo_path(resource) else {
warn!(
"{}: the packslip names a repository path {:?}, which is not safe to fetch",
tv.style(),
resource.repo.as_deref().unwrap_or_default()
);
continue;
};
let dest = base.join("repo").join(rel);
if dest.exists() {
continue;
}
let Some((url, headers)) = repo_file_request(statement, rel) else {
warn!(
"{}: {rel} comes from the source repository, which mise cannot read files from",
tv.style()
);
continue;
};
pr.set_message(format!("download {rel}"));
file::create_dir_all(dest.parent().unwrap_or(&base))?;
if let Err(err) = HTTP
.download_file_with_headers(&url, &dest, &headers, Some(pr))
.await
{
warn!(
"{}: could not fetch {rel} from the source repository: {err}",
tv.style()
);
}
}
_ => {}
}
}
Ok(())
}
/// Put every usable static man page into the layout `man` expects below one
/// MANPATH root. Release assets and repository files otherwise land flat or in
/// arbitrary source-tree paths, while an archive is not required to use a
/// `share/man/manN` layout.
pub(crate) fn install_man_pages(
install_path: &Path,
statement: &Statement,
artifact: Option<&Artifact>,
) -> Result<()> {
let root = install_path.join(RESOURCES_DIR).join(MANPAGES_DIR);
let mut resources: Vec<_> = selected_resources(statement, artifact)
.into_iter()
.filter(|resource| resource.kind == "man")
.collect();
resources.sort_by_key(|resource| match resource.source() {
Some(ResourceSource::Archive) => 0,
Some(ResourceSource::Asset) => 1,
Some(ResourceSource::Repo) => 2,
_ => 3,
});
let mut installed = std::collections::BTreeSet::new();
for resource in resources {
let Some(source) = resource_path(install_path, resource) else {
continue;
};
let Some(name) = source.file_name().and_then(|name| name.to_str()) else {
debug!("ignoring a packslip man page without a UTF-8 file name");
continue;
};
let Some(section) = man_section(name) else {
warn!(
"ignoring packslip man page {name:?}: its file name does not end in a man section"
);
continue;
};
let target = root.join(format!("man{section}")).join(name);
// The resource order is an ordered fallback list. Once a higher-ranked
// source supplied this page, a lower-ranked one must not replace it.
if !installed.insert(target.clone()) {
continue;
}
file::create_dir_all(target.parent().unwrap_or(&root))?;
file::make_symlink_or_copy(&source, &target)?;
}
Ok(())
}
/// Return the leading section identifier encoded in a conventional man-page
/// file name. Subsections such as `3pm` still live in the `man3` directory.
fn man_section(name: &str) -> Option<char> {
let uncompressed = [".gz", ".bz2", ".xz", ".zst", ".lzma"]
.into_iter()
.find_map(|suffix| name.strip_suffix(suffix))
.unwrap_or(name);
let (_, section) = uncompressed.rsplit_once('.')?;
section
.bytes()
.all(|byte| byte.is_ascii_alphanumeric())
.then(|| section.chars().next())
.flatten()
}
/// Return the normalized man root when this install contains Packslip pages.
pub(crate) fn manpath(install_path: &Path) -> Option<PathBuf> {
let path = install_path.join(RESOURCES_DIR).join(MANPAGES_DIR);
path.is_dir().then_some(path)
}
/// An executable of the install, by the name the packslip gave it.
fn installed_bin(install_path: &Path, program: &str) -> Option<PathBuf> {
if !is_safe_relative(program) {
return None;
}
let linked = install_path.join(MISE_BINS_DIR).join(program);
if linked.exists() {
return Some(linked);
}
locate_in_install(install_path, program)
}
/// Unpack a skill shipped as its own archive, dropping a lone top-level
/// directory the way artifacts are unpacked.
fn unpack_skill(archive: &Path, dir: &Path, pr: &dyn SingleReport) -> Result<()> {
let name = archive.file_name().unwrap_or_default().to_string_lossy();
let format = file::ExtractionFormat::from_file_name(&name);
if !format.is_archive() {
bail!("skill asset {name} is not an archive mise can unpack");
}
let strip_components = usize::from(file::should_strip_components(archive, format)?);
let staging = staging_dir(dir)?;
let unpacked = file::extract_archive(
archive,
&staging,
format,
&file::ExtractOptions {
strip_components,
pr: Some(pr),
..Default::default()
},
);
into_place(&staging, dir, unpacked)
}
/// A fresh sibling directory to build a skill in, so `dir` only ever
/// exists once it is complete and an interrupted attempt cannot pass for
/// a finished one on the next install.
fn staging_dir(dir: &Path) -> Result<PathBuf> {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
let staging = dir.with_file_name(format!(".{name}.partial"));
if staging.exists() {
file::remove_all(&staging)?;
}
file::create_dir_all(&staging)?;
Ok(staging)
}
/// Move a finished staging directory to where it belongs, or clean it up
/// when building it failed.
fn into_place(staging: &Path, dir: &Path, built: Result<()>) -> Result<()> {
if let Err(err) = built {
let _ = file::remove_all(staging);
return Err(err);
}
if dir.exists() {
file::remove_all(dir)?;
}
std::fs::rename(staging, dir).wrap_err_with(|| {
format!(
"moving {} into place at {}",
staging.display(),
dir.display()
)
})
}
/// Fetch a directory of the source repository at `commit` into `dest`,
/// through the GitHub contents API. Entries that are not plain files or
/// directories (symlinks, submodules) are left out.
async fn fetch_repo_dir(
repo: &str,
commit: &str,
rel: &str,
dest: &Path,
pr: &dyn SingleReport,
) -> Result<()> {
let url = format!(
"https://api.github.com/repos/{repo}/contents/{}?ref={commit}",
url_path(rel)
);
// The client asks for the raw media type on every contents URL, which
// is right for a file body and wrong for a directory listing.
let mut headers = github::get_headers(&url)?;
headers.insert(
reqwest::header::ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
let listing: serde_json::Value = HTTP_FETCH.json_with_headers(&url, &headers).await?;
let Some(entries) = listing.as_array() else {
bail!("{rel} is not a directory of the repository");
};
file::create_dir_all(dest)?;
for entry in entries {
let Some(name) = entry["name"].as_str() else {
continue;
};
if !file::is_plain_file_name(name) {
continue;
}
match entry["type"].as_str() {
Some("dir") => {
Box::pin(fetch_repo_dir(
repo,
commit,
&format!("{rel}/{name}"),
&dest.join(name),
pr,
))
.await?;
}
Some("file") => {
// Through the contents API rather than the entry's raw
// download URL: the client's token, and the raw media type it
// sets on contents URLs, apply there, so a private repository
// works the same as a public one.
let file_url = format!(
"https://api.github.com/repos/{repo}/contents/{}?ref={commit}",
url_path(&format!("{rel}/{name}"))
);
pr.set_message(format!("download {rel}/{name}"));
HTTP.download_file_with_headers(
&file_url,
&dest.join(name),
&github::get_headers(&file_url)?,
Some(pr),
)
.await?;
}
_ => {}
}
}
Ok(())
}
/// A skill one of the active tools declares: a directory holding
/// `SKILL.md`, for the exact version that is active here.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct Skill {
pub name: String,
pub tool: String,
pub version: String,
pub path: PathBuf,
}
/// Where `sync_skills` records which links in a directory it made, so
/// only those are ever replaced or pruned. A link's target alone would not
/// tell a link mise made from one a person pointed into mise's installs.
pub(crate) const SYNC_STATE: &str = ".mise-skills.json";
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct SyncState {
/// Each link mise made, by name, with the target it was made with. A
/// link at that name pointing anywhere else is somebody else's, even
/// if it points into mise's installs.
#[serde(default)]
links: BTreeMap<String, String>,
}
/// A missing state file means nothing was linked yet. A malformed one is
/// an error, not an empty set: forgetting which links are mise's would
/// leave them as foreign, unreplaced and unpruned, for good.
fn read_sync_state(dir: &Path) -> Result<SyncState> {
let path = dir.join(SYNC_STATE);
if !path.is_file() {
return Ok(SyncState::default());
}
let text = file::read_to_string(&path)?;
serde_json::from_str(&text).wrap_err_with(|| {
format!(
"{} is not valid; it records which links in {} mise made. Fix or remove it, then run sync again",
path.display(),
dir.display()
)
})
}
fn write_sync_state(dir: &Path, state: &SyncState) -> Result<()> {
let path = dir.join(SYNC_STATE);
if state.links.is_empty() {
if path.exists() {
file::remove_file(&path)?;
}
return Ok(());
}
file::write_atomic(&path, serde_json::to_string_pretty(state)?)
}
/// The skills a statement declares that are present in the install.
pub(crate) fn skills_of(
statement: &Statement,
install_path: &Path,
tool: &str,
version: &str,
artifact: Option<&Artifact>,
) -> Vec<Skill> {
// A vendor may offer one skill from several sources, as completions
// are offered; the most verifiable one that is on disk is the skill.
let rank = |r: &Resource| match r.source() {
Some(ResourceSource::Archive) => 0,
Some(ResourceSource::Asset) => 1,
Some(ResourceSource::Repo) => 2,
Some(ResourceSource::Exec) => 3,
None => 4,
};
// Each name is its own skill, so platform scope is resolved per name:
// a skill for one platform never hides the skills for every platform.
let skills: Vec<&Resource> = statement
.predicate
.resources
.iter()
.filter(|r| r.kind == "skill")
.collect();
let mut names: Vec<&str> = Vec::new();
for name in skills.iter().filter_map(|r| skill_name(r)) {
if !names.contains(&name) {
names.push(name);
}
}
let mut chosen: Vec<(usize, Skill)> = Vec::new();
for name in names {
let mut group = applicable(
skills
.iter()
.copied()
.filter(|r| skill_name(r) == Some(name)),
artifact,
);
group.sort_by_key(|r| rank(r));
if let Some((r, path)) = group
.into_iter()
.find_map(|r| resource_dir(install_path, r).map(|p| (r, p)))
{
chosen.push((
rank(r),
Skill {
name: name.to_string(),
tool: tool.to_string(),
version: version.to_string(),
path,
},
));
}
}
chosen.sort_by_key(|(rank, _)| *rank);
chosen.into_iter().map(|(_, skill)| skill).collect()
}
/// The skills of every tool active in the current directory.
pub(crate) async fn active_skills(config: &Arc<Config>) -> Result<Vec<Skill>> {
let ts = config.get_toolset().await?;
let mut skills = Vec::new();
for (backend, tv) in ts.list_current_installed_versions(config) {
let install_path = tv.install_path();
let statement = match statement(&install_path) {
Ok(Some(statement)) => statement,
Ok(None) => continue,
Err(err) => {
warn!("{}: {err}", tv.style());
continue;
}
};
let artifact = selected_artifact(
&statement,
&install_path,
tv.request.options().get_string("variant").as_deref(),
);
skills.extend(skills_of(
&statement,
&install_path,
&backend.ba().short,
&tv.version,
artifact.as_ref(),
));
}
Ok(skills)
}
/// Where skills are linked under `root`, a project root or the home
/// directory: the `skills.dir` setting, or that setting itself when it
/// is absolute.
pub(crate) fn skills_dir(root: &Path) -> PathBuf {
root.join(&Settings::get().skills.dir)
}
/// With `skills.auto_sync` on, link the active tools' skills into the
/// project after an install or a version change. Nothing fails an install
/// here: a problem is reported and the tools stay installed. Outside a
/// project root there is nowhere to link into, so nothing happens.
pub(crate) async fn auto_sync_skills(config: &Arc<Config>) {
let settings = Settings::get();
if !settings.skills.auto_sync {
return;
}
let Some(root) = &config.project_root else {
return;
};
let dir = skills_dir(root);
let result = async {
let skills = active_skills(config).await?;
if skills.is_empty() && !settings.skills.prune {
return Ok(SyncReport::default());
}
sync_skills(&dir, &skills, &crate::dirs::INSTALLS, settings.skills.prune)
}
.await;
match result {
Ok(report) => {
for name in &report.linked {
info!("linked skill {name} into {}", dir.display());
}
for name in &report.pruned {
info!("removed skill link {name} from {}", dir.display());
}
for (name, why) in &report.skipped {
warn!("skipped skill {name}: {why}");
}
}
Err(err) => warn!("could not sync skills into {}: {err}", dir.display()),
}
}
/// What [`sync_skills`] did.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SyncReport {
pub linked: Vec<String>,
pub unchanged: Vec<String>,
pub pruned: Vec<String>,
/// Skills not linked, with why.
pub skipped: Vec<(String, String)>,
}
/// Link each skill into `dir` under its name. Only links mise made, which
/// it records in [`SYNC_STATE`] beside them and which point into
/// `installs`, are ever replaced or, with `prune`, removed; anything else
/// at a skill's name is left alone.
pub(crate) fn sync_skills(
dir: &Path,
skills: &[Skill],
installs: &Path,
prune: bool,
) -> Result<SyncReport> {
let mut report = SyncReport::default();
let before = read_sync_state(dir)?.links;
let mut wanted: BTreeMap<&str, &Skill> = BTreeMap::new();
for skill in skills {
match wanted.get(skill.name.as_str()) {
Some(first) => report.skipped.push((
skill.name.clone(),
format!(
"{} also provides a skill called {}; keeping that one",
first.tool, skill.name
),
)),
None => {
wanted.insert(&skill.name, skill);
}
}
}
// Mise's own link: recorded under this name, still a link, still
// pointing exactly where mise pointed it, and that is inside installs.
// Where a link points, as recorded. Windows reports a junction's target
// with a verbatim prefix, so both sides are simplified; a target that
// still exists is also matched by identity.
let points_at = |link: &Path, target: &str| {
std::fs::read_link(link).is_ok_and(|t| {
dunce::simplified(&t) == dunce::simplified(Path::new(target))
|| same_file::is_same_file(link, target).unwrap_or(false)
})
};
let ours = |name: &str, link: &Path| {
before.get(name).is_some_and(|target| {
file::is_symlink_or_junction(link)
&& points_at(link, target)
&& file::is_symlink_target_within(link, installs).unwrap_or(false)
})
};
// The record is written before a link is made and after one is
// removed, so a sync cut short never leaves a link mise made that it
// would not recognise as its own next time.
let mut current = before.clone();
let persist = |links: &BTreeMap<String, String>| {
write_sync_state(
dir,
&SyncState {
links: links.clone(),
},
)
};
if !wanted.is_empty() {
file::create_dir_all(dir)?;
}
for (name, skill) in &wanted {
let link = dir.join(name);
// Already right, and mise's: a link a person made to the same place
// is still theirs and is not adopted.
if ours(name, &link) && file::is_symlink_to(&link, &skill.path) {
report.unchanged.push(name.to_string());
continue;
}
if link.exists() || file::is_symlink_or_junction(&link) {
if !ours(name, &link) {
report.skipped.push((
name.to_string(),
format!("{} exists and is not a link mise made", link.display()),
));
continue;
}
file::remove_all(&link)?;
}
current.insert(name.to_string(), skill.path.display().to_string());
persist(¤t)?;
file::make_symlink(&skill.path, &link)?;
report.linked.push(name.to_string());
}
let mut made: BTreeMap<String, String> = report
.linked
.iter()
.chain(&report.unchanged)
.filter_map(|name| {
wanted
.get(name.as_str())
.map(|skill| (name.clone(), skill.path.display().to_string()))
})
.collect();
if prune && dir.is_dir() {
for entry in file::ls(dir)? {
let Some(name) = entry.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !wanted.contains_key(name) && ours(name, &entry) {
file::remove_all(&entry)?;
current.remove(name);
persist(¤t)?;
report.pruned.push(name.to_string());
}
}
} else {
// Without pruning, links made earlier stay mise's as long as they
// still are what mise made.
made.extend(
before
.iter()
.filter(|(name, target)| {
let link = dir.join(name);
file::is_symlink_or_junction(&link) && points_at(&link, target)
})
.map(|(name, target)| (name.clone(), target.clone())),
);
}
if made != current {
persist(&made)?;
}
Ok(report)
}
/// Where a completion for one shell can come from, most verifiable first.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CompletionSource {
/// A script the vendor shipped, on disk.
File(PathBuf),
/// A CLI spec on disk to derive the script from.
Spec {
format: String,
bin: String,
path: PathBuf,
},
/// A command of the tool's that prints the script.
Exec(Vec<String>, BTreeMap<String, String>),
/// A command of the tool's that prints a CLI spec to derive from.
SpecExec {
format: String,
bin: String,
argv: Vec<String>,
env: BTreeMap<String, String>,
},
}
/// The entries of one kind that apply to the selected artifact, keeping
/// only the most specific of them: a resource may carry `os`, `arch`, or
/// `libc` when layouts differ by platform, and the one naming the most of
/// those wins. With no artifact selected, only unscoped entries apply.
pub(crate) fn applicable<'a>(
resources: impl Iterator<Item = &'a Resource>,
artifact: Option<&Artifact>,
) -> Vec<&'a Resource> {
let specificity = |r: &Resource| {
(
r.artifact.is_some(),
[&r.os, &r.arch, &r.libc]
.into_iter()
.filter(|f| f.is_some())
.count(),
)
};
let fits: Vec<&Resource> = resources
.filter(|r| match artifact {
Some(artifact) => resource_fits(r, artifact),
None => specificity(r) == (false, 0),
})
.collect();
let best = fits
.iter()
.map(|r| specificity(r))
.max()
.unwrap_or_default();
fits.into_iter()
.filter(|r| specificity(r) == best)
.collect()
}
fn selected_resources<'a>(
statement: &'a Statement,
artifact: Option<&Artifact>,
) -> Vec<&'a Resource> {
match artifact {
Some(artifact) => packslip::select_resources(statement, artifact),
None => statement
.predicate
.resources
.iter()
.filter(|r| {
r.artifact.is_none() && r.os.is_none() && r.arch.is_none() && r.libc.is_none()
})
.collect(),
}
}
/// Every way the statement offers a `shell` completion, in the order the
/// specification says a consumer takes them: the entries that apply to
/// the selected artifact, then shipped scripts, then a script derived
/// from a CLI spec, then anything that runs the tool.
/// Whether the statement offers this shell a completion at all, however the
/// install turned out. A declared file that never reached the install drops
/// out of [`completion_sources`], and the two cases want different answers:
/// one is the vendor declaring nothing, the other is a fetch that failed or
/// was skipped, and reporting the second as the first hides it.
pub(crate) fn declares_completion(statement: &Statement, shell: &str) -> bool {
statement.predicate.resources.iter().any(|r| {
r.kind == "cli-spec"
|| (r.kind == "completion"
&& (r.shell.as_deref() == Some(shell) || r.shells.iter().any(|s| s == shell)))
})
}
pub(crate) fn completion_sources(
statement: &Statement,
install_path: &Path,
shell: &str,
artifact: Option<&Artifact>,
tool: Option<&str>,
) -> Vec<CompletionSource> {
let bin = completion_bin(statement, tool);
let describes =
|r: &&Resource| r.bin.as_deref().or_else(|| statement.sole_bin()) == bin && bin.is_some();
// Select per identity before considering source order. A completion
// for one executable must never hide or complete another executable.
let selected = selected_resources(statement, artifact);
let completion_entries = applicable(
selected.iter().copied().filter(describes).filter(|r| {
r.kind == "completion"
&& (r.shell.as_deref() == Some(shell) || r.shells.iter().any(|s| s == shell))
}),
artifact,
);
let mut spec_entries: Vec<_> = selected
.iter()
.copied()
.filter(describes)
.filter(|r| r.kind == "cli-spec")
.collect();
// The specification ranks the static sources of a spec as it ranks a
// shipped script's: the archive the release signed, then a signed asset,
// then the source repository. Document order breaks ties within a rank.
spec_entries.sort_by_key(|r| match r.source() {
Some(ResourceSource::Archive) => 0,
Some(ResourceSource::Asset) => 1,
Some(ResourceSource::Repo) => 2,
_ => 3,
});
let completions = || completion_entries.iter().copied();
let for_shell =
|r: &Resource| r.shell.as_deref() == Some(shell) || r.shells.iter().any(|s| s == shell);
let mut sources = Vec::new();
for rank in [
ResourceSource::Archive,
ResourceSource::Asset,
ResourceSource::Repo,
] {
for r in completions().filter(|r| r.source() == Some(rank) && for_shell(r)) {
if let Some(path) = resource_path(install_path, r) {
sources.push(CompletionSource::File(path));
}
}
}
let specs = || {
spec_entries
.iter()
.copied()
.filter_map(|r| Some((r, r.format.clone()?, r.bin.clone()?)))
};
for (r, format, bin) in specs().filter(|(r, ..)| r.source() != Some(ResourceSource::Exec)) {
if let Some(path) = resource_path(install_path, r) {
sources.push(CompletionSource::Spec { format, bin, path });
}
}
let substitute = |argv: &[String]| -> Vec<String> {
argv.iter().map(|a| a.replace("{shell}", shell)).collect()
};
for r in completions().filter(|r| r.source() == Some(ResourceSource::Exec) && for_shell(r)) {
sources.push(CompletionSource::Exec(
substitute(&r.exec),
r.env
.iter()
.map(|(k, v)| (k.clone(), v.replace("{shell}", shell)))
.collect(),
));
}
for (r, format, bin) in specs().filter(|(r, ..)| r.source() == Some(ResourceSource::Exec)) {
sources.push(CompletionSource::SpecExec {
format,
bin,
argv: substitute(&r.exec),
env: r
.env
.iter()
.map(|(k, v)| (k.clone(), v.replace("{shell}", shell)))
.collect(),
});
}
sources
}
/// The active, installed tool called `name`, or the one providing an
/// executable called `name`.
async fn find_tool(
config: &Arc<Config>,
ts: &Toolset,
name: &str,
) -> Result<(Arc<dyn Backend>, ToolVersion)> {
if let Some(found) = ts.which(config, name).await {
return Ok(found);
}
let by_name = ts
.list_current_installed_versions(config)
.into_iter()
.find(|(b, _)| b.ba().short == name || b.tool_name() == name || b.id() == name);
match by_name {
Some(found) => Ok(found),
None => bail!("{name} is not an active, installed tool or one of their executables"),
}
}
/// Run one of the tool's own executables and return what it printed.
async fn run_tool(
config: &Arc<Config>,
backend: &Arc<dyn Backend>,
tv: &ToolVersion,
argv: &[String],
env: &BTreeMap<String, String>,
) -> Result<String> {
let Some((program, args)) = argv.split_first() else {
bail!("an exec entry with no command");
};
let Some(path) = backend.which(config, tv, program).await? else {
bail!("{} has no executable called {program}", tv.style());
};
run_resource_command(
&path,
args,
env,
&tv.install_path(),
std::time::Duration::from_secs(5),
)
.await
}
/// Run vendor resource generation outside the user's project, without
/// input, under a deadline. Empty output is a failed source, never a cache hit.
async fn run_resource_command(
path: &Path,
args: &[String],
env: &BTreeMap<String, String>,
install_path: &Path,
timeout: std::time::Duration,
) -> Result<String> {
let work = tempfile::tempdir()?;
let output = CmdLineRunner::new(path)
.args(args)
.envs(env)
.prepend_path(vec![install_path.join(MISE_BINS_DIR)])?
.current_dir(work.path())
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.with_timeout(timeout)
.read_isolated(4 * 1024 * 1024)
.await?;
if output.trim().is_empty() {
bail!("resource command produced no output");
}
Ok(output)
}
/// Derive a completion script from a CLI spec with the consumer's own
/// tooling. Only the `usage` format is known.
fn derive_from_spec(format: &str, bin: &str, spec: &Path, shell: &str) -> Result<String> {
if format != "usage" {
bail!("mise cannot derive completions from a {format} spec");
}
// Validate before returning a loader, so an invalid preferred spec still
// falls through to another resource source.
file::read_to_string(spec)?
.parse::<usage::Spec>()
.map_err(|err| eyre!("invalid usage specification: {err}"))?;
let shell = usage_rs::complete::Shell::from_name(shell)
.ok_or_else(|| eyre!("unsupported completion shell: {shell}"))?;
let path = completions::encode_spec_path(spec);
let script = usage_rs::script::script_for("mise", bin, shell);
Ok(script.replace(
" __complete_word__ ",
&format!(" __usage_complete_word {path} "),
))
}
/// The `shell` completion script for `tool`, from the packslip of the
/// version that is active right now.
fn completion_bin<'a>(statement: &'a Statement, tool: Option<&'a str>) -> Option<&'a str> {
tool.map(packslip::command_name)
.filter(|name| {
statement
.predicate
.artifacts
.iter()
.flat_map(|a| &a.bin)
.any(|b| b.name == *name)
})
.or_else(|| statement.sole_bin())
}
fn completion_cache_path(install_path: &Path, tool: &str, shell: &str) -> Result<PathBuf> {
let bin = packslip::command_name(tool);
if !file::is_plain_file_name(bin) || !file::is_plain_file_name(shell) {
bail!("invalid completion cache identity");
}
Ok(install_path
.join(RESOURCES_DIR)
.join("completions-v2")
.join(bin)
.join(format!("{shell}.completion")))
}
pub(crate) async fn completion_script(
config: &Arc<Config>,
tool: &str,
shell: &str,
) -> Result<String> {
let ts = config.get_toolset().await?;
let (backend, tv) = find_tool(config, ts, tool).await?;
let install_path = tv.install_path();
let Some(statement) = statement(&install_path)? else {
bail!(
"{} was not installed from a packslip, so mise does not know its completions",
tv.style()
);
};
let artifact = selected_artifact(
&statement,
&install_path,
tv.request.options().get_string("variant").as_deref(),
);
let sources = completion_sources(
&statement,
&install_path,
shell,
artifact.as_ref(),
Some(tool),
);
if sources.is_empty() {
if declares_completion(&statement, shell) {
bail!(
"the packslip of {} declares a {shell} completion, but none of the files it names are in the install: the resource fetch failed or was skipped",
tv.style()
);
}
bail!(
"the packslip of {} declares no {shell} completion",
tv.style()
);
}
// A completion is asked for the moment a shell completes the command,
// which is when the user was going to run it anyway, so an `exec`
// source runs on demand with no setting; the specification's Running
// an exec entry says so. Because it runs the tool, its result is
// cached beside the install so the command runs once per version and
// shell rather than at every tab.
let bin = completion_bin(&statement, Some(tool)).ok_or_else(|| {
eyre!(
"{} provides several executables; name the command to complete",
tv.style()
)
})?;
let cache = completion_cache_path(&install_path, bin, shell)?;
// Nothing below happens until a source that runs the tool comes up. A
// system or shared install is read-only, and a completion that is simply
// a file in it has to stay readable there: taking the lock first would
// ask to write to the install before reading anything from it.
// `None` until the first source that runs the tool, and `Some(None)`
// where the install cannot be written to and so cannot be locked.
let mut generating: Option<Option<fslock::LockFile>> = None;
let mut skipped = Vec::new();
for source in sources {
let ran_tool = matches!(
source,
CompletionSource::Exec(..) | CompletionSource::SpecExec { .. }
);
if ran_tool && generating.is_none() {
generating = Some(lock_generation(&cache).await);
// An empty entry is what an interrupted generation leaves behind,
// not a completion; reading it back would hide every source below.
if let Ok(cached) = file::read_to_string(&cache)
&& !cached.trim().is_empty()
{
return Ok(cached);
}
}
let attempt = match source {
CompletionSource::File(path) => file::read_to_string(&path),
CompletionSource::Spec { format, bin, path } => {
derive_from_spec(&format, &bin, &path, shell)
}
CompletionSource::Exec(argv, env) => run_tool(config, &backend, &tv, &argv, &env).await,
CompletionSource::SpecExec {
format,
bin,
argv,
env,
} => {
// Any failure here is one more reason to try the next source,
// not the end of the search. The spec is kept in the install:
// a script derived from it names the file at completion time,
// so it has to outlive this command.
async {
if !file::is_plain_file_name(&bin) || !file::is_plain_file_name(&format) {
bail!("cli-spec entry names {bin:?} in format {format:?}");
}
let spec = run_tool(config, &backend, &tv, &argv, &env).await?;
// A spec generated for one shell, as `{shell}` in the
// command allows, is not the spec for another, and two
// shells generating at once must not read each other's
// half-written file. `shell` is a plain file name: the
// cache path above refuses anything else.
let dir = install_path.join(RESOURCES_DIR).join("specs").join(shell);
file::create_dir_all(&dir)?;
let path = dir.join(format!("{bin}.{format}"));
file::write_atomic(&path, &spec)?;
derive_from_spec(&format, &bin, &path, shell)
}
.await
}
};
match attempt {
Ok(script) if script.trim().is_empty() => {
skipped.push("nothing was printed".to_string())
}
Ok(script) => {
if ran_tool
&& let Some(dir) = cache.parent()
&& file::create_dir_all(dir).is_ok()
{
let _ = file::write_atomic(&cache, &script);
}
return Ok(script);
}
Err(err) => skipped.push(err.to_string()),
}
}
bail!(
"no usable {shell} completion for {}: {}",
tv.style(),
skipped.join("; ")
)
}
/// Take turns generating, so that of the shells completing one command at
/// once the first runs the tool and the rest read what it cached. The lock
/// lives beside the cache, shared by every process that shares the install,
/// and is taken off the runtime's threads.
///
/// A read-only install cannot be locked and does not need to be: nothing
/// will be cached there either, so each shell generates its own script
/// rather than being refused a completion.
async fn lock_generation(cache: &Path) -> Option<fslock::LockFile> {
let lock_path = cache.with_extension("lock");
let taken = tokio::task::spawn_blocking(move || -> Result<fslock::LockFile> {
if let Some(dir) = lock_path.parent() {
file::create_dir_all(dir)?;
}
let mut lock = fslock::LockFile::open(&lock_path)?;
lock.lock()?;
Ok(lock)
})
.await;
match taken {
Ok(Ok(lock)) => Some(lock),
Ok(Err(err)) => {
debug!("generating a completion without a lock: {err}");
None
}
Err(err) => {
debug!("generating a completion without a lock: {err}");
None
}
}
}
pub(crate) fn completion_ident(tool: &str) -> String {
tool.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() {
char::from(byte).to_string()
} else {
format!("_{byte:02x}")
}
})
.collect()
}
/// A stub the shell loads by name, which asks mise for the real script at
/// completion time, so it follows whichever version of the tool is active.
/// It carries the marker usage's installer looks for, so re-installing
/// replaces it rather than refusing a foreign file.
///
/// In zsh and bash the vendor's script replaces the stub while it completes
/// and the stub is put back afterwards, so the next completion asks mise
/// again and a version switch in another directory is followed on the next
/// tab. fish reads the script in a child shell of its own, and PowerShell
/// puts this completer back after delegating, for the same reason: neither
/// keeps the registrations of a version that is no longer the active one.
pub(crate) fn stub(tool: &str, shell: usage_rs::complete::Shell) -> Result<String> {
use usage_rs::complete::Shell;
let note = format!("mise completes {tool} from the packslip of whichever version is active");
let by = format!(
"@generated by usage's installer for `mise completion {} --tool {tool} --install`",
shell.as_str()
);
let ident = completion_ident(tool);
let loader = format!("__mise_load_{ident}");
let stub = match shell {
Shell::Zsh => format!(
r#"#compdef {tool}
# {note}.
# {by}
# The vendor's script takes over this function while it completes; the stub
# is put back afterwards, so the next completion asks mise again.
local __mise_stub="${{functions[_{tool}]}}"
local __mise_matches="${{compstate[nmatches]:-0}}"
# Loaded in a function of its own: a `return` in the vendor's script ends
# that function, not this one, so the stub is always put back below.
{loader}() {{
eval "$(command mise completion zsh --tool '{tool}' 2>/dev/null)"
}}
{loader} "$@"
local __mise_fn="${{_comps[{tool}]:-_{tool}}}"
local __mise_ret=0
if [[ "${{compstate[nmatches]:-0}}" != "$__mise_matches" ]]; then
# The script completed on its own, as one that checks funcstack does
# when it finds itself inside _{tool}; calling it again would double
# every candidate.
:
elif [[ "$__mise_fn" != _{tool} || "${{functions[_{tool}]}}" != "$__mise_stub" ]]; then
"$__mise_fn" "$@"
__mise_ret=$?
fi
functions[_{tool}]="$__mise_stub"
compdef _{tool} '{tool}'
return $__mise_ret
"#
),
Shell::Bash => {
let func = format!("__mise_complete_{ident}");
format!(
r#"# {note}.
# {by}
# The vendor's script registers its own completer, which handles this
# completion; the stub is put back at the next prompt, so the next asks mise
# again.
{func}() {{
eval "$(command mise completion bash --tool '{tool}' 2>/dev/null)"
local __mise_spec
__mise_spec=$(complete -p '{tool}' 2>/dev/null)
if [[ -n $__mise_spec && $__mise_spec != *{func}* ]]; then
# The vendor's registration is in place now, options and all. Hand this
# completion to it: 124 makes bash retry with the current registration.
# The stub comes back at the next prompt, so later completions ask mise
# again and a version switch is followed.
{func}_restub() {{
# Runs first at the prompt, so this is the last command's status,
# which a prompt that shows it must get back unchanged.
local __mise_status=$?
complete -F {func} '{tool}'
if declare -p PROMPT_COMMAND 2>/dev/null | grep -q '^declare -a'; then
local __mise_i
for __mise_i in "${{!PROMPT_COMMAND[@]}}"; do
[[ ${{PROMPT_COMMAND[__mise_i]}} == "{func}_restub" ]] && unset 'PROMPT_COMMAND[__mise_i]'
done
else
PROMPT_COMMAND=${{PROMPT_COMMAND//{func}_restub;/}}
fi
return $__mise_status
}}
if declare -p PROMPT_COMMAND 2>/dev/null | grep -q '^declare -a'; then
PROMPT_COMMAND=("{func}_restub" "${{PROMPT_COMMAND[@]}}")
else
PROMPT_COMMAND="{func}_restub;${{PROMPT_COMMAND:-}}"
fi
return 124
fi
# Nothing usable came back; stay registered and offer nothing this time.
complete -F {func} '{tool}'
return 0
}}
complete -F {func} '{tool}'
"#
)
}
Shell::Fish => format!(
r#"# {note}.
# {by}
# The vendor's script is read in a child shell, once per completion, so its
# registrations and helper functions never outlive the version they came
# from and a version switch in another directory is followed at the next tab.
function {loader}
set -l __mise_fish (status fish-path)
set -l __mise_line (commandline --current-process --cut-at-cursor | string collect --allow-empty)
# An empty completion path keeps the child from autoloading this stub.
$__mise_fish --no-config -c '
set fish_complete_path
command mise completion fish --tool $argv[1] 2>/dev/null | source
complete --do-complete "$argv[2]"
' -- '{tool}' $__mise_line
end
complete -c '{tool}' -f -a '({loader})'
"#
),
Shell::PowerShell => format!(
r#"# {note}.
# {by}
# The vendor's script registers its own completer, which handles this
# completion; this one is put back afterwards, so the next completion asks
# mise again and a version switch in another directory is followed.
function global:{loader} {{
param($wordToComplete, $commandAst, $cursorPosition)
# A script that registers nothing for this command would otherwise reach
# this completer again through TabExpansion2, without end.
if ($global:{loader}_busy) {{ return }}
$global:{loader}_busy = $true
try {{
$__mise_script = @(& mise completion powershell --tool '{tool}' 2>$null) -join "`n"
if ($__mise_script) {{
Invoke-Expression $__mise_script
$__mise_cursor = $cursorPosition - $commandAst.Extent.StartOffset
$__mise_line = $commandAst.Extent.Text.PadRight([Math]::Max($commandAst.Extent.Text.Length, $__mise_cursor))
(TabExpansion2 -inputScript $__mise_line -cursorColumn $__mise_cursor).CompletionMatches
}}
}} finally {{
Register-ArgumentCompleter -Native -CommandName '{tool}' -ScriptBlock $function:{loader}
$global:{loader}_busy = $false
}}
}}
Register-ArgumentCompleter -Native -CommandName '{tool}' -ScriptBlock $function:{loader}
"#
),
_ => bail!(
"{} loads completions eagerly, so mise cannot leave it a stub; redirect `mise completion {} --tool {tool}` yourself",
shell.as_str(),
shell.as_str()
),
};
Ok(stub)
}
#[cfg(test)]
mod tests {
use super::*;
fn statement_with(resources: &str) -> Statement {
let json = format!(
r#"{{"_type":"https://in-toto.io/Statement/v1","subject":[{{"name":"t-linux-x64.tar.xz","digest":{{"sha256":"{a}"}}}},{{"name":"t-skill.tar.gz","digest":{{"sha256":"{b}"}}}}],"predicateType":"https://packslip.dev/release/v1","predicate":{{"project":"github.com/o/r","version":"1.0.0","published_at":"2026-09-01T00:00:00Z","source":{{"repo":"https://github.com/o/r","commit":"{c}"}},"artifacts":[{{"name":"t-linux-x64.tar.xz","os":"linux","arch":"x86_64","libc":"gnu","size":5,"format":"tar.xz","bin":["t","u"]}}],"resources":{resources},"identity":{{"scheme":"sigstore-oidc","key_id":"https://github.com/o/r/.github/workflows/r.yml@refs/tags/v1","issuer":"https://token.actions.githubusercontent.com"}}}}}}"#,
a = "a".repeat(64),
b = "b".repeat(64),
c = "c".repeat(40),
);
let statement: Statement = serde_json::from_str(&json).unwrap();
statement.validate().unwrap();
statement
}
/// A statement whose second subject is accounted for by a skill asset.
fn basic() -> Statement {
statement_with(r#"[{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}]"#)
}
#[cfg(unix)]
#[tokio::test]
async fn resource_commands_have_an_environment_and_bounded_execution() {
let install = tempfile::tempdir().unwrap();
let env = [("COMPLETE".into(), "zsh".into())].into_iter().collect();
let args = vec![
"-c".into(),
"printf '%s\\n%s' \"$COMPLETE\" \"$PWD\"; printf ignored >&2".into(),
];
let output = run_resource_command(
Path::new("/bin/sh"),
&args,
&env,
install.path(),
std::time::Duration::from_secs(2),
)
.await
.unwrap();
assert!(output.starts_with("zsh\n"));
assert!(!output.contains("ignored"));
assert_ne!(
output.lines().nth(1).unwrap(),
std::env::current_dir().unwrap().to_str().unwrap()
);
for script in ["exit 0", "exit 1", "exec sleep 10"] {
assert!(
run_resource_command(
Path::new("/bin/sh"),
&["-c".into(), script.into()],
&env,
install.path(),
std::time::Duration::from_millis(100)
)
.await
.is_err()
);
}
}
#[test]
fn fetching_and_completing_use_the_same_artifact_scope() {
let s = statement_with(
r#"[
{"kind":"completion","bin":"t","shell":"zsh","archive":"generic/_t"},
{"kind":"completion","bin":"t","shell":"zsh","archive":"specific/_t","artifact":"t-linux-x64.tar.xz"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let selected = selected_resources(&s, Some(&s.predicate.artifacts[0]));
assert!(!selected.contains(&&s.predicate.resources[0]));
assert!(selected.contains(&&s.predicate.resources[1]));
let unknown = selected_resources(&s, None);
assert!(unknown.contains(&&s.predicate.resources[0]));
assert!(!unknown.contains(&&s.predicate.resources[1]));
}
#[test]
fn completion_exec_substitutes_environment_values() {
let s = statement_with(
r#"[{"kind":"completion","bin":"t","shells":["zsh"],"exec":["t"],"env":{"COMPLETE":"{shell}"}},{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}]"#,
);
let sources = completion_sources(
&s,
Path::new("/unused"),
"zsh",
Some(&s.predicate.artifacts[0]),
Some("t"),
);
assert_eq!(
sources,
vec![CompletionSource::Exec(
vec!["t".into()],
[("COMPLETE".into(), "zsh".into())].into_iter().collect()
)]
);
}
#[test]
fn completion_identity_separates_commands_and_caches() {
let root = tempfile::tempdir().unwrap();
file::write(root.path().join("_t"), "t").unwrap();
file::write(root.path().join("_u"), "u").unwrap();
let s = statement_with(
r#"[
{"kind":"completion","bin":"t","shell":"zsh","archive":"_t","os":"linux"},
{"kind":"completion","bin":"u","shell":"zsh","archive":"_u"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let artifact = &s.predicate.artifacts[0];
assert_eq!(
completion_sources(&s, root.path(), "zsh", Some(artifact), Some("u")),
vec![CompletionSource::File(root.path().join("_u"))]
);
assert_ne!(
completion_cache_path(root.path(), "t", "zsh").unwrap(),
completion_cache_path(root.path(), "u", "zsh").unwrap()
);
assert_eq!(
completion_cache_path(root.path(), "t.exe", "zsh").unwrap(),
completion_cache_path(root.path(), "t", "zsh").unwrap()
);
assert!(completion_cache_path(root.path(), "../t", "zsh").is_err());
}
#[test]
fn statement_is_read_back_and_validated() {
let dir = tempfile::tempdir().unwrap();
assert!(statement(dir.path()).unwrap().is_none());
let s = basic();
file::write(
dir.path().join(STATEMENT_FILE),
serde_json::to_string(&s).unwrap(),
)
.unwrap();
assert_eq!(statement(dir.path()).unwrap(), Some(s));
file::write(dir.path().join(STATEMENT_FILE), "{}").unwrap();
assert!(statement(dir.path()).is_err());
}
#[test]
fn repo_file_requests_pin_the_commit() {
let s = basic();
assert_eq!(
repo_file_request(&s, "docs/a?b#c.md").unwrap().0,
format!(
"https://api.github.com/repos/o/r/contents/docs/a%3Fb%23c.md?ref={}",
"c".repeat(40)
),
"a name cannot rewrite the query or fragment"
);
let (url, headers) = repo_file_request(&s, "completions/t.fish").unwrap();
assert_eq!(
url,
format!(
"https://api.github.com/repos/o/r/contents/completions/t.fish?ref={}",
"c".repeat(40)
)
);
assert_eq!(
headers.get(reqwest::header::ACCEPT).unwrap(),
"application/vnd.github.raw+json"
);
let mut gitlab = s.clone();
gitlab.predicate.source.as_mut().unwrap().repo = "https://gitlab.com/g/p.git".into();
assert_eq!(
repo_file_request(&gitlab, "x").unwrap().0,
format!("https://gitlab.com/g/p/-/raw/{}/x", "c".repeat(40))
);
let mut other = s.clone();
other.predicate.source.as_mut().unwrap().repo = "https://example.com/r".into();
assert!(repo_file_request(&other, "x").is_none());
let mut no_commit = s;
no_commit.predicate.source.as_mut().unwrap().commit = None;
assert!(repo_file_request(&no_commit, "x").is_none());
}
#[test]
fn vendor_paths_never_leave_the_install() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let outside = root.join("outside");
std::fs::write(&outside, "").unwrap();
let mut s = statement_with(
r#"[{"kind":"completion","bin":"t","shell":"zsh","asset":"t-skill.tar.gz"},
{"kind":"man","bin":"t","repo":"man/t.1"}]"#,
);
// Tamper after validation, as a hostile file on disk could.
s.predicate.resources[0].asset = Some("../outside".into());
s.predicate.resources[1].repo = Some("/etc/passwd".into());
for r in &s.predicate.resources {
assert_eq!(resource_path(root, r), None, "{r:?}");
}
assert!(outside.exists());
}
#[test]
fn man_pages_are_normalized_under_one_manpath_root() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for (rel, contents) in [
("share/docs/t.1", "archive"),
(&format!("{RESOURCES_DIR}/repo/docs/t.1"), "repo"),
(&format!("{RESOURCES_DIR}/repo/docs/u.5.gz"), "compressed"),
(&format!("{RESOURCES_DIR}/repo/docs/u.3pm.gz"), "subsection"),
("share/docs/v.1", "generic"),
(&format!("{RESOURCES_DIR}/repo/docs/v.1"), "platform"),
(
&format!("{RESOURCES_DIR}/repo/docs/README"),
"not a man page",
),
] {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, contents).unwrap();
}
let s = statement_with(
r#"[
{"kind":"man","bin":"t","archive":"share/docs/t.1"},
{"kind":"man","bin":"t","repo":"docs/t.1"},
{"kind":"man","bin":"u","repo":"docs/u.5.gz"},
{"kind":"man","bin":"u","repo":"docs/u.3pm.gz"},
{"kind":"man","bin":"u","repo":"docs/README"},
{"kind":"man","bin":"u","archive":"share/docs/v.1"},
{"kind":"man","bin":"u","os":"linux","arch":"x86_64","repo":"docs/v.1"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
install_man_pages(root, &s, Some(&s.predicate.artifacts[0])).unwrap();
let manpath = manpath(root).unwrap();
assert_eq!(
std::fs::read_to_string(manpath.join("man1/t.1")).unwrap(),
"archive",
"the shipped page wins over its repository fallback"
);
assert_eq!(
std::fs::read_to_string(manpath.join("man5/u.5.gz")).unwrap(),
"compressed"
);
assert_eq!(
std::fs::read_to_string(manpath.join("man3/u.3pm.gz")).unwrap(),
"subsection"
);
assert_eq!(
std::fs::read_to_string(manpath.join("man1/v.1")).unwrap(),
"platform",
"resource selection keeps the most specific matching page"
);
assert!(!manpath.join("manREADME/README").exists());
}
#[test]
fn man_sections_accept_the_names_man_uses() {
assert_eq!(man_section("tool.1"), Some('1'));
assert_eq!(man_section("tool.3pm.gz"), Some('3'));
assert_eq!(man_section("tool.5.xz"), Some('5'));
assert_eq!(man_section("README"), None);
assert_eq!(man_section("tool.bad-section"), None);
}
#[test]
fn static_specs_follow_source_priority_then_document_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for rel in [
"first.kdl",
"second.kdl",
&format!("{RESOURCES_DIR}/repo/t.kdl"),
&format!("{RESOURCES_DIR}/assets/t-skill.tar.gz"),
] {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "name t").unwrap();
}
let s = statement_with(
r#"[
{"kind":"cli-spec","bin":"t","format":"usage","repo":"t.kdl"},
{"kind":"cli-spec","bin":"t","format":"usage","asset":"t-skill.tar.gz"},
{"kind":"cli-spec","bin":"t","format":"usage","archive":"first.kdl"},
{"kind":"cli-spec","bin":"t","format":"usage","archive":"second.kdl"}
]"#,
);
let paths: Vec<_> =
completion_sources(&s, root, "fish", Some(&s.predicate.artifacts[0]), Some("t"))
.into_iter()
.map(|source| match source {
CompletionSource::Spec { path, .. } => path,
other => panic!("unexpected source {other:?}"),
})
.collect();
assert_eq!(
paths,
[
"first.kdl",
"second.kdl",
&format!("{RESOURCES_DIR}/assets/t-skill.tar.gz"),
&format!("{RESOURCES_DIR}/repo/t.kdl"),
]
.map(|rel| root.join(rel)),
"the release's own archive first, the source repository last"
);
}
#[test]
fn a_declared_completion_is_not_an_absent_one() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let s = statement_with(
r#"[
{"kind":"completion","bin":"t","shell":"zsh","archive":"_t"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let host = s.predicate.artifacts[0].clone();
assert!(
completion_sources(&s, root, "zsh", Some(&host), Some("t")).is_empty(),
"the file it names was never fetched into the install"
);
assert!(
declares_completion(&s, "zsh"),
"so the failure is the fetch's, and must not be reported as the \
vendor declaring nothing"
);
assert!(!declares_completion(&s, "fish"));
let none = statement_with(r#"[{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}]"#);
assert!(!declares_completion(&none, "zsh"));
}
#[test]
fn an_unfinished_skill_directory_does_not_hide_the_source_below_it() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let s = statement_with(
r#"[
{"kind":"skill","name":"t","archive":"empty"},
{"kind":"skill","name":"t","repo":"skills/t"},
{"kind":"skill","name":"other","asset":"t-skill.tar.gz"}
]"#,
);
// What an interrupted unpack leaves: the directory, and no SKILL.md.
std::fs::create_dir_all(root.join("empty")).unwrap();
let fallback = root.join(RESOURCES_DIR).join("repo/skills/t");
std::fs::create_dir_all(&fallback).unwrap();
assert!(
skills_of(&s, root, "tool", "1", Some(&s.predicate.artifacts[0])).is_empty(),
"neither directory holds a skill yet"
);
std::fs::write(fallback.join("SKILL.md"), "# t").unwrap();
let skills = skills_of(&s, root, "tool", "1", Some(&s.predicate.artifacts[0]));
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].path, fallback);
}
#[test]
fn completion_sources_follow_the_spec_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("share")).unwrap();
std::fs::write(root.join("share/_t"), "#compdef t").unwrap();
std::fs::create_dir_all(root.join(RESOURCES_DIR).join("repo/completions")).unwrap();
std::fs::write(root.join(RESOURCES_DIR).join("repo/completions/t.zsh"), "").unwrap();
std::fs::write(root.join("t.kdl"), "").unwrap();
let s = statement_with(
r#"[
{"kind":"completion","bin":"t","shell":"zsh","exec":["t","completion","zsh"]},
{"kind":"completion","bin":"t","shells":["bash","zsh"],"exec":["t","completions","{shell}"]},
{"kind":"completion","bin":"t","shell":"zsh","repo":"completions/t.zsh"},
{"kind":"completion","bin":"t","shell":"zsh","asset":"t-skill.tar.gz"},
{"kind":"cli-spec","format":"usage","bin":"t","exec":["t","usage"]},
{"kind":"cli-spec","format":"usage","bin":"t","archive":"t.kdl"},
{"kind":"completion","bin":"t","shell":"fish","archive":"share/t.fish"},
{"kind":"completion","bin":"t","shell":"zsh","archive":"share/_t"}
]"#,
);
let host = s.predicate.artifacts[0].clone();
let sources = completion_sources(&s, root, "zsh", Some(&host), Some("t"));
assert_eq!(
sources,
vec![
CompletionSource::File(root.join("share/_t")),
CompletionSource::File(root.join(RESOURCES_DIR).join("repo/completions/t.zsh")),
CompletionSource::Spec {
format: "usage".into(),
bin: "t".into(),
path: root.join("t.kdl"),
},
CompletionSource::Exec(
vec!["t".into(), "completion".into(), "zsh".into()],
BTreeMap::new()
),
CompletionSource::Exec(
vec!["t".into(), "completions".into(), "zsh".into()],
BTreeMap::new()
),
CompletionSource::SpecExec {
format: "usage".into(),
bin: "t".into(),
argv: vec!["t".into(), "usage".into()],
env: BTreeMap::new(),
},
],
"shipped files first, an unfetched asset skipped, then the spec, then anything that runs the tool"
);
let fish = completion_sources(&s, root, "fish", Some(&host), Some("t"));
assert!(
matches!(fish.first(), Some(CompletionSource::Spec { .. })),
"the fish file is not in the archive, so the spec comes first: {fish:?}"
);
assert!(
completion_sources(&s, root, "nu", Some(&host), Some("t"))
.iter()
.all(|c| !matches!(c, CompletionSource::File(_) | CompletionSource::Exec(..)))
);
}
#[test]
fn the_spec_for_the_completed_executable_wins() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.kdl"), "").unwrap();
std::fs::write(root.join("b.kdl"), "").unwrap();
let s = statement_with(
r#"[
{"kind":"cli-spec","format":"usage","bin":"t","archive":"a.kdl"},
{"kind":"cli-spec","format":"usage","bin":"u","archive":"b.kdl"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let host = s.predicate.artifacts[0].clone();
let bins = |tool: Option<&str>| -> Vec<String> {
completion_sources(&s, root, "zsh", Some(&host), tool)
.into_iter()
.filter_map(|c| match c {
CompletionSource::Spec { bin, .. } => Some(bin),
_ => None,
})
.collect()
};
assert_eq!(bins(Some("u")), vec!["u"]);
assert_eq!(
bins(Some("u.exe")),
vec!["u"],
"the name as a Windows stub embeds it"
);
assert!(bins(None).is_empty());
assert_eq!(
bins(Some("github.com/o/r")),
Vec::<String>::new(),
"an ambiguous tool id must not complete an arbitrary executable"
);
}
#[test]
fn a_shipped_skill_never_shadows_a_scoped_one() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// The unscoped source is the one on disk, so it would win a race the
// fetch loop's "a higher source already has it" skip could start.
let shipped = root.join("share/skills/t");
std::fs::create_dir_all(&shipped).unwrap();
std::fs::write(shipped.join("SKILL.md"), "# shipped").unwrap();
let s = statement_with(
r#"[
{"kind":"skill","name":"t","archive":"top/share/skills/t"},
{"kind":"skill","name":"t","os":"linux","asset":"t-skill.tar.gz"}
]"#,
);
let linux = s.predicate.artifacts[0].clone();
// Fetching and reading agree because both narrow to the most specific
// entry per skill name first: the unscoped entry is not a "higher
// source" for the scoped one, it is a different platform's answer to
// the same question and is gone before either looks.
let selected: Vec<_> = selected_resources(&s, Some(&linux))
.into_iter()
.map(|r| r.asset.as_deref().or(r.archive.as_deref()))
.collect();
assert_eq!(selected, [Some("t-skill.tar.gz")]);
assert!(
skills_of(&s, root, "tool", "1", Some(&linux)).is_empty(),
"the scoped skill is the skill, and it is not on disk yet"
);
// With nothing scoped fitting, the shipped one applies as it always did.
let mut windows = linux.clone();
windows.os = Some("windows".into());
windows.libc = None;
assert_eq!(
skills_of(&s, root, "tool", "1", Some(&windows))
.iter()
.map(|s| s.path.clone())
.collect::<Vec<_>>(),
[shipped]
);
}
#[test]
fn specs_rank_by_specificity_within_one_identity() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for f in ["any.kdl", "linux.kdl", "any.json"] {
std::fs::write(root.join(f), "").unwrap();
}
let s = statement_with(
r#"[
{"kind":"cli-spec","format":"usage","bin":"t","archive":"any.kdl"},
{"kind":"cli-spec","format":"usage","bin":"t","os":"linux","archive":"linux.kdl"},
{"kind":"cli-spec","format":"clap","bin":"t","archive":"any.json"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let spec = |format: &str, path: &str| CompletionSource::Spec {
format: format.into(),
bin: "t".into(),
path: root.join(path),
};
let linux = s.predicate.artifacts[0].clone();
assert_eq!(
completion_sources(&s, root, "zsh", Some(&linux), Some("t")),
vec![spec("usage", "linux.kdl"), spec("clap", "any.json")],
"a scoped spec wins for its own format, and never hides another format"
);
let mut windows = linux.clone();
windows.os = Some("windows".into());
windows.libc = None;
assert_eq!(
completion_sources(&s, root, "zsh", Some(&windows), Some("t")),
vec![spec("usage", "any.kdl"), spec("clap", "any.json")],
"nothing scoped fits, so the unscoped spec applies"
);
}
#[test]
fn scoped_resources_follow_the_selected_artifact() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for f in ["_t.linux", "_t.any", "_t.mac"] {
std::fs::write(root.join(f), "").unwrap();
}
let s = statement_with(
r#"[
{"kind":"completion","bin":"t","shell":"zsh","archive":"_t.any"},
{"kind":"completion","bin":"t","shell":"zsh","os":"linux","archive":"_t.linux"},
{"kind":"completion","bin":"t","shell":"zsh","os":"darwin","archive":"_t.mac"},
{"kind":"skill","name":"t","asset":"t-skill.tar.gz"}
]"#,
);
let linux = s.predicate.artifacts[0].clone();
assert_eq!(
completion_sources(&s, root, "zsh", Some(&linux), Some("t")),
vec![CompletionSource::File(root.join("_t.linux"))],
"the most specific applicable entry wins"
);
let mut mac = linux.clone();
mac.os = Some("darwin".into());
mac.libc = None;
assert_eq!(
completion_sources(&s, root, "zsh", Some(&mac), Some("t")),
vec![CompletionSource::File(root.join("_t.mac"))]
);
let mut windows = mac.clone();
windows.os = Some("windows".into());
assert_eq!(
completion_sources(&s, root, "zsh", Some(&windows), Some("t")),
vec![CompletionSource::File(root.join("_t.any"))],
"nothing scoped fits, so the unscoped entry applies"
);
assert_eq!(
completion_sources(&s, root, "zsh", None, Some("t")),
vec![CompletionSource::File(root.join("_t.any"))],
"with no artifact selected only unscoped entries apply"
);
}
#[test]
fn installed_artifact_marker_controls_resource_scope() {
let dir = tempfile::tempdir().unwrap();
let mut statement = basic();
let mut musl = statement.predicate.artifacts[0].clone();
musl.name = "t-linux-x64-musl.tar.xz".into();
musl.libc = Some("musl".into());
statement.predicate.artifacts.push(musl.clone());
std::fs::write(
dir.path()
.join(crate::backend::packslip::SELECTED_ARTIFACT_FILE),
&musl.name,
)
.unwrap();
assert_eq!(
selected_artifact(&statement, dir.path(), None)
.unwrap()
.name,
musl.name
);
}
#[test]
fn skills_are_found_where_the_install_holds_them() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for rel in [
"share/skills/t",
"share/skills/here",
"share/skills/elsewhere",
&format!("{RESOURCES_DIR}/skills/packed"),
&format!("{RESOURCES_DIR}/repo/skills/fromrepo"),
] {
std::fs::create_dir_all(root.join(rel)).unwrap();
std::fs::write(root.join(rel).join("SKILL.md"), "# skill").unwrap();
}
let s = statement_with(
r#"[
{"kind":"skill","name":"t","archive":"top/share/skills/t"},
{"kind":"skill","name":"packed","asset":"t-skill.tar.gz"},
{"kind":"skill","name":"fromrepo","repo":"skills/fromrepo"},
{"kind":"skill","name":"t","repo":"skills/fromrepo"},
{"kind":"skill","name":"generated","exec":["t","skill"]},
{"kind":"skill","name":"missing","archive":"nowhere"},
{"kind":"skill","name":"here","os":"linux","archive":"top/share/skills/here"},
{"kind":"skill","name":"elsewhere","os":"windows","archive":"top/share/skills/elsewhere"}
]"#,
);
// A name that would leave the directory, as a tampered file could carry.
let mut s = s;
let mut escape = s.predicate.resources[0].clone();
escape.name = Some("../escape".into());
s.predicate.resources.push(escape);
let host = s.predicate.artifacts[0].clone();
let skills = skills_of(&s, root, "tool", "1", Some(&host));
assert_eq!(
skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
["t", "here", "packed", "fromrepo"],
"an exec skill not yet generated, a missing directory, and another platform's skill are absent; a fallback source for t is not a second t; a scoped skill hides none of the unscoped ones"
);
assert_eq!(
skills[0].path,
root.join("share/skills/t"),
"a stripped top dir"
);
assert_eq!(skills[0].tool, "tool");
assert_eq!(github_repo(&s).as_deref(), Some("o/r"));
}
#[test]
fn a_failed_unpack_leaves_nothing_behind() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("skill.tar.gz");
std::fs::write(&archive, b"not an archive").unwrap();
let target = dir.path().join("skills/t");
let pr = crate::ui::progress_report::QuietReport::new();
assert!(unpack_skill(&archive, &target, &pr).is_err());
assert!(!target.exists());
assert!(
!dir.path().join("skills/.t.partial").exists(),
"the staging directory is cleaned up"
);
assert!(
!dir.path().join("skills").exists()
|| std::fs::read_dir(dir.path().join("skills"))
.unwrap()
.next()
.is_none()
);
}
#[test]
fn sync_links_only_what_mise_made() {
let dir = tempfile::tempdir().unwrap();
let installs = dir.path().join("installs");
let v1 = installs.join("tool/1/skills/t");
let v2 = installs.join("tool/2/skills/t");
let other = installs.join("other/1/skills/o");
for p in [&v1, &v2, &other] {
std::fs::create_dir_all(p).unwrap();
}
let skill = |name: &str, tool: &str, version: &str, path: &Path| Skill {
name: name.into(),
tool: tool.into(),
version: version.into(),
path: path.to_path_buf(),
};
let target = dir.path().join("project/.claude/skills");
let report =
sync_skills(&target, &[skill("t", "tool", "1", &v1)], &installs, false).unwrap();
assert_eq!(report.linked, ["t"]);
assert!(file::is_symlink_to(&target.join("t"), &v1));
// Same again: nothing to do. A version switch: the link follows.
let report =
sync_skills(&target, &[skill("t", "tool", "1", &v1)], &installs, false).unwrap();
assert_eq!(report.unchanged, ["t"]);
let report =
sync_skills(&target, &[skill("t", "tool", "2", &v2)], &installs, false).unwrap();
assert_eq!(report.linked, ["t"]);
assert!(file::is_symlink_to(&target.join("t"), &v2));
// A real directory, or a link mise did not make, is left alone.
std::fs::create_dir_all(target.join("mine")).unwrap();
let elsewhere = dir.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
file::make_symlink(&elsewhere, &target.join("theirs")).unwrap();
let report = sync_skills(
&target,
&[
skill("mine", "tool", "2", &v2),
skill("theirs", "tool", "2", &v2),
skill("o", "other", "1", &other),
skill("o", "tool", "2", &v2),
],
&installs,
true,
)
.unwrap();
assert_eq!(report.linked, ["o"]);
assert_eq!(report.skipped.len(), 3, "{:?}", report.skipped);
assert!(target.join("mine").is_dir());
assert!(file::is_symlink_to(&target.join("theirs"), &elsewhere));
assert_eq!(
report.pruned,
["t"],
"no longer active, and a link mise made"
);
assert!(!target.join("t").is_symlink());
let state: serde_json::Value =
serde_json::from_str(&file::read_to_string(target.join(SYNC_STATE)).unwrap()).unwrap();
assert_eq!(
state["links"],
serde_json::json!({ "o": other.display().to_string() })
);
// A person who removes mise's link and makes their own at the same
// name, even into the installs directory, keeps it: the target is
// not the one mise recorded.
file::remove_all(target.join("o")).unwrap();
file::make_symlink(&v1, &target.join("o")).unwrap();
let report = sync_skills(
&target,
&[skill("o", "other", "1", &other)],
&installs,
true,
)
.unwrap();
assert!(file::is_symlink_to(&target.join("o"), &v1), "left alone");
assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped);
assert_eq!(report.pruned, Vec::<String>::new());
let state: serde_json::Value = serde_json::from_str(
&file::read_to_string(target.join(SYNC_STATE)).unwrap_or("{}".into()),
)
.unwrap();
assert!(
state["links"].get("o").is_none(),
"no longer mise's: {state}"
);
file::remove_all(target.join("o")).unwrap();
let report = sync_skills(
&target,
&[skill("o", "other", "1", &other)],
&installs,
false,
)
.unwrap();
assert_eq!(report.linked, ["o"]);
// A link a person pointed into mise's installs is not mise's to touch,
// even though its target says otherwise.
file::make_symlink(&v1, &target.join("handmade")).unwrap();
let report = sync_skills(
&target,
&[
skill("handmade", "tool", "2", &v2),
skill("o", "other", "1", &other),
],
&installs,
true,
)
.unwrap();
assert!(
file::is_symlink_to(&target.join("handmade"), &v1),
"left alone"
);
assert_eq!(report.pruned, Vec::<String>::new());
assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped);
// Even a person's link that already points at the wanted skill is
// not adopted: it is skipped, not recorded as mise's.
file::make_symlink(&other, &target.join("same")).unwrap();
let report = sync_skills(
&target,
&[skill("same", "other", "1", &other)],
&installs,
false,
)
.unwrap();
assert_eq!(report.unchanged, Vec::<String>::new());
assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped);
let state: serde_json::Value =
serde_json::from_str(&file::read_to_string(target.join(SYNC_STATE)).unwrap()).unwrap();
assert!(state["links"].get("same").is_none(), "{state}");
// A malformed state file is an error, never an empty set.
file::write(target.join(SYNC_STATE), "{not json").unwrap();
let err = sync_skills(
&target,
&[skill("o", "other", "1", &other)],
&installs,
false,
)
.unwrap_err();
assert!(err.to_string().contains("is not valid"), "{err}");
// Nothing to link creates nothing.
let empty = dir.path().join("empty");
let report = sync_skills(&empty, &[], &installs, false).unwrap();
assert_eq!(report, SyncReport::default());
assert!(!empty.exists());
}
#[test]
fn stubs_carry_the_installer_marker_and_defer_to_mise() {
use usage_rs::complete::Shell;
for shell in [Shell::Zsh, Shell::Bash, Shell::Fish, Shell::PowerShell] {
let stub = stub("rg", shell).unwrap();
assert!(stub.contains("@generated by usage"), "{stub}");
assert!(
stub.contains(&format!("mise completion {} --tool", shell.as_str())),
"{stub}"
);
assert!(stub.contains("'rg'"), "the stub names the tool: {stub}");
}
let zsh = stub("rg", Shell::Zsh).unwrap();
assert!(zsh.starts_with("#compdef rg\n"), "{zsh}");
assert!(
zsh.contains("__mise_load_rg() {"),
"the vendor's script runs in a function of its own: {zsh}"
);
let pwsh = stub("rg", Shell::PowerShell).unwrap();
assert!(pwsh.contains("if ($__mise_script)"), "{pwsh}");
assert!(
pwsh.contains("if ($global:__mise_load_rg_busy) { return }"),
"a script registering nothing must not recurse: {pwsh}"
);
assert!(
pwsh.matches("Register-ArgumentCompleter -Native -CommandName 'rg'")
.count()
== 2,
"registered once, and put back after delegating: {pwsh}"
);
let fish = stub("rg", Shell::Fish).unwrap();
assert!(
fish.contains("complete -c 'rg' -f -a '(__mise_load_rg)'"),
"asked for at completion time, not sourced at load: {fish}"
);
assert!(
fish.contains("set fish_complete_path"),
"the child cannot autoload this stub: {fish}"
);
assert!(
zsh.contains("compstate[nmatches]"),
"a script that completes on its own is not called again: {zsh}"
);
assert!(
zsh.contains("compdef _rg 'rg'"),
"put back after completing: {zsh}"
);
let bash = stub("cargo-nextest", Shell::Bash).unwrap();
assert!(
bash.contains("complete -F __mise_complete_cargo_2dnextest 'cargo-nextest'"),
"{bash}"
);
assert!(
bash.contains("return 124"),
"non-function registrations: {bash}"
);
assert!(
bash.contains("__mise_complete_cargo_2dnextest_restub"),
"the stub comes back at the next prompt: {bash}"
);
assert!(stub("rg", Shell::Nu).is_err());
}
}