use crate::error::{MinoError, MinoResult};
use crate::layer::manifest::LayerManifest;
use std::path::{Path, PathBuf};
const BUILTIN_RUST_MANIFEST: &str = include_str!("../../images/rust/layer.toml");
const BUILTIN_RUST_INSTALL: &str = include_str!("../../images/rust/install.sh");
const BUILTIN_TS_MANIFEST: &str = include_str!("../../images/typescript/layer.toml");
const BUILTIN_TS_INSTALL: &str = include_str!("../../images/typescript/install.sh");
const BUILTIN_PYTHON_MANIFEST: &str = include_str!("../../images/python/layer.toml");
const BUILTIN_PYTHON_INSTALL: &str = include_str!("../../images/python/install.sh");
#[derive(Debug)]
pub struct ResolvedLayer {
pub manifest: LayerManifest,
pub install_script: LayerScript,
pub source: LayerSource,
}
#[derive(Debug)]
pub enum LayerScript {
Path(PathBuf),
Embedded(&'static str),
None,
}
impl LayerScript {
pub async fn content(&self) -> MinoResult<String> {
match self {
Self::Path(path) => tokio::fs::read_to_string(path).await.map_err(|e| {
MinoError::io(format!("reading install script {}", path.display()), e)
}),
Self::Embedded(content) => Ok((*content).to_string()),
Self::None => Ok(String::new()),
}
}
pub fn has_content(&self) -> bool {
!matches!(self, Self::None)
}
}
#[derive(Debug, Clone)]
pub struct AvailableLayer {
pub name: String,
pub description: String,
pub source: LayerSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LayerSource {
ProjectLocal,
UserGlobal,
BuiltIn,
}
pub async fn resolve_layers(
names: &[String],
project_dir: &Path,
) -> MinoResult<Vec<ResolvedLayer>> {
let mut resolved = Vec::with_capacity(names.len());
for name in names {
let layer = resolve_single(name, project_dir).await?;
resolved.push(layer);
}
Ok(resolved)
}
fn validate_layer_name(name: &str) -> MinoResult<()> {
if name.is_empty() {
return Err(MinoError::User("Layer name cannot be empty".to_string()));
}
if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
return Err(MinoError::User(format!(
"Invalid layer name '{}': must not contain path separators or '..'",
name
)));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(MinoError::User(format!(
"Invalid layer name '{}': must contain only alphanumeric characters, hyphens, or underscores",
name
)));
}
Ok(())
}
async fn resolve_single(name: &str, project_dir: &Path) -> MinoResult<ResolvedLayer> {
validate_layer_name(name)?;
let project_layer_dir = project_dir.join(".mino").join("layers").join(name);
let global_layer_dir = dirs::config_dir().map(|d| d.join("mino").join("layers").join(name));
if let Some(layer) = try_resolve_from_dir(&project_layer_dir, LayerSource::ProjectLocal).await?
{
return Ok(layer);
}
if let Some(ref dir) = global_layer_dir {
if let Some(layer) = try_resolve_from_dir(dir, LayerSource::UserGlobal).await? {
return Ok(layer);
}
}
if let Some(layer) = resolve_builtin(name)? {
return Ok(layer);
}
let mut searched = vec![project_layer_dir.display().to_string()];
if let Some(ref dir) = global_layer_dir {
searched.push(dir.display().to_string());
}
searched.push("built-in layers".to_string());
Err(MinoError::LayerNotFound {
name: name.to_string(),
searched: searched.join(", "),
})
}
async fn try_resolve_from_dir(
dir: &Path,
source: LayerSource,
) -> MinoResult<Option<ResolvedLayer>> {
let manifest_path = dir.join("layer.toml");
let script_path = dir.join("install.sh");
if !manifest_path.exists() {
return Ok(None);
}
let manifest = LayerManifest::from_file(&manifest_path).await?;
manifest.user_install.validate()?;
manifest.root_install.validate()?;
let install_script = if script_path.exists() {
LayerScript::Path(script_path)
} else if !manifest.user_install.is_empty() {
LayerScript::None
} else {
return Err(MinoError::LayerScriptMissing(
script_path.display().to_string(),
));
};
Ok(Some(ResolvedLayer {
manifest,
install_script,
source,
}))
}
fn resolve_builtin(name: &str) -> MinoResult<Option<ResolvedLayer>> {
let (manifest_str, install_str) = match name {
"rust" | "cargo" => (BUILTIN_RUST_MANIFEST, BUILTIN_RUST_INSTALL),
"typescript" | "ts" | "node" => (BUILTIN_TS_MANIFEST, BUILTIN_TS_INSTALL),
"python" | "py" => (BUILTIN_PYTHON_MANIFEST, BUILTIN_PYTHON_INSTALL),
_ => return Ok(None),
};
let manifest = LayerManifest::parse(manifest_str)?;
manifest.user_install.validate()?;
manifest.root_install.validate()?;
let install_script = if install_str.trim().is_empty()
|| install_str
.lines()
.all(|l| l.trim().is_empty() || l.starts_with('#'))
{
LayerScript::None
} else {
LayerScript::Embedded(install_str)
};
Ok(Some(ResolvedLayer {
manifest,
install_script,
source: LayerSource::BuiltIn,
}))
}
pub async fn list_available_layers(project_dir: &Path) -> MinoResult<Vec<AvailableLayer>> {
let mut seen = std::collections::HashSet::new();
let mut layers = Vec::new();
let project_layers_dir = project_dir.join(".mino").join("layers");
scan_layer_dir(
&project_layers_dir,
LayerSource::ProjectLocal,
&mut seen,
&mut layers,
)
.await;
if let Some(global_dir) = dirs::config_dir().map(|d| d.join("mino").join("layers")) {
scan_layer_dir(&global_dir, LayerSource::UserGlobal, &mut seen, &mut layers).await;
}
for (name, manifest_str) in &[
("typescript", BUILTIN_TS_MANIFEST),
("rust", BUILTIN_RUST_MANIFEST),
("python", BUILTIN_PYTHON_MANIFEST),
] {
if seen.contains(*name) {
continue;
}
if let Ok(manifest) = LayerManifest::parse(manifest_str) {
seen.insert(name.to_string());
layers.push(AvailableLayer {
name: manifest.layer.name.clone(),
description: manifest.layer.description.clone(),
source: LayerSource::BuiltIn,
});
}
}
Ok(layers)
}
async fn scan_layer_dir(
dir: &Path,
source: LayerSource,
seen: &mut std::collections::HashSet<String>,
layers: &mut Vec<AvailableLayer>,
) {
let entries = match tokio::fs::read_dir(dir).await {
Ok(e) => e,
Err(_) => return, };
let mut entries = entries;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("layer.toml");
if !manifest_path.exists() {
continue;
}
if let Ok(manifest) = LayerManifest::from_file(&manifest_path).await {
let name = manifest.layer.name.clone();
if seen.contains(&name) {
continue;
}
seen.insert(name.clone());
layers.push(AvailableLayer {
name,
description: manifest.layer.description.clone(),
source: source.clone(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn resolve_builtin_rust() {
let layer = resolve_builtin("rust").unwrap().unwrap();
assert_eq!(layer.manifest.layer.name, "rust");
assert!(matches!(layer.source, LayerSource::BuiltIn));
assert!(matches!(layer.install_script, LayerScript::None));
assert!(layer.manifest.has_user_install());
}
#[test]
fn resolve_builtin_typescript() {
let layer = resolve_builtin("typescript").unwrap().unwrap();
assert_eq!(layer.manifest.layer.name, "typescript");
}
#[test]
fn resolve_builtin_aliases() {
assert!(resolve_builtin("cargo").unwrap().is_some());
assert!(resolve_builtin("ts").unwrap().is_some());
assert!(resolve_builtin("node").unwrap().is_some());
}
#[test]
fn resolve_builtin_unknown() {
assert!(resolve_builtin("java").unwrap().is_none());
}
#[tokio::test]
async fn resolve_project_local_layer() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("custom");
std::fs::create_dir_all(&layer_dir).unwrap();
let manifest = r#"
[layer]
name = "custom"
description = "Custom layer"
version = "1"
[env]
MY_VAR = "/custom/path"
"#;
std::fs::write(layer_dir.join("layer.toml"), manifest).unwrap();
std::fs::write(layer_dir.join("install.sh"), "#!/bin/bash\necho ok").unwrap();
let layers = resolve_layers(&["custom".to_string()], temp.path())
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].manifest.layer.name, "custom");
assert_eq!(layers[0].source, LayerSource::ProjectLocal);
assert!(matches!(layers[0].install_script, LayerScript::Path(_)));
}
#[tokio::test]
async fn resolve_missing_layer_errors() {
let temp = TempDir::new().unwrap();
let result = resolve_layers(&["nonexistent".to_string()], temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("not found"));
}
#[tokio::test]
async fn project_local_overrides_builtin() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("rust");
std::fs::create_dir_all(&layer_dir).unwrap();
let manifest = r#"
[layer]
name = "rust"
description = "Custom Rust"
version = "99"
"#;
std::fs::write(layer_dir.join("layer.toml"), manifest).unwrap();
std::fs::write(layer_dir.join("install.sh"), "#!/bin/bash\necho custom").unwrap();
let layers = resolve_layers(&["rust".to_string()], temp.path())
.await
.unwrap();
assert_eq!(layers[0].manifest.layer.version, "99");
assert_eq!(layers[0].source, LayerSource::ProjectLocal);
}
#[tokio::test]
async fn embedded_script_content() {
let layer = resolve_builtin("python").unwrap().unwrap();
let content = layer.install_script.content().await.unwrap();
assert!(content.contains("python3"));
assert!(content.contains("dnf"));
}
#[test]
fn resolve_builtin_rust_user_install() {
let layer = resolve_builtin("rust").unwrap().unwrap();
assert_eq!(
layer.manifest.user_install.runtime.as_deref(),
Some("rustup")
);
assert!(layer
.manifest
.user_install
.cargo_tools
.contains(&"sccache".to_string()));
}
#[test]
fn resolve_builtin_typescript_user_install() {
let layer = resolve_builtin("typescript").unwrap().unwrap();
assert!(matches!(layer.install_script, LayerScript::None));
assert_eq!(layer.manifest.user_install.runtime.as_deref(), Some("nvm"));
assert!(layer
.manifest
.user_install
.npm_globals
.contains(&"pnpm".to_string()));
}
#[test]
fn validate_layer_name_rejects_traversal() {
assert!(validate_layer_name("../etc").is_err());
assert!(validate_layer_name("foo/bar").is_err());
assert!(validate_layer_name("foo\\bar").is_err());
assert!(validate_layer_name("..").is_err());
}
#[test]
fn validate_layer_name_rejects_empty() {
assert!(validate_layer_name("").is_err());
}
#[test]
fn validate_layer_name_rejects_special_chars() {
assert!(validate_layer_name("rust!").is_err());
assert!(validate_layer_name("hello world").is_err());
assert!(validate_layer_name("layer.name").is_err());
}
#[test]
fn validate_layer_name_accepts_valid() {
assert!(validate_layer_name("rust").is_ok());
assert!(validate_layer_name("typescript").is_ok());
assert!(validate_layer_name("my-layer").is_ok());
assert!(validate_layer_name("my_layer_v2").is_ok());
}
#[tokio::test]
async fn resolve_rejects_traversal_name() {
let temp = TempDir::new().unwrap();
let result = resolve_layers(&["../evil".to_string()], temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid layer name"));
}
#[tokio::test]
async fn list_available_includes_builtins() {
let temp = TempDir::new().unwrap();
let layers = list_available_layers(temp.path()).await.unwrap();
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
assert!(names.contains(&"typescript"));
assert!(names.contains(&"rust"));
assert!(names.contains(&"python"));
assert!(layers.iter().all(|l| l.source == LayerSource::BuiltIn));
}
#[tokio::test]
async fn list_available_includes_project_local() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("python");
std::fs::create_dir_all(&layer_dir).unwrap();
std::fs::write(
layer_dir.join("layer.toml"),
"[layer]\nname = \"python\"\ndescription = \"Python 3\"\nversion = \"1\"\n",
)
.unwrap();
std::fs::write(layer_dir.join("install.sh"), "#!/bin/bash\necho ok").unwrap();
let layers = list_available_layers(temp.path()).await.unwrap();
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
assert!(names.contains(&"python"));
}
#[test]
fn resolve_builtin_python() {
let layer = resolve_builtin("python").unwrap().unwrap();
assert_eq!(layer.manifest.layer.name, "python");
assert!(matches!(layer.source, LayerSource::BuiltIn));
assert!(matches!(layer.install_script, LayerScript::Embedded(_)));
}
#[test]
fn resolve_builtin_python_alias() {
let layer = resolve_builtin("py").unwrap().unwrap();
assert_eq!(layer.manifest.layer.name, "python");
}
#[tokio::test]
async fn embedded_python_script_content() {
let layer = resolve_builtin("python").unwrap().unwrap();
let content = layer.install_script.content().await.unwrap();
assert!(content.contains("python3"));
assert!(content.contains("dnf"));
assert!(layer.manifest.has_user_install());
assert_eq!(layer.manifest.user_install.runtime.as_deref(), Some("uv"));
assert!(layer
.manifest
.user_install
.uv_tools
.contains(&"ruff".to_string()));
}
#[tokio::test]
async fn resolve_user_install_only_layer_no_script() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("mytools");
std::fs::create_dir_all(&layer_dir).unwrap();
let manifest = r#"
[layer]
name = "mytools"
description = "User install only"
version = "1"
[user_install]
runtime = "nvm"
runtime_version = "22"
npm_globals = ["pnpm"]
"#;
std::fs::write(layer_dir.join("layer.toml"), manifest).unwrap();
let layers = resolve_layers(&["mytools".to_string()], temp.path())
.await
.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].manifest.layer.name, "mytools");
assert!(matches!(layers[0].install_script, LayerScript::None));
assert!(layers[0].manifest.has_user_install());
}
#[tokio::test]
async fn resolve_missing_script_still_errors_without_user_install() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("broken");
std::fs::create_dir_all(&layer_dir).unwrap();
let manifest = r#"
[layer]
name = "broken"
description = "Broken layer"
version = "1"
"#;
std::fs::write(layer_dir.join("layer.toml"), manifest).unwrap();
let result = resolve_layers(&["broken".to_string()], temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("install script missing"));
}
#[test]
fn layer_script_none_has_no_content() {
let rt = tokio::runtime::Runtime::new().unwrap();
let content = rt.block_on(LayerScript::None.content()).unwrap();
assert!(content.is_empty());
assert!(!LayerScript::None.has_content());
}
#[tokio::test]
async fn list_available_deduplicates_by_name() {
let temp = TempDir::new().unwrap();
let layer_dir = temp.path().join(".mino").join("layers").join("rust");
std::fs::create_dir_all(&layer_dir).unwrap();
std::fs::write(
layer_dir.join("layer.toml"),
"[layer]\nname = \"rust\"\ndescription = \"Custom Rust\"\nversion = \"99\"\n",
)
.unwrap();
std::fs::write(layer_dir.join("install.sh"), "#!/bin/bash\necho ok").unwrap();
let layers = list_available_layers(temp.path()).await.unwrap();
let rust_layers: Vec<&AvailableLayer> =
layers.iter().filter(|l| l.name == "rust").collect();
assert_eq!(rust_layers.len(), 1);
assert_eq!(rust_layers[0].source, LayerSource::ProjectLocal);
assert_eq!(rust_layers[0].description, "Custom Rust");
}
}