use microsandbox::protocol::exec::ExecFailureKind;
use microsandbox::sandbox::ExecOutput;
use microsandbox::{MicrosandboxError, Sandbox};
use std::collections::BTreeMap;
use std::time::Duration;
use thiserror::Error;
pub const ENABLED_LABEL: &str = "firebrick.mise";
const CONFIG_FILES: [&str; 5] = [
"mise.toml",
".mise.toml",
"mise/config.toml",
".config/mise.toml",
".tool-versions",
];
const INSTALL_TIMEOUT: Duration = Duration::from_secs(30 * 60);
const TRUST_TIMEOUT: Duration = Duration::from_secs(60);
const STDERR_TAIL_LINES: usize = 10;
#[derive(Error, Debug)]
pub enum MiseError {
#[error("mise install failed in sandbox {sandbox}: {output}")]
Failed {
sandbox: String,
output: String,
},
#[error("the image of sandbox {0} has no mise executable")]
NotInstalled(String),
#[error("failed to run mise in sandbox {sandbox}")]
Exec {
sandbox: String,
#[source]
source: MicrosandboxError,
},
}
pub fn label_value(enabled: bool) -> &'static str {
if enabled { "true" } else { "false" }
}
pub fn is_enabled(labels: &BTreeMap<String, String>) -> bool {
labels.get(ENABLED_LABEL).map(String::as_str) != Some(label_value(false))
}
fn config_paths(workspace: &str) -> Vec<String> {
let workspace = workspace.trim_end_matches('/');
CONFIG_FILES
.iter()
.map(|file| format!("{workspace}/{file}"))
.collect()
}
fn tail(output: &str, lines: usize) -> String {
let all: Vec<&str> = output
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
all[all.len().saturating_sub(lines)..].join("\n")
}
pub async fn install_tools(sb: &Sandbox, workspace: &str) -> Result<(), MiseError> {
let configs = existing_configs(sb, workspace).await?;
if configs.is_empty() {
return Ok(());
}
for config in &configs {
run(sb, workspace, &["trust", config], TRUST_TIMEOUT).await?;
}
run(sb, workspace, &["install", "--yes"], INSTALL_TIMEOUT).await
}
async fn existing_configs(sb: &Sandbox, workspace: &str) -> Result<Vec<String>, MiseError> {
let mut configs = vec![];
for path in config_paths(workspace) {
let exists = sb
.fs()
.exists(&path)
.await
.map_err(|source| exec_error(sb, source))?;
if exists {
configs.push(path);
}
}
Ok(configs)
}
async fn run(
sb: &Sandbox,
workspace: &str,
args: &[&str],
timeout: Duration,
) -> Result<(), MiseError> {
let result = sb
.exec_with("mise", |e| {
e.args(args.iter().copied()).cwd(workspace).timeout(timeout)
})
.await;
match result {
Ok(output) if output.status().success => Ok(()),
Ok(output) => Err(failed(sb, failure_output(&output))),
Err(MicrosandboxError::ExecTimeout(timeout)) => Err(failed(
sb,
format!("mise {} timed out after {timeout:?}", args[0]),
)),
Err(MicrosandboxError::ExecFailed(failure))
if failure.kind == ExecFailureKind::NotFound =>
{
Err(MiseError::NotInstalled(sb.name().to_string()))
}
Err(source) => Err(exec_error(sb, source)),
}
}
fn failure_output(output: &ExecOutput) -> String {
let stderr = tail(
&String::from_utf8_lossy(output.stderr_bytes()),
STDERR_TAIL_LINES,
);
if stderr.is_empty() {
format!("mise exited with code {}", output.status().code)
} else {
stderr
}
}
fn failed(sb: &Sandbox, output: String) -> MiseError {
MiseError::Failed {
sandbox: sb.name().to_string(),
output,
}
}
fn exec_error(sb: &Sandbox, source: MicrosandboxError) -> MiseError {
MiseError::Exec {
sandbox: sb.name().to_string(),
source,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_paths_lists_root_config_files() {
assert_eq!(
config_paths("/workspaces/demo/"),
[
"/workspaces/demo/mise.toml",
"/workspaces/demo/.mise.toml",
"/workspaces/demo/mise/config.toml",
"/workspaces/demo/.config/mise.toml",
"/workspaces/demo/.tool-versions",
]
);
}
#[test]
fn is_enabled_defaults_to_true() {
assert!(is_enabled(&BTreeMap::new()));
}
#[test]
fn is_enabled_reads_the_label() {
for enabled in [true, false] {
let labels = BTreeMap::from([(ENABLED_LABEL.to_string(), label_value(enabled).into())]);
assert_eq!(is_enabled(&labels), enabled);
}
}
#[test]
fn tail_keeps_the_last_non_empty_lines() {
assert_eq!(tail("a\nb\n\nc\nd\n", 3), "b\nc\nd");
assert_eq!(tail("only\n", 3), "only");
assert_eq!(tail("", 3), "");
}
#[test]
fn failed_error_names_the_sandbox_and_output() {
let error = MiseError::Failed {
sandbox: "demo".into(),
output: "mise ERROR no such version".into(),
};
assert_eq!(
error.to_string(),
"mise install failed in sandbox demo: mise ERROR no such version"
);
}
}