pub(crate) mod hook_env;
pub(crate) mod ports;
pub(crate) mod presets;
pub(crate) mod prune;
pub(crate) mod runtime;
pub(crate) mod tasks;
pub(crate) mod urls;
use crate::config::config_file::ConfigFile;
use crate::config::env_directive::EnvDirective;
use crate::config::{Config, ConfigMap, Settings};
use crate::toolset::{ToolRequest, ToolRequestSet, ToolSource};
use eyre::{Result, bail};
use indexmap::IndexMap;
use path_absolutize::Absolutize;
use ports::{PortClaim, PortRequest};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub(crate) const EXPERIMENTAL: &str = "[daemons] requires experimental = true";
pub(crate) const DAEMON_TASK_MARKER: &str = "MISE_DAEMON_TASK";
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub(crate) enum Declaration {
Preset(String),
Definition(toml::Table),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DaemonSettings {
#[serde(default)]
pub namespace: Option<String>,
#[serde(default)]
pub namespace_per_worktree: Option<bool>,
}
impl DaemonSettings {
fn merge(&mut self, other: Self) {
if other.namespace.is_some() {
self.namespace = other.namespace;
}
if other.namespace_per_worktree.is_some() {
self.namespace_per_worktree = other.namespace_per_worktree;
}
}
pub(crate) fn namespace_per_worktree(&self) -> bool {
self.namespace_per_worktree.unwrap_or(true)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GroupTable {
daemons: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub(crate) enum GroupDeclaration {
List(Vec<String>),
Table(GroupTable),
}
impl GroupDeclaration {
fn members(&self) -> &[String] {
match self {
GroupDeclaration::List(members) => members,
GroupDeclaration::Table(table) => &table.daemons,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct Group {
pub name: String,
pub source: PathBuf,
pub root: PathBuf,
pub members: Vec<String>,
pub daemons: Vec<String>,
}
pub(crate) enum BareName<'a> {
Daemon,
Group,
Import(&'a str),
Unresolved(&'a str),
}
#[derive(Debug, Clone)]
pub(crate) struct Daemon {
pub name: String,
pub source: PathBuf,
pub root: PathBuf,
pub table: toml::Table,
pub preset: Option<String>,
pub data_dir: Option<PathBuf>,
pub task: Option<String>,
pub tool: Option<(String, String)>,
pub exports: IndexMap<String, String>,
pub imported: bool,
pub port: Option<PortClaim>,
pub host: Option<String>,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct DaemonSet {
pub daemons: IndexMap<String, Daemon>,
pub namespaces: IndexMap<PathBuf, String>,
pub aliases: IndexMap<(PathBuf, String), String>,
pub blocked: IndexMap<String, (String, String)>,
pub import_errors: IndexMap<(PathBuf, String), String>,
pub groups: Vec<Group>,
pub labels: IndexMap<PathBuf, urls::RootLabels>,
}
impl Daemon {
fn withdraw_host(&mut self) {
let Some(host) = self.host.take() else {
return;
};
self.exports.retain(|_, value| !value.contains(&host));
urls::withdraw(&mut self.table);
}
}
fn validate_name(kind: &str, name: &str) -> Result<()> {
if name.is_empty()
|| name == "."
|| name.contains("..")
|| name.contains("--")
|| name.starts_with('-')
|| name.ends_with('-')
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
bail!("invalid {kind} name {name:?}; use letters, numbers, '.', '_' or '-'");
}
Ok(())
}
pub(crate) fn validate_namespace(namespace: &str) -> Result<()> {
if validate_name("daemon", namespace).is_err() {
bail!("invalid daemon namespace {namespace:?}; use letters, numbers, '.', '_' or '-'");
}
Ok(())
}
pub(crate) fn state_dir(root: &Path) -> PathBuf {
crate::dirs::STATE
.join("daemons")
.join(crate::hash::hash_to_str(
&root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
))
}
pub(crate) fn lock_file_for_state_dir(dir: &Path) -> PathBuf {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
dir.with_file_name(format!("{name}.lock"))
}
pub(crate) fn legacy_lock_file_for_state_dir(dir: &Path) -> PathBuf {
dir.join("project.lock")
}
pub(crate) struct ProjectLock {
_sibling: fslock::LockFile,
_legacy: fslock::LockFile,
}
impl ProjectLock {
pub(crate) fn acquire(root: &Path) -> Result<Self> {
let dir = state_dir(root);
Ok(Self {
_sibling: crate::lock_file::LockFile::at(&lock_file_for_state_dir(&dir)).lock()?,
_legacy: crate::lock_file::LockFile::at(&legacy_lock_file_for_state_dir(&dir))
.lock()?,
})
}
pub(crate) fn try_acquire(dir: &Path) -> Result<Option<Self>> {
let Some(sibling) =
crate::lock_file::LockFile::at(&lock_file_for_state_dir(dir)).try_lock()?
else {
return Ok(None);
};
let Some(legacy) =
crate::lock_file::LockFile::at(&legacy_lock_file_for_state_dir(dir)).try_lock()?
else {
return Ok(None);
};
Ok(Some(Self {
_sibling: sibling,
_legacy: legacy,
}))
}
}
fn routed_claimant(imported: &[bool]) -> Option<usize> {
let mut found = imported.iter().enumerate().filter(|(_, i)| **i);
match (found.next(), found.next()) {
(Some((only, _)), None) => Some(only),
_ => None,
}
}
pub(crate) fn load(files: &ConfigMap) -> Result<DaemonSet> {
let mut declarations = IndexMap::new();
let mut settings: IndexMap<PathBuf, DaemonSettings> = IndexMap::new();
let mut group_declarations = IndexMap::new();
let mut declared_in: IndexMap<String, Vec<PathBuf>> = IndexMap::new();
for cf in files.values().rev() {
let entries = cf.daemon_declarations();
let file_settings = cf.daemon_settings();
let groups = cf.daemon_group_declarations();
if entries.is_empty() && groups.is_empty() && file_settings.is_none() {
continue;
}
if !Settings::get().experimental {
if !entries.is_empty() || !groups.is_empty() {
warn_once!("{EXPERIMENTAL}; ignoring daemon declarations");
}
continue;
}
if Settings::safe_mode() && !crate::config::is_global_config(cf.get_path()) {
continue;
}
let source = cf.get_path().to_path_buf();
let project_root = cf.project_root();
let root = project_root.clone().unwrap_or_else(|| cf.config_root());
if let Some(file_settings) = file_settings {
match &project_root {
None => warn_once!(
"[daemons_settings] in {} is ignored; it belongs in a project configuration",
crate::file::display_path(cf.get_path())
),
Some(project_root) => settings
.entry(project_root.clone())
.or_default()
.merge(file_settings),
}
}
for (name, declaration) in entries {
let roots = declared_in.entry(name.clone()).or_default();
if !roots.contains(&root) {
roots.push(root.clone());
}
declarations.insert(name, (declaration, source.clone(), root.clone()));
}
for (name, declaration) in groups {
group_declarations.insert(
(root.clone(), name),
(declaration, source.clone(), root.clone()),
);
}
}
let mut set = DaemonSet::default();
let mut imported_ids: IndexMap<(PathBuf, String), String> = IndexMap::new();
let mut state = LoadState::default();
for (name, (declaration, source, root)) in declarations {
validate_name("daemon", &name)?;
if let Declaration::Definition(table) = &declaration
&& table.contains_key("project")
{
let spec = parse_import(&name, table.clone())?;
let (key, daemon) = match import(
&name,
&spec,
&source,
&root,
&mut set.namespaces,
&mut state,
) {
Ok(imported) => imported,
Err(err) => {
debug!("[daemons.{name}] import failed: {err:#}");
set.import_errors
.insert((root.clone(), name), format!("{err:#}"));
continue;
}
};
if let Some(existing) = set.daemons.get(&key)
&& existing.root != daemon.root
{
bail!(
"daemon {key} is imported from both {} and {}; they share a namespace, so one of those projects needs its own [daemons_settings] namespace",
existing.root.display(),
daemon.root.display()
);
}
imported_ids.insert((root.clone(), name.clone()), key.clone());
set.aliases.insert((root.clone(), name), key.clone());
set.daemons.insert(key, daemon);
continue;
}
let settings = settings_for(&settings, &root);
set.daemons.insert(
name.clone(),
build(
&name,
declaration,
source,
root,
&settings,
&mut state,
false,
)?,
);
}
let mut canonical: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
let mut canonical_root = |root: &Path| -> PathBuf {
canonical
.entry(root.to_path_buf())
.or_insert_with(|| root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
.clone()
};
let mut claimants: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut seen: BTreeMap<String, std::collections::BTreeSet<(PathBuf, String)>> = BTreeMap::new();
for (key, daemon) in set.daemons.iter() {
let Some(host) = daemon.host.clone() else {
continue;
};
let ident = (canonical_root(&daemon.root), daemon.name.clone());
if seen.entry(host.clone()).or_default().insert(ident) {
claimants.entry(host).or_default().push(key.clone());
}
}
for (host, keys) in &claimants {
if keys.len() < 2 {
continue;
}
let imported: Vec<bool> = keys.iter().map(|k| set.daemons[k].imported).collect();
let where_each = keys
.iter()
.map(|k| {
let daemon = &set.daemons[k];
format!("{} in {}", daemon.name, daemon.root.display())
})
.collect::<Vec<_>>()
.join(", ");
let winner = routed_claimant(&imported);
match winner {
Some(only) => warn_once!(
"[daemons] {where_each} all resolve to {host}. {} is declared by the project it is imported from, which registers it from its own configuration, so pitchfork serves that one and the others get no URL. Give one of them a different proxy label, or set proxy = false on it.",
set.daemons[&keys[only]].name
),
None => warn_once!(
"[daemons] {where_each} all resolve to {host}, so pitchfork routes none of them. Give one of them a different proxy label, or set proxy = false on it."
),
}
state.withdrawn_hosts.extend(
keys.iter()
.enumerate()
.filter(|(i, _)| Some(*i) != winner)
.map(|(_, k)| k.clone()),
);
}
for (key, daemon) in set.daemons.iter_mut() {
daemon
.exports
.retain(|key, _| !state.ambiguous.contains(key));
if state.withdrawn_hosts.contains(key) {
daemon.withdraw_host();
}
}
let mut claimed_ports: BTreeMap<(PathBuf, u16), String> = BTreeMap::new();
for daemon in set.daemons.values() {
let Some(claim) = daemon.port else {
continue;
};
let root = canonical_root(&daemon.root);
if let Some(other) = claimed_ports.insert((root, claim.port), daemon.name.clone())
&& other != daemon.name
{
bail!(
"daemons {other} and {} both use port {} in {}; give one of them its own port, or a different `base` if they use port = \"auto\"",
daemon.name,
claim.port,
daemon.root.display()
);
}
}
let preset_keys: std::collections::BTreeSet<String> = set
.daemons
.values()
.filter(|d| d.preset.is_some() && !d.imported)
.flat_map(|d| d.exports.keys().cloned())
.collect();
for daemon in set.daemons.values_mut().filter(|d| d.preset.is_none()) {
let name = daemon.name.clone();
daemon.exports.retain(|key, _| {
if preset_keys.contains(key) {
warn_once!(
"[daemons] {name} would export {key}, which a preset already sets; the preset's value is kept. Rename {name} to get its own variable."
);
return false;
}
true
});
}
set.labels = state.labels;
for root in set
.daemons
.values()
.filter(|d| !d.imported)
.map(|d| d.root.clone())
.collect::<indexmap::IndexSet<_>>()
{
let namespace = runtime::resolve_namespace(&root, Some(&settings_for(&settings, &root)))?;
set.namespaces.insert(root, namespace);
}
let mut claimed: IndexMap<String, PathBuf> = IndexMap::new();
for daemon in set.daemons.values() {
let Some(namespace) = set.namespaces.get(&daemon.root) else {
continue;
};
let id = format!("{namespace}/{}", daemon.name);
if let Some(other) = claimed.insert(id.clone(), daemon.root.clone())
&& other != daemon.root
{
bail!(
"daemon {id} is declared in both {} and {}; they share a namespace, so give one of them a different name or its own [daemons_settings] namespace",
other.display(),
daemon.root.display()
);
}
}
let failures = set.import_errors.clone();
let mut blocked: IndexMap<String, (String, String)> = IndexMap::new();
for (key, daemon) in set.daemons.iter_mut().filter(|(_, d)| !d.imported) {
let mut reach: IndexMap<String, Result<String, String>> = IndexMap::new();
for ancestor in daemon.root.ancestors() {
for ((root, name), id) in &imported_ids {
if root.as_path() == ancestor {
reach.entry(name.clone()).or_insert_with(|| Ok(id.clone()));
}
}
for ((root, name), err) in &failures {
if root.as_path() == ancestor {
reach
.entry(name.clone())
.or_insert_with(|| Err(err.clone()));
}
}
}
let imports: IndexMap<String, String> = reach
.iter()
.filter_map(|(name, id)| Some((name.clone(), id.as_ref().ok()?.clone())))
.collect();
let unresolved: IndexMap<String, String> = reach
.iter()
.filter_map(|(name, id)| Some((name.clone(), id.as_ref().err()?.clone())))
.collect();
if let Some(missing) = rewrite_depends(&mut daemon.table, &imports, &unresolved)? {
let error = unresolved[&missing].clone();
blocked.insert(key.clone(), (missing, error));
}
}
set.blocked = blocked;
load_groups(&mut set, group_declarations, &declared_in)?;
Ok(set)
}
#[derive(Default)]
struct LoadState {
claims: BTreeMap<PathBuf, BTreeMap<String, PortClaim>>,
keys: BTreeMap<String, String>,
ambiguous: std::collections::BTreeSet<String>,
withdrawn_hosts: std::collections::BTreeSet<String>,
labels: IndexMap<PathBuf, urls::RootLabels>,
}
impl LoadState {
fn labels(&mut self, root: &Path, settings: &DaemonSettings) -> Result<urls::RootLabels> {
if let Some(labels) = self.labels.get(root) {
return Ok(labels.clone());
}
let labels = urls::labels(root, settings)?;
self.labels.insert(root.to_path_buf(), labels.clone());
Ok(labels)
}
}
fn build(
name: &str,
declaration: Declaration,
source: PathBuf,
root: PathBuf,
settings: &DaemonSettings,
state: &mut LoadState,
imported: bool,
) -> Result<Daemon> {
let (preset, version, mut table) = match declaration {
Declaration::Preset(version) => (Some(name.to_string()), Some(version), toml::Table::new()),
Declaration::Definition(mut table) => {
let preset = take_string(&mut table, "preset")?;
let version = take_string(&mut table, "version")?;
(preset, version, table)
}
};
let request = table
.remove("port")
.map(|value| ports::parse(name, value))
.transpose()?;
let persisted = if matches!(request, Some(PortRequest::Auto { .. })) {
state
.claims
.entry(root.clone())
.or_insert_with(|| {
runtime::read_state(&root)
.map(|s| s.ports)
.unwrap_or_default()
})
.get(name)
.copied()
} else {
None
};
let init = take_init(&mut table, name)?;
let task = take_string(&mut table, "task")?;
let args = take_args(&mut table, name)?;
if preset.is_some() {
if task.is_some() {
bail!("[daemons.{name}] cannot combine preset with task");
}
if args.is_some() {
bail!("[daemons.{name}] args requires task, which a preset cannot use");
}
}
if let Some(preset) = preset {
let version =
version.ok_or_else(|| eyre::eyre!("[daemons.{name}] requires version with preset"))?;
let claim = match request {
Some(PortRequest::Passthrough(_)) => bail!(
"[daemons.{name}].port must be an integer or \"auto\"; pitchfork's structured port is only available on custom daemons"
),
Some(PortRequest::Fixed(port)) => Some(PortClaim::fixed(port)),
Some(PortRequest::Auto { base, stride }) => Some(ports::resolve(
name,
&root,
base,
stride,
Some(presets::default_port(&preset)?),
persisted,
)?),
None => None,
};
return presets::expand(
name,
&preset,
&version,
table,
presets::Extras {
init: &init,
port: claim,
labels: &state.labels(&root, settings)?,
imported,
},
&source,
&root,
);
}
if version.is_some() || table.contains_key("options") {
bail!("[daemons.{name}] requires preset when specifying version or options");
}
if task.is_some() && table.contains_key("run") {
bail!("[daemons.{name}] cannot set both run and task");
}
if args.is_some() && task.is_none() {
bail!("[daemons.{name}] args requires task");
}
if let Some(task) = &task {
if task.is_empty() {
bail!("[daemons.{name}] task must not be empty");
}
let mut run = format!(
"exec {} run {}",
presets::quote(crate::env::MISE_BIN.to_string_lossy()),
presets::quote(task)
);
let args = args.unwrap_or_default();
if !args.is_empty() {
run.push_str(" --");
for arg in args {
run.push(' ');
run.push_str(&presets::quote(arg));
}
}
table.insert("run".into(), toml::Value::String(run));
table
.entry("env".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| eyre::eyre!("[daemons.{name}] env must be a table"))?
.insert(
DAEMON_TASK_MARKER.into(),
toml::Value::String("1".to_string()),
);
if cfg!(unix) && table.get("mise").and_then(toml::Value::as_bool) != Some(false) {
presets::wrap_probe_commands(&mut table);
}
if init.is_empty() {
table
.entry("mise".to_string())
.or_insert(toml::Value::Boolean(false));
}
}
if table.get("run").and_then(toml::Value::as_str).is_none() {
bail!("[daemons.{name}] requires run, task, preset, or project");
}
let claim = match request {
Some(PortRequest::Passthrough(value)) => {
table.insert("port".into(), value);
None
}
Some(PortRequest::Fixed(port)) => Some(PortClaim::fixed(port)),
Some(PortRequest::Auto { base, stride }) => {
Some(ports::resolve(name, &root, base, stride, None, persisted)?)
}
None => None,
};
if let Some(claim) = claim {
table.insert("port".into(), expected_port(claim.port));
}
let proxy = urls::proxy_settings();
let urls::Applied { host, .. } = urls::apply(
name,
&mut table,
&state.labels(&root, settings)?,
&proxy.tld,
)?;
let mut exports = IndexMap::new();
if !imported
&& (claim.is_some() || host.is_some())
&& let Some(base) = env_var_base(name)
{
match state.keys.insert(base.clone(), name.to_string()) {
Some(other) => {
warn_once!(
"[daemons] {other} and {name} both map to {base}_PORT; their names differ only by punctuation, so neither endpoint is exported. Rename one of them."
);
state.ambiguous.insert(format!("{base}_PORT"));
state.ambiguous.insert(format!("{base}_URL"));
}
None => {
if let Some(c) = claim {
exports.insert(format!("{base}_PORT"), c.port.to_string());
}
if let Some(host) = &host {
exports.insert(format!("{base}_URL"), proxy.url(host));
}
}
}
}
table
.entry("mise".to_string())
.or_insert(toml::Value::Boolean(true));
if !init.is_empty() {
let run = table["run"].as_str().unwrap().to_string();
table.insert(
"run".into(),
toml::Value::String(presets::with_init(&init, &run)),
);
}
Ok(Daemon {
name: name.to_string(),
source,
root,
table,
preset: None,
data_dir: None,
task,
tool: None,
exports,
imported,
port: claim,
host,
})
}
fn settings_for(settings: &IndexMap<PathBuf, DaemonSettings>, root: &Path) -> DaemonSettings {
let mut resolved = DaemonSettings::default();
for ancestor in root.ancestors().collect::<Vec<_>>().into_iter().rev() {
if let Some(declared) = settings.get(ancestor) {
resolved.merge(declared.clone());
}
}
resolved
}
pub(crate) fn ensure_not_blocked(
set: &DaemonSet,
starting: &DaemonSet,
root: Option<&Path>,
) -> Result<()> {
let Some((name, import)) = set.blocked.iter().find(|(name, _)| {
set.daemons
.get(*name)
.is_some_and(|blocked| starting.contains(blocked))
}) else {
return Ok(());
};
let project = root
.map(|root| format!(" in {}", root.display()))
.unwrap_or_default();
let (import, error) = import;
bail!("daemon {name:?}{project} depends on [daemons.{import}], which is unavailable: {error}");
}
fn hierarchy_config_paths(dir: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for ancestor in crate::file::all_dirs(dir, &crate::env::MISE_CEILING_PATHS)?
.into_iter()
.rev()
{
if crate::config::config_dir_is_ignored(&ancestor, false) {
continue;
}
paths.extend(
crate::config::config_paths_in_dir(&ancestor)
.into_iter()
.rev()
.filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
.filter(|path| !crate::config::config_path_is_ignored(path, false)),
);
}
let mut seen = std::collections::HashSet::new();
paths.retain(|path| seen.insert(crate::file::desymlink_path(path)));
Ok(paths)
}
struct Import {
project: String,
remote_name: String,
}
fn parse_import(local_name: &str, mut table: toml::Table) -> Result<Import> {
let project = take_string(&mut table, "project")?
.ok_or_else(|| eyre::eyre!("[daemons.{local_name}].project must be a string"))?;
let remote_name = take_string(&mut table, "name")?.unwrap_or_else(|| local_name.to_string());
validate_name("daemon", &remote_name)?;
if let Some(unexpected) = table.keys().next() {
bail!(
"[daemons.{local_name}] declares {unexpected:?} alongside project; define the daemon in the referenced project instead"
);
}
Ok(Import {
project,
remote_name,
})
}
fn import(
local_name: &str,
spec: &Import,
source: &Path,
root: &Path,
namespaces: &mut IndexMap<PathBuf, String>,
state: &mut LoadState,
) -> Result<(String, Daemon)> {
let Import {
project,
remote_name,
} = spec;
let remote_name = remote_name.as_str();
let expanded = crate::file::replace_path(project);
let dir = if expanded.is_absolute() {
expanded
} else {
root.join(expanded)
};
let dir = dir
.absolutize()
.map(|p| p.to_path_buf())
.unwrap_or_else(|_| dir.clone());
let dir = dir.canonicalize().unwrap_or(dir);
if !dir.is_dir() {
bail!(
"[daemons.{local_name}].project expects a project directory at {}, which {}; check that project out there or point [daemons.{local_name}].project in {} at its directory",
dir.display(),
if dir.exists() {
"is not a directory"
} else {
"does not exist"
},
crate::file::display_path(source)
);
}
let paths = hierarchy_config_paths(&dir)?;
if paths.is_empty() {
bail!(
"[daemons.{local_name}].project expects a mise configuration in {}, which has none; add a mise.toml declaring [daemons.{remote_name}] there",
dir.display()
);
}
let mut found: Option<(Declaration, PathBuf, PathBuf)> = None;
let mut settings: IndexMap<PathBuf, DaemonSettings> = IndexMap::new();
let mut available: Vec<String> = Vec::new();
for path in &paths {
if !Settings::safe_mode() && !crate::config::config_file::is_path_trusted(path) {
let untrusted = crate::config::config_file::config_trust_root(path);
bail!(
"[daemons.{local_name}].project needs {}, which is not trusted; run `mise trust {}` after reviewing it",
crate::file::display_path(path),
untrusted.display()
);
}
let cf = crate::config::config_file::mise_toml::MiseToml::from_file(path)?;
let project_root = cf.project_root();
let remote_root = project_root
.clone()
.unwrap_or_else(|| crate::config::config_file::config_root::config_root(path));
if let Some(file_settings) = cf.daemon_settings()
&& let Some(project_root) = &project_root
{
settings
.entry(project_root.clone())
.or_default()
.merge(file_settings);
}
for (name, declaration) in cf.daemon_declarations() {
if !available.contains(&name) {
available.push(name.clone());
}
if name == remote_name {
found = Some((declaration, path.clone(), remote_root.clone()));
}
}
}
let Some((declaration, remote_source, remote_root)) = found else {
bail!(
"[daemons.{local_name}].project expects [daemons.{remote_name}] in {}, which declares {}",
dir.display(),
if available.is_empty() {
"no daemons".to_string()
} else {
available.join(", ")
}
);
};
if let Declaration::Definition(table) = &declaration
&& table.contains_key("project")
{
bail!(
"[daemons.{remote_name}] in {} is itself imported with project; reference the project that declares it",
dir.display()
);
}
let remote_settings = settings_for(&settings, &remote_root);
let daemon = build(
remote_name,
declaration,
remote_source,
remote_root.clone(),
&remote_settings,
state,
true,
)?;
let namespace = runtime::resolve_namespace(&remote_root, Some(&remote_settings))?;
let id = format!("{namespace}/{}", daemon.name);
namespaces.insert(remote_root, namespace);
Ok((id, daemon))
}
fn rewrite_depends(
table: &mut toml::Table,
imported: &IndexMap<String, String>,
unresolved: &IndexMap<String, String>,
) -> Result<Option<String>> {
let Some(depends) = table.get_mut("depends") else {
return Ok(None);
};
let mut missing: Option<String> = None;
let rewrite = |value: &mut toml::Value| {
if let Some(name) = value.as_str()
&& let Some(id) = imported.get(name)
{
*value = toml::Value::String(id.clone());
}
};
let dangling = |value: &toml::Value| {
value
.as_str()
.filter(|name| unresolved.contains_key(*name))
.map(str::to_string)
};
let mut empty = false;
match depends {
toml::Value::String(_) => {
if let Some(name) = dangling(depends) {
missing = Some(name);
empty = true;
} else {
rewrite(depends);
}
}
toml::Value::Array(entries) => {
if entries.iter().any(|entry| entry.as_str().is_none()) {
bail!("daemon depends must be a string or an array of strings");
}
missing = entries.iter().find_map(dangling);
entries.retain(|entry| dangling(entry).is_none());
entries.iter_mut().for_each(rewrite);
empty = entries.is_empty();
}
_ => bail!("daemon depends must be a string or an array of strings"),
}
if empty {
table.remove("depends");
}
Ok(missing)
}
fn depends_names(table: &toml::Table) -> Vec<String> {
let Some(depends) = table.get("depends") else {
return Vec::new();
};
let entries: Vec<&toml::Value> = match depends {
toml::Value::String(_) => vec![depends],
toml::Value::Array(entries) => entries.iter().collect(),
_ => Vec::new(),
};
entries
.into_iter()
.filter_map(|entry| entry.as_str())
.map(str::to_string)
.collect()
}
fn env_var_base(name: &str) -> Option<String> {
if name.starts_with(|c: char| c.is_ascii_digit()) {
warn_once!(
"[daemons] {name} starts with a digit, so its port and URL cannot be exported as shell variables; rename it to start with a letter to get them"
);
return None;
}
Some(
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect(),
)
}
pub(crate) fn expected_port(port: u16) -> toml::Value {
expected_ports(&[port])
}
pub(crate) fn expected_ports(ports: &[u16]) -> toml::Value {
toml::Value::Table(toml::Table::from_iter([
(
"expect".into(),
toml::Value::Array(
ports
.iter()
.map(|port| toml::Value::Integer(i64::from(*port)))
.collect(),
),
),
("bump".into(), toml::Value::Boolean(false)),
]))
}
type GroupKey = (PathBuf, String);
type GroupDeclarations = IndexMap<GroupKey, (GroupDeclaration, PathBuf, PathBuf)>;
fn load_groups(
set: &mut DaemonSet,
declarations: GroupDeclarations,
declared_in: &IndexMap<String, Vec<PathBuf>>,
) -> Result<()> {
let declares = |root: &Path, name: &str| {
declared_in
.get(name)
.is_some_and(|roots| roots.iter().any(|r| r == root))
};
let mut groups: IndexMap<GroupKey, Group> = IndexMap::new();
for (key, (declaration, source, root)) in declarations {
let name = key.1.clone();
validate_name("daemon group", &name)?;
if declares(&root, &name) {
bail!("[daemon_groups.{name}] conflicts with the daemon of the same name");
}
let members = declaration.members().to_vec();
if members.is_empty() {
bail!("[daemon_groups.{name}] requires at least one daemon or group");
}
groups.insert(
key,
Group {
name,
source,
root,
members,
daemons: Vec::new(),
},
);
}
for key in groups.keys().cloned().collect::<Vec<_>>() {
let daemons = expand_group(&groups, &key, &declares, &mut Vec::new())?;
let group_root = groups[&key].root.clone();
if let Some(member) = daemons
.iter()
.find(|member| set.imported_in(&group_root, member))
{
let name = &groups[&key].name;
bail!(
"[daemon_groups.{name}] names {member:?}, which this project reaches with `project`; a group covers the daemons this project declares, so name the imported daemon directly or list it in `depends`"
);
}
groups[&key].daemons = daemons;
}
set.groups = groups.into_values().collect();
Ok(())
}
fn expand_group(
groups: &IndexMap<GroupKey, Group>,
key: &GroupKey,
declares: &impl Fn(&Path, &str) -> bool,
seen: &mut Vec<String>,
) -> Result<Vec<String>> {
let name = &key.1;
if seen.iter().any(|s| s == name) {
seen.push(name.clone());
bail!(
"[daemon_groups.{name}] references itself: {}",
seen.join(" -> ")
);
}
seen.push(name.clone());
let group = &groups[key];
let mut expanded: Vec<String> = Vec::new();
for member in &group.members {
if declares(&group.root, member) {
if !expanded.contains(member) {
expanded.push(member.clone());
}
continue;
}
let nested = (group.root.clone(), member.clone());
if groups.contains_key(&nested) {
for daemon in expand_group(groups, &nested, declares, seen)? {
if !expanded.contains(&daemon) {
expanded.push(daemon);
}
}
continue;
}
bail!(
"{}: [daemon_groups.{name}] member {member:?} is not a daemon or group declared for {}",
group.source.display(),
group.root.display()
);
}
seen.pop();
Ok(expanded)
}
fn take_init(table: &mut toml::Table, name: &str) -> Result<Vec<String>> {
let Some(value) = table.remove("init") else {
return Ok(vec![]);
};
let steps = match value {
toml::Value::String(step) => vec![step],
toml::Value::Array(steps) => steps
.into_iter()
.map(|step| match step {
toml::Value::String(step) => Ok(step),
_ => bail!("[daemons.{name}] init entries must be strings"),
})
.collect::<Result<Vec<_>>>()?,
_ => bail!("[daemons.{name}] init must be a string or an array of strings"),
};
if steps.iter().any(|step| step.trim().is_empty()) {
bail!("[daemons.{name}] init entries must not be empty");
}
Ok(steps)
}
fn take_args(table: &mut toml::Table, name: &str) -> Result<Option<Vec<String>>> {
table
.remove("args")
.map(|value| match value {
toml::Value::Array(args) => args
.into_iter()
.map(|arg| match arg {
toml::Value::String(arg) => Ok(arg),
_ => bail!("[daemons.{name}] args entries must be strings"),
})
.collect::<Result<Vec<_>>>(),
_ => bail!("[daemons.{name}] args must be an array of strings"),
})
.transpose()
}
fn take_string(table: &mut toml::Table, key: &str) -> Result<Option<String>> {
table
.remove(key)
.map(|v| {
v.as_str()
.map(str::to_string)
.ok_or_else(|| eyre::eyre!("daemon {key} must be a string"))
})
.transpose()
}
impl DaemonSet {
pub(crate) fn add_tool_requests(&self, trs: &mut ToolRequestSet) -> Result<()> {
for daemon in self.daemons.values() {
let Some((tool, version)) = daemon.tool.as_ref().filter(|_| !daemon.imported) else {
continue;
};
let ba = crate::cli::args::BackendArg::from(tool.as_str());
if let Some(existing) = trs.tools.get(&ba) {
if existing.iter().all(|tr| tr.source().is_mise_toml_daemon())
&& existing.iter().any(|tr| tr.version() != *version)
{
bail!(
"daemon {} requests {tool}@{version}, conflicting with another daemon; use one version per tool",
daemon.name
);
}
continue;
}
let source = ToolSource::MiseTomlDaemon(daemon.source.clone());
let request = ToolRequest::new(ba.into(), version, source.clone())?;
trs.add_version(request, &source);
}
Ok(())
}
pub(crate) fn env_entries(&self) -> Vec<(EnvDirective, PathBuf)> {
self.daemons
.values()
.filter(|daemon| !daemon.imported)
.flat_map(|daemon| {
daemon.exports.iter().map(|(k, v)| {
(
EnvDirective::Val(k.clone(), v.clone(), Default::default()),
daemon.source.clone(),
)
})
})
.collect()
}
pub(crate) fn roots(&self) -> Vec<PathBuf> {
self.daemons
.values()
.map(|d| d.root.clone())
.collect::<indexmap::IndexSet<_>>()
.into_iter()
.collect()
}
pub(crate) fn for_root(&self, root: &Path) -> Self {
Self {
daemons: self
.daemons
.iter()
.filter(|(_, d)| d.root == root)
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
namespaces: self
.namespaces
.iter()
.filter(|(r, _)| r.as_path() == root)
.map(|(r, n)| (r.clone(), n.clone()))
.collect(),
aliases: self
.aliases
.iter()
.filter(|((r, _), _)| r.as_path() == root)
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
import_errors: self.import_errors.clone(),
blocked: self.blocked.clone(),
labels: self
.labels
.iter()
.filter(|(r, _)| r.as_path() == root)
.map(|(r, l)| (r.clone(), l.clone()))
.collect(),
groups: self
.groups
.iter()
.filter(|g| g.root == root)
.cloned()
.collect(),
}
}
pub(crate) fn group(&self, name: &str) -> Option<&Group> {
self.groups.iter().find(|g| g.name == name)
}
pub(crate) fn expand(&self, name: &str) -> Option<&[String]> {
self.group(name).map(|g| g.daemons.as_slice())
}
pub(crate) fn resolve_alias(&self, name: &str) -> String {
self.imported_as(name)
.map(str::to_string)
.or_else(|| {
let daemon = self.daemons.get(name)?;
let namespace = self.namespace_for(&daemon.root)?;
Some(format!("{namespace}/{}", daemon.name))
})
.unwrap_or_else(|| name.to_string())
}
pub(crate) fn imported_as(&self, name: &str) -> Option<&str> {
self.aliases
.iter()
.find(|((_, key), _)| key == name)
.map(|(_, id)| id.as_str())
}
pub(crate) fn resolve_bare(&self, root: &Path, name: &str) -> Option<BareName<'_>> {
for ancestor in root.ancestors() {
if self
.daemons
.values()
.any(|d| !d.imported && d.root == ancestor && d.name == name)
{
return Some(BareName::Daemon);
}
if self
.groups
.iter()
.any(|g| g.root == ancestor && g.name == name)
{
return Some(BareName::Group);
}
let key = (ancestor.to_path_buf(), name.to_string());
if let Some(id) = self.aliases.get(&key) {
return Some(BareName::Import(id.as_str()));
}
if let Some(err) = self.import_errors.get(&key) {
return Some(BareName::Unresolved(err.as_str()));
}
}
None
}
pub(crate) fn imported_in(&self, root: &Path, name: &str) -> bool {
matches!(self.resolve_bare(root, name), Some(BareName::Import(_)))
}
pub(crate) fn namespace_for(&self, root: &Path) -> Option<&str> {
self.namespaces.get(root).map(String::as_str)
}
pub(crate) fn restricted_to(&self, requested: &Self) -> Self {
Self {
daemons: self
.daemons
.iter()
.filter_map(|(k, v)| {
let asked = requested
.daemons
.values()
.find(|r| r.name == v.name && r.root == v.root)?;
let mut daemon = v.clone();
if asked.host.is_none() {
daemon.withdraw_host();
}
Some((k.clone(), daemon))
})
.collect(),
namespaces: self.namespaces.clone(),
aliases: self.aliases.clone(),
import_errors: self.import_errors.clone(),
blocked: self.blocked.clone(),
labels: self.labels.clone(),
groups: self.groups.clone(),
}
}
pub(crate) fn with_dependencies(&self, names: &[String]) -> Self {
let qualified = |d: &Daemon| match self.namespace_for(&d.root) {
Some(namespace) => format!("{namespace}/{}", d.name),
None => d.name.clone(),
};
let by_id: IndexMap<_, _> = self.daemons.values().map(|d| (qualified(d), d)).collect();
let mut keep: indexmap::IndexSet<String> = by_id
.iter()
.filter(|(id, daemon)| {
names.iter().any(|name| {
name == *id
|| self.daemons.get(name).is_some_and(|exact| {
exact.name == daemon.name && exact.root == daemon.root
})
})
})
.map(|(id, _)| id.clone())
.collect();
let mut queue: Vec<String> = keep.iter().cloned().collect();
while let Some(id) = queue.pop() {
let Some(daemon) = by_id.get(&id) else {
continue;
};
for dependency in depends_names(&daemon.table) {
let dependency = if dependency.contains('/') {
dependency
} else if let Some(namespace) = self.namespace_for(&daemon.root) {
format!("{namespace}/{dependency}")
} else {
dependency
};
if by_id.contains_key(&dependency) && keep.insert(dependency.clone()) {
queue.push(dependency);
}
}
}
Self {
daemons: self
.daemons
.iter()
.filter(|(_, d)| keep.contains(&qualified(d)))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
namespaces: self.namespaces.clone(),
aliases: self.aliases.clone(),
import_errors: self.import_errors.clone(),
blocked: self.blocked.clone(),
labels: self.labels.clone(),
groups: self.groups.clone(),
}
}
pub(crate) fn contains(&self, daemon: &Daemon) -> bool {
self.daemons
.values()
.any(|d| d.name == daemon.name && d.root == daemon.root)
}
pub(crate) fn find(&self, name: &str) -> Option<&Daemon> {
self.daemons.values().find(|d| d.name == name)
}
pub(crate) async fn validate_tasks(&self, config: &Arc<Config>) -> Result<()> {
if self.daemons.values().all(|d| d.task.is_none()) {
return Ok(());
}
let tasks = config.tasks_with_aliases().await?;
for daemon in self.daemons.values() {
let Some(task) = &daemon.task else {
continue;
};
if !tasks.contains_key(task) {
bail!("daemon {} runs unknown task {task:?}", daemon.name);
}
}
Ok(())
}
pub(crate) fn auto_starting(&self) -> Self {
let names: Vec<String> = self
.daemons
.iter()
.filter(|(_, d)| {
d.table
.get("auto")
.and_then(toml::Value::as_array)
.is_some_and(|a| a.iter().any(|v| v.as_str() == Some("start")))
})
.map(|(key, _)| key.clone())
.collect();
self.with_dependencies(&names)
}
pub(crate) fn auto(&self) -> bool {
self.daemons.values().any(|d| {
d.table
.get("auto")
.and_then(toml::Value::as_array)
.is_some_and(|a| !a.is_empty())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::config_file::mise_toml::MiseToml;
use std::sync::Arc;
fn files(entries: &[(&str, &str)]) -> ConfigMap {
entries
.iter()
.map(|(path, body)| {
let path = PathBuf::from(path);
let cf: Arc<dyn crate::config::config_file::ConfigFile> =
Arc::new(MiseToml::from_str(body, &path).unwrap());
(path, cf)
})
.collect()
}
#[tokio::test]
async fn daemons_merge_before_expanding_tools_and_exports() {
crate::toolset::install_state::init().await.unwrap();
let config = files(&[
(
"/parent/child/mise.toml",
"[daemons.postgres]\nrun = 'echo custom'\n",
),
(
"/parent/mise.toml",
"[daemons]\npostgres = '18'\nredis = '8'\n",
),
]);
let set = load(&config).unwrap();
assert!(set.daemons["postgres"].tool.is_none());
assert!(set.daemons["postgres"].exports.is_empty());
assert_eq!(
set.daemons["postgres"].table["run"].as_str(),
Some("echo custom")
);
let mut requests = ToolRequestSet::default();
set.add_tool_requests(&mut requests).unwrap();
assert_eq!(requests.tools.len(), 1);
assert!(
requests
.sources
.values()
.next()
.unwrap()
.is_mise_toml_daemon()
);
}
#[tokio::test]
async fn preset_names_and_version_conflicts_are_reported() {
crate::toolset::install_state::init().await.unwrap();
let config = files(&[("/project/mise.toml", "[daemons]\nunknown = '1'\n")]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("available presets")
);
let config = files(&[(
"/project/mise.toml",
"[daemons]\npostgres = '18'\n[daemons.analytics]\npreset = 'postgres'\nversion = '17'\nport = 5433\n",
)]);
let set = load(&config).unwrap();
assert!(
set.add_tool_requests(&mut ToolRequestSet::default())
.is_err()
);
}
#[test]
fn preset_run_override_keeps_initialization() {
let config = files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\nrun = 'echo starting && exec postgres -D /data'\n",
)]);
let set = load(&config).unwrap();
let run = set.daemons["db"].table["run"].as_str().unwrap();
assert!(run.contains("daemons __init"));
assert!(run.ends_with("&& echo starting && exec postgres -D /data"));
let invalid = files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\nrun = 42\n",
)]);
assert!(load(&invalid).is_err());
}
#[test]
fn preset_tool_override_is_used_and_not_forwarded() {
let config = files(&[(
"/project/mise.toml",
"[daemons.cache]\npreset = 'redis'\nversion = '8'\ntool = 'github:example/redis'\n",
)]);
let set = load(&config).unwrap();
assert_eq!(
set.daemons["cache"].tool.as_ref().unwrap().0,
"github:example/redis"
);
assert!(!set.daemons["cache"].table.contains_key("tool"));
let config = files(&[(
"/project/mise.toml",
"[daemons.cache]\npreset = 'redis'\nversion = '8'\ntool = 42\n",
)]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("tool must be a string")
);
}
#[test]
#[cfg(unix)]
fn task_probe_environment_respects_explicit_opt_out() {
for opt_out in [false, true] {
let source = format!(
"[daemons.api]\ntask = 'dev'\nready_cmd = 'echo ready'\nhealth_cmd = {{ run = 'echo {{{{ env.FOO }}}}', interval = '2s' }}\n{}",
if opt_out { "mise = false\n" } else { "" }
);
let config = files(&[("/project/mise.toml", &source)]);
let set = load(&config).unwrap();
let table = &set.daemons["api"].table;
let expected = if opt_out {
"echo ready".to_string()
} else {
presets::in_tool_env("echo ready")
};
assert_eq!(table["ready_cmd"].as_str(), Some(expected.as_str()));
let expected = if opt_out {
"echo {{ env.FOO }}".to_string()
} else {
presets::in_tool_env("echo {{ env.FOO }}")
};
assert_eq!(table["health_cmd"]["run"].as_str(), Some(expected.as_str()));
assert_eq!(table["health_cmd"]["interval"].as_str(), Some("2s"));
}
}
#[test]
fn task_daemons_run_mise_without_a_mise_wrapper() {
let config = files(&[(
"/project/mise.toml",
"[daemons.core]\ntask = 'dev:core'\nargs = ['--port', \"it's 3000\"]\nready_port = 3000\n",
)]);
let set = load(&config).unwrap();
let daemon = &set.daemons["core"];
let mise = presets::quote(crate::env::MISE_BIN.to_string_lossy());
assert_eq!(
daemon.table["run"].as_str(),
Some(format!("exec {mise} run 'dev:core' -- '--port' 'it'\\''s 3000'").as_str())
);
assert_eq!(daemon.table["env"][DAEMON_TASK_MARKER].as_str(), Some("1"));
assert_eq!(daemon.table["mise"].as_bool(), Some(false));
assert_eq!(daemon.task.as_deref(), Some("dev:core"));
assert!(!daemon.table.contains_key("task"));
assert!(!daemon.table.contains_key("args"));
assert_eq!(daemon.table["ready_port"].as_integer(), Some(3000));
let config = files(&[("/project/mise.toml", "[daemons.core]\ntask = 'dev'\n")]);
assert_eq!(
load(&config).unwrap().daemons["core"].table["run"].as_str(),
Some(format!("exec {mise} run 'dev'").as_str())
);
}
#[test]
fn conflicting_daemon_process_declarations_are_rejected() {
for body in [
"[daemons.core]\ntask = 'dev'\nrun = 'server'\n",
"[daemons.core]\npreset = 'postgres'\nversion = '18'\ntask = 'dev'\n",
"[daemons.core]\nargs = ['--port']\nrun = 'server'\n",
"[daemons.core]\ntask = ''\n",
"[daemons.core]\nready_port = 3000\n",
] {
assert!(
load(&files(&[("/project/mise.toml", body)])).is_err(),
"{body}"
);
}
}
#[test]
fn conflict_errors_name_the_key_that_is_set() {
let err = load(&files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\nargs = ['--flag']\n",
)]))
.unwrap_err()
.to_string();
assert!(err.contains("args requires task"), "{err}");
let err = load(&files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\ntask = 'dev'\n",
)]))
.unwrap_err()
.to_string();
assert!(err.contains("cannot combine preset with task"), "{err}");
}
#[test]
fn init_steps_run_before_the_long_running_process() {
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\ninit = ['npm ci', 'npm run migrate']\nrun = 'exec npm start'\n",
)]);
let set = load(&config).unwrap();
assert_eq!(
set.daemons["api"].table["run"].as_str(),
Some("npm ci && npm run migrate && exec npm start")
);
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\ninit = 'npm ci'\nrun = 'exec npm start'\n",
)]);
assert_eq!(
load(&config).unwrap().daemons["api"].table["run"].as_str(),
Some("npm ci && exec npm start")
);
let config = files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\ninit = 'echo ready'\n",
)]);
let run = load(&config).unwrap().daemons["db"].table["run"]
.as_str()
.unwrap()
.to_string();
let init = run.find(" daemons __init ").unwrap();
assert!(init < run.find("echo ready").unwrap());
assert!(run.contains("&& echo ready && exec "));
let wrapped = files(&[(
"/project/mise.toml",
"[daemons.core]\ntask = 'dev'\ninit = ['npm ci', 'npm run migrate']\n",
)]);
let daemon = &load(&wrapped).unwrap().daemons["core"];
let run = daemon.table["run"].as_str().unwrap().to_string();
assert!(
run.starts_with("npm ci && npm run migrate && exec "),
"{run}"
);
assert!(run.ends_with("run 'dev'"), "{run}");
assert_eq!(daemon.table["mise"].as_bool(), Some(true));
let explicit = files(&[(
"/project/mise.toml",
"[daemons.core]\ntask = 'dev'\ninit = 'npm ci'\nmise = false\n",
)]);
assert_eq!(
load(&explicit).unwrap().daemons["core"].table["mise"].as_bool(),
Some(false)
);
assert!(
!load(&config).unwrap().daemons["db"]
.table
.contains_key("init")
);
for body in [
"[daemons.api]\ninit = 1\nrun = 'server'\n",
"[daemons.api]\ninit = [1]\nrun = 'server'\n",
"[daemons.api]\ninit = ' '\nrun = 'server'\n",
] {
assert!(
load(&files(&[("/project/mise.toml", body)])).is_err(),
"{body}"
);
}
}
#[test]
fn custom_daemons_accept_integer_ports() {
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\nrun = 'server'\nport = 3000\n",
)]);
let set = load(&config).unwrap();
assert_eq!(
set.daemons["api"].table["port"]["expect"][0].as_integer(),
Some(3000)
);
}
fn referenced_project(dir: &Path, body: &str) -> PathBuf {
let path = untrusted_project(dir, body);
crate::config::config_file::trust(dir).unwrap();
path
}
static IMPORT_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn import_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test::lock_ignoring_poison(&IMPORT_TESTS)
}
fn failure(set: &DaemonSet, name: &str) -> String {
set.import_errors
.iter()
.find(|((_, key), _)| key == name)
.map(|(_, err)| err.clone())
.unwrap_or_else(|| panic!("no import failure recorded for {name:?}"))
}
fn untrusted_project(dir: &Path, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let path = dir.join(&*crate::env::MISE_DEFAULT_CONFIG_FILENAME);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn referenced_project_daemons_belong_to_that_project() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror-pipeline");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'mirror'\n[daemons.worker]\nrun = 'exec worker'\n[daemons.idle]\nrun = 'exec idle'\n",
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.worker]\nproject = '../mirror-pipeline'\n[daemons.api]\nrun = 'exec api'\ndepends = ['worker']\n",
)]);
let set = load(&config).unwrap();
let worker = &set.daemons["mirror/worker"];
assert!(worker.imported);
assert_eq!(worker.name, "worker");
assert_eq!(worker.root, mirror.canonicalize().unwrap());
assert_eq!(
worker.source,
mirror
.canonicalize()
.unwrap()
.join(&*crate::env::MISE_DEFAULT_CONFIG_FILENAME)
);
assert!(!set.daemons.contains_key("idle"));
let starting = set.with_dependencies(&["api".into()]);
assert!(starting.daemons.contains_key("mirror/worker"));
assert!(starting.daemons.contains_key("api"));
assert!(set.roots().contains(&mirror.canonicalize().unwrap()));
assert_eq!(
set.namespace_for(&mirror.canonicalize().unwrap()),
Some("mirror")
);
assert_eq!(
set.daemons["api"].table["depends"][0].as_str(),
Some("mirror/worker")
);
let mut requests = ToolRequestSet::default();
set.add_tool_requests(&mut requests).unwrap();
assert!(requests.tools.is_empty());
assert!(set.env_entries().is_empty());
}
#[test]
fn referenced_daemon_may_be_renamed_locally() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.pipeline]\nproject = {}\nname = 'worker'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
)]);
let set = load(&config).unwrap();
let daemon = set.daemons.values().next().unwrap();
assert_eq!(daemon.name, "worker");
assert!(daemon.imported);
assert_eq!(
set.resolve_alias("pipeline"),
format!("{}/worker", set.namespace_for(&daemon.root).unwrap())
);
assert_eq!(set.resolve_alias("other"), "other");
}
#[test]
fn an_unresolvable_import_does_not_break_other_commands() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.pipeline]\nproject = '../mirror-pipeline'\n[daemons.api]\nrun = 'exec api'\n",
)]);
let set = load(&config).unwrap();
assert!(set.blocked.is_empty());
assert!(set.daemons.contains_key("api"));
assert!(!set.daemons.contains_key("pipeline"));
let err = &failure(&set, "pipeline");
assert!(err.contains("mirror-pipeline"), "{err}");
assert!(err.contains("[daemons.pipeline].project"), "{err}");
assert!(
set.add_tool_requests(&mut ToolRequestSet::default())
.is_ok(),
"tool resolution must survive an unresolvable import"
);
}
#[test]
fn missing_referenced_projects_name_the_path_and_the_setting() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let source = app.join("mise.toml");
let config = files(&[(
source.to_str().unwrap(),
"[daemons.worker]\nproject = '../mirror-pipeline'\n",
)]);
let err = failure(&load(&config).unwrap(), "worker");
assert!(err.contains(&tmp.path().join("mirror-pipeline").display().to_string()));
assert!(err.contains("[daemons.worker].project"));
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
let config = files(&[(
source.to_str().unwrap(),
"[daemons.worker]\nproject = '../empty'\n",
)]);
assert!(failure(&load(&config).unwrap(), "worker").contains("mise configuration"));
referenced_project(
&tmp.path().join("other"),
"[daemons.build]\nrun = 'exec build'\n",
);
let config = files(&[(
source.to_str().unwrap(),
"[daemons.worker]\nproject = '../other'\n",
)]);
let err = failure(&load(&config).unwrap(), "worker");
assert!(err.contains("[daemons.worker]"), "{err}");
assert!(err.contains("build"), "{err}");
}
#[test]
fn referenced_projects_inherit_daemons_and_namespaces_from_parent_configs() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let group = tmp.path().join("group");
referenced_project(
&group,
"[daemons_settings]\nnamespace = 'shared'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let mirror = group.join("mirror-pipeline");
std::fs::create_dir_all(&mirror).unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.worker]\nproject = {}\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
)]);
let set = load(&config).unwrap();
let daemon = &set.daemons["shared/worker"];
assert!(daemon.imported);
assert_eq!(daemon.root, group.canonicalize().unwrap());
assert_eq!(
set.namespace_for(&group.canonicalize().unwrap()),
Some("shared")
);
}
#[test]
fn global_daemon_settings_do_not_capture_every_project() {
let tmp = tempfile::tempdir().unwrap();
let global = crate::dirs::HOME.join("config").join("config.toml");
let config = files(&[
(
global.to_str().unwrap(),
"[daemons_settings]\nnamespace = 'everything'\n",
),
(
tmp.path().join("app").join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\n",
),
]);
let set = load(&config).unwrap();
let root = set.daemons["api"].root.clone();
assert_ne!(set.namespace_for(&root), Some("everything"));
assert_eq!(
set.namespace_for(&root),
Some(runtime::namespace(&root).unwrap().as_str())
);
}
#[test]
fn two_imports_cannot_claim_one_daemon_id() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
for dir in ["one", "two"] {
referenced_project(
&tmp.path().join(dir),
"[daemons_settings]\nnamespace = 'shared'\n[daemons.worker]\nrun = 'exec worker'\n",
);
}
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.first]\nproject = '../one'\nname = 'worker'\n[daemons.second]\nproject = '../two'\nname = 'worker'\n",
)]);
let err = load(&config).unwrap_err().to_string();
assert!(err.contains("shared/worker"), "{err}");
assert!(err.contains("share a namespace"), "{err}");
}
#[test]
fn depends_entries_must_be_strings() {
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\nrun = 'exec api'\ndepends = ['db', 3]\n",
)]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("array of strings")
);
}
#[test]
fn selecting_a_daemon_includes_what_it_depends_on() {
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\nrun = 'exec api'\ndepends = ['cache']\n[daemons.cache]\nrun = 'exec cache'\ndepends = ['db']\n[daemons.db]\nrun = 'exec db'\n[daemons.unrelated]\nrun = 'exec other'\n",
)]);
let set = load(&config).unwrap();
let starting = set.with_dependencies(&["api".to_string()]);
let mut names: Vec<_> = starting.daemons.values().map(|d| d.name.clone()).collect();
names.sort();
assert_eq!(names, ["api", "cache", "db"]);
}
#[test]
fn qualified_selection_requires_the_daemons_namespace() {
let config = files(&[(
"/project/mise.toml",
"[daemons_settings]\nnamespace = 'app'\n[daemons.worker]\nrun = 'exec worker'\n[daemons.api]\nrun = 'exec api'\n",
)]);
let set = load(&config).unwrap();
let selected = set.with_dependencies(&["mirror/worker".into(), "api".into()]);
assert!(selected.find("worker").is_none());
assert!(selected.find("api").is_some());
assert!(
set.with_dependencies(&["app/worker".into()])
.find("worker")
.is_some()
);
}
#[test]
fn two_projects_cannot_claim_one_daemon_id() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'shared'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons_settings]\nnamespace = 'shared'\n[daemons.worker]\nrun = 'exec local'\n[daemons.pipeline]\nproject = {}\nname = 'worker'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
)]);
let err = load(&config).unwrap_err().to_string();
assert!(err.contains("shared/worker"), "{err}");
assert!(err.contains("share a namespace"), "{err}");
}
#[test]
fn nested_referenced_projects_resolve_the_same_namespace_both_ways() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let group = tmp.path().join("group");
referenced_project(&group, "[daemons_settings]\nnamespace = 'shared'\n");
let mirror = group.join("mirror-pipeline");
referenced_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
let own = files(&[
(
mirror
.join(&*crate::env::MISE_DEFAULT_CONFIG_FILENAME)
.to_str()
.unwrap(),
"[daemons.worker]\nrun = 'exec worker'\n",
),
(
group
.join(&*crate::env::MISE_DEFAULT_CONFIG_FILENAME)
.to_str()
.unwrap(),
"[daemons_settings]\nnamespace = 'shared'\n",
),
]);
let own = load(&own).unwrap();
let own_root = own.daemons["worker"].root.clone();
assert_eq!(own.namespace_for(&own_root), Some("shared"));
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.worker]\nproject = {}\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
)]);
let imported = load(&config).unwrap();
assert!(imported.daemons.contains_key("shared/worker"));
assert_eq!(
imported.namespace_for(&mirror.canonicalize().unwrap()),
Some("shared")
);
}
#[test]
fn starting_a_daemon_whose_import_is_unavailable_is_refused() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.pipeline]\nproject = '../gone'\n[daemons.api]\nrun = 'exec api'\ndepends = ['pipeline']\n[daemons.web]\nrun = 'exec web'\n",
)]);
let set = load(&config).unwrap();
let err = ensure_not_blocked(&set, &set.with_dependencies(&["api".into()]), None)
.unwrap_err()
.to_string();
assert!(err.contains("[daemons.pipeline]"), "{err}");
assert!(err.contains("gone"), "{err}");
assert!(ensure_not_blocked(&set, &set.with_dependencies(&["web".into()]), None).is_ok());
let err = ensure_not_blocked(&set, &set.with_dependencies(&["api".into()]), Some(&app))
.unwrap_err()
.to_string();
assert!(err.contains(&app.display().to_string()), "{err}");
}
#[test]
fn a_name_means_the_nearest_declaration_that_claims_it() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let parent = tmp.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let config = files(&[
(
child.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\n[daemon_groups]\nops = ['api']\n",
),
(
parent.join("mise.toml").to_str().unwrap(),
"[daemons.ops]\nproject = '../gone'\n",
),
]);
let set = load(&config).unwrap();
assert!(matches!(
set.resolve_bare(&child, "ops"),
Some(BareName::Group)
));
assert!(matches!(
set.resolve_bare(&parent, "ops"),
Some(BareName::Unresolved(_))
));
assert_eq!(set.expand("ops"), Some(["api".to_string()].as_slice()));
}
#[test]
fn a_nearer_failure_is_not_hidden_by_an_ancestor_that_resolved() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'remote'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let parent = tmp.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let config = files(&[
(
child.join("mise.toml").to_str().unwrap(),
"[daemons.pipeline]\nproject = '../gone'\n",
),
(
parent.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.pipeline]\nproject = {}\nname = 'worker'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
),
]);
let set = load(&config).unwrap();
assert!(matches!(
set.resolve_bare(&child, "pipeline"),
Some(BareName::Unresolved(_))
));
assert!(!set.imported_in(&child, "pipeline"));
assert!(set.resolve_bare(&parent, "pipeline").is_none());
assert!(set.imported_as("pipeline").is_none());
}
#[test]
fn the_auto_lifecycle_checks_only_what_it_starts() {
let _serial = import_lock();
let app = tempfile::tempdir().unwrap();
let root = app.path().to_path_buf();
let config = files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\nauto = ['start', 'stop']\ndepends = ['pipeline']\n\
[daemons.manual]\nrun = 'exec manual'\ndepends = ['other']\n\
[daemons.pipeline]\nproject = '../gone'\n[daemons.other]\nproject = '../gone'\n",
)]);
let set = load(&config).unwrap();
let starting = set.auto_starting();
assert!(starting.daemons.contains_key("api"));
assert!(!starting.daemons.contains_key("manual"));
let err = ensure_not_blocked(&set, &starting, Some(&root))
.unwrap_err()
.to_string();
assert!(err.contains("\"api\""), "{err}");
assert!(err.contains("[daemons.pipeline]"), "{err}");
let config = files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\nauto = ['start']\n\
[daemons.manual]\nrun = 'exec manual'\ndepends = ['other']\n\
[daemons.other]\nproject = '../gone'\n",
)]);
let set = load(&config).unwrap();
assert!(set.blocked.contains_key("manual"));
assert!(ensure_not_blocked(&set, &set.auto_starting(), Some(&root)).is_ok());
}
#[test]
fn a_broken_import_does_not_disturb_a_working_one() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'remote'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let parent = tmp.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let config = files(&[
(
child.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\ndepends = ['pipeline', 'absent']\n",
),
(
parent.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.pipeline]\nproject = {}\nname = 'worker'\n[daemons.absent]\nproject = '../gone'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
),
]);
let set = load(&config).unwrap();
let depends = set.daemons["api"].table["depends"].as_array().unwrap();
assert_eq!(depends.len(), 1);
assert_eq!(depends[0].as_str(), Some("remote/worker"));
assert_eq!(
set.blocked.get("api").map(|(name, _)| name.as_str()),
Some("absent")
);
assert!(set.imported_in(&child, "pipeline"));
}
#[test]
fn a_child_reaches_an_import_its_parent_declared() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'remote'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let parent = tmp.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let config = files(&[
(
child.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\ndepends = ['pipeline']\n",
),
(
parent.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.pipeline]\nproject = {}\nname = 'worker'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
),
]);
let set = load(&config).unwrap();
assert_eq!(
set.daemons["api"].table["depends"][0].as_str(),
Some("remote/worker")
);
assert!(set.imported_in(&child, "pipeline"));
}
#[test]
fn an_import_belongs_to_the_project_that_declared_it() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
let parent = tmp.path().join("parent");
let child = parent.join("child");
std::fs::create_dir_all(&child).unwrap();
let config = files(&[
(
child.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'exec api'\n[daemon_groups]\nops = ['api']\n",
),
(
parent.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.ops]\nproject = {}\nname = 'worker'\n",
toml::Value::String(mirror.to_string_lossy().into_owned())
),
),
]);
let set = load(&config).unwrap();
assert_eq!(set.expand("ops"), Some(["api".to_string()].as_slice()));
assert!(set.imported_in(&parent, "ops"));
assert!(!set.imported_in(&child, "ops"));
let starting = set.with_dependencies(&["api".to_string()]);
assert_eq!(starting.daemons.len(), 1);
assert_eq!(starting.daemons["api"].root, child);
}
#[test]
fn a_group_cannot_name_a_daemon_reached_with_project() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.pipeline]\nproject = '../mirror'\nname = 'worker'\n[daemons.api]\nrun = 'exec api'\n[daemon_groups]\nweb = ['api', 'pipeline']\n",
)]);
let err = load(&config).unwrap_err().to_string();
assert!(err.contains("[daemon_groups.web]"), "{err}");
assert!(err.contains("pipeline"), "{err}");
assert!(err.contains("depends"), "{err}");
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.pipeline]\nproject = '../mirror'\nname = 'worker'\n[daemons.api]\nrun = 'exec api'\n[daemon_groups]\nweb = ['api']\n",
)]);
let set = load(&config).unwrap();
assert_eq!(set.expand("web"), Some(["api".to_string()].as_slice()));
}
#[test]
fn a_daemon_is_identified_by_its_project_not_just_its_name() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'remote'\n[daemons.api]\nrun = 'exec remote api'\n",
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.remote_api]\nproject = '../mirror'\nname = 'api'\n[daemons.api]\nrun = 'exec local api'\n",
)]);
let set = load(&config).unwrap();
let local = &set.daemons["api"];
let imported = &set.daemons["remote/api"];
assert_eq!(local.name, imported.name);
assert_ne!(local.root, imported.root);
let only_imported = set.with_dependencies(&["remote/api".into()]);
assert!(only_imported.contains(imported));
assert!(!only_imported.contains(local));
assert!(only_imported.find("api").is_some());
}
#[test]
fn an_untrusted_referenced_project_is_not_imported() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
untrusted_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.worker]\nproject = '../mirror'\n[daemons.api]\nrun = 'exec api'\ndepends = ['worker']\n",
)]);
let config_path = mirror.join(&*crate::env::MISE_DEFAULT_CONFIG_FILENAME);
let _paranoid = Paranoid::on();
let set = load(&config).unwrap();
assert!(set.find("worker").is_none());
let err = &failure(&set, "worker");
assert!(err.contains("not trusted"), "{err}");
assert!(err.contains("mise trust"), "{err}");
assert!(!crate::config::config_file::is_path_trusted(&config_path));
assert!(!set.daemons["api"].table.contains_key("depends"));
assert_eq!(
set.blocked.get("api").map(|(name, _)| name.as_str()),
Some("worker")
);
crate::config::config_file::trust(&config_path).unwrap();
let set = load(&config).unwrap();
assert_eq!(set.find("worker").map(|d| d.imported), Some(true));
}
struct Paranoid;
impl Paranoid {
fn on() -> Self {
use confique::Layer;
let mut settings = crate::config::settings::SettingsPartial::empty();
settings.paranoid = Some(true);
crate::config::Settings::reset(Some(settings));
Self
}
}
impl Drop for Paranoid {
fn drop(&mut self) {
crate::config::Settings::reset(None);
}
}
#[test]
fn referenced_projects_with_tool_versions_files_still_import() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(&mirror, "[daemons.worker]\nrun = 'exec worker'\n");
std::fs::write(
mirror.join(&*crate::env::MISE_DEFAULT_TOOL_VERSIONS_FILENAME),
"node 20.0.0\n",
)
.unwrap();
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.worker]\nproject = '../mirror'\n",
)]);
let set = load(&config).unwrap();
assert_eq!(set.find("worker").map(|d| d.imported), Some(true));
}
#[test]
fn daemon_settings_merge_across_files_instead_of_replacing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let config = files(&[
(
root.join("mise.local.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace_per_worktree = false\n",
),
(
root.join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'entiredb'\n[daemons.api]\nrun = 'exec api'\n",
),
]);
let set = load(&config).unwrap();
let declared = set.daemons["api"].root.clone();
assert_eq!(set.namespace_for(&declared), Some("entiredb"));
let mut merged = DaemonSettings {
namespace: Some("entiredb".into()),
namespace_per_worktree: Some(true),
};
merged.merge(DaemonSettings {
namespace: None,
namespace_per_worktree: Some(false),
});
assert_eq!(merged.namespace.as_deref(), Some("entiredb"));
assert!(!merged.namespace_per_worktree());
assert!(DaemonSettings::default().namespace_per_worktree());
}
#[test]
fn referenced_projects_cannot_be_chained_or_mixed_with_a_definition() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let far = tmp.path().join("far");
referenced_project(&far, "[daemons.worker]\nrun = 'exec worker'\n");
let middle = tmp.path().join("middle");
referenced_project(
&middle,
&format!(
"[daemons.worker]\nproject = {}\n",
toml::Value::String(far.to_string_lossy().into_owned())
),
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.worker]\nproject = '../middle'\n",
)]);
assert!(
failure(&load(&config).unwrap(), "worker")
.contains("reference the project that declares it")
);
let config = files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons.worker]\nproject = '../far'\nrun = 'exec local'\n",
)]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("define the daemon in the referenced project")
);
}
#[test]
fn explicit_namespaces_are_validated() {
for namespace in ["entiredb", "entire.db", "entire_db", "db-1"] {
assert!(validate_namespace(namespace).is_ok(), "{namespace}");
}
for namespace in ["", ".", "..", "a--b", "-a", "a-", "a/b", "a b"] {
assert!(validate_namespace(namespace).is_err(), "{namespace}");
}
let tmp = tempfile::tempdir().unwrap();
let config = files(&[(
tmp.path().join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'entire--db'\n[daemons.api]\nrun = 'exec api'\n",
)]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("invalid daemon namespace")
);
}
#[test]
fn explicit_namespace_replaces_the_hashed_default() {
let tmp = tempfile::tempdir().unwrap();
let config = files(&[(
tmp.path().join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'entiredb'\n[daemons.api]\nrun = 'exec api'\n",
)]);
let set = load(&config).unwrap();
let root = set.daemons["api"].root.clone();
assert_eq!(set.namespace_for(&root), Some("entiredb"));
}
#[test]
fn auto_ports_separate_worktrees_but_leave_the_primary_checkout_alone() {
let tmp = tempfile::tempdir().unwrap();
let primary = tmp.path().join("project");
std::fs::create_dir_all(primary.join(".git")).unwrap();
let linked = tmp.path().join("worktree");
std::fs::create_dir_all(&linked).unwrap();
let private = primary.join(".git").join("worktrees").join("worktree");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(primary.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
let body = "[daemons.db]\npreset = 'postgres'\nversion = '18'\nport = 'auto'\n[daemons.api]\nrun = 'server'\n[daemons.api.port]\nauto = true\nbase = 3000\n";
let ports = |root: &Path| {
let set = load(&files(&[(root.join("mise.toml").to_str().unwrap(), body)])).unwrap();
(
set.daemons["db"].port.unwrap().port,
set.daemons["api"].port.unwrap().port,
)
};
assert_eq!(ports(&primary), (5432, 3000));
let (db, api) = ports(&linked);
assert!(db > 5432 && db <= 5432 + ports::SLOTS);
assert!(api > 3000 && api <= 3000 + ports::SLOTS);
assert_eq!(ports(&linked), (db, api));
let set = load(&files(&[(
linked.join("mise.toml").to_str().unwrap(),
body,
)]))
.unwrap();
assert_eq!(set.daemons["db"].exports["PGPORT"], db.to_string());
assert!(set.daemons["db"].exports["DATABASE_URL"].contains(&format!(":{db}/")));
assert!(
set.daemons["db"].table["run"]
.as_str()
.unwrap()
.contains(&format!("-p {db}"))
);
assert_eq!(
set.daemons["api"].table["port"]["expect"][0].as_integer(),
Some(i64::from(api))
);
}
#[test]
fn custom_daemons_export_their_resolved_port() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join(".git")).unwrap();
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'server'\nport = 3000\n[daemons.web-ui]\nrun = 'ui'\n[daemons.web-ui.port]\nauto = true\nbase = 4000\n",
)]))
.unwrap();
assert_eq!(set.daemons["api"].exports["API_PORT"], "3000");
assert_eq!(set.daemons["web-ui"].exports["WEB_UI_PORT"], "4000");
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.web-ui]\nrun = 'a'\nport = 3000\n[daemons.web_ui]\nrun = 'b'\nport = 3001\n",
)]))
.unwrap();
assert!(set.daemons["web-ui"].exports.is_empty());
assert!(set.daemons["web_ui"].exports.is_empty());
assert_eq!(set.daemons["web-ui"].port.unwrap().port, 3000);
assert_eq!(set.daemons["web_ui"].port.unwrap().port, 3001);
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.\".api\"]\nrun = 'a'\nport = 3000\n",
)]))
.unwrap();
assert_eq!(set.daemons[".api"].exports["_API_PORT"], "3000");
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.9api]\nrun = 'a'\nport = 3000\n",
)]))
.unwrap();
assert!(set.daemons["9api"].exports.is_empty());
assert_eq!(set.daemons["9api"].port.unwrap().port, 3000);
assert_eq!(
set.daemons["9api"].table["port"]["expect"][0].as_integer(),
Some(3000)
);
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'server'\n",
)]))
.unwrap();
assert!(set.daemons["api"].exports.is_empty());
assert!(set.daemons["api"].host.is_none());
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = false\n",
)]))
.unwrap();
assert_eq!(
set.daemons["api"].exports.keys().collect::<Vec<_>>(),
vec!["API_PORT"]
);
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'server'\nport = 3000\n",
)]))
.unwrap();
assert!(set.env_entries().iter().any(
|(d, _)| matches!(d, EnvDirective::Val(k, v, _) if k == "API_PORT" && v == "3000")
));
}
#[test]
fn a_persisted_allocation_wins_over_a_fresh_derivation() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("wt");
std::fs::create_dir_all(&root).unwrap();
let private = tmp.path().join(".git").join("worktrees").join("wt");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(
tmp.path().join(".git").join("HEAD"),
"ref: refs/heads/main\n",
)
.unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
std::fs::write(
root.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
let body = "[daemons.api]\nrun = 'server'\n[daemons.api.port]\nauto = true\nbase = 3000\n";
let cfg = || files(&[(root.join("mise.toml").to_str().unwrap(), body)]);
let derived = load(&cfg()).unwrap().daemons["api"].port.unwrap();
assert_ne!(derived.port, 3000, "a worktree is offset");
let state = runtime::State {
root: root.clone(),
ports: std::collections::BTreeMap::from([(
"api".to_string(),
PortClaim {
port: 3456,
base: 3000,
stride: 1,
},
)]),
..Default::default()
};
let dir = state_dir(&root);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
let set = load(&cfg()).unwrap();
assert_eq!(set.daemons["api"].port.unwrap().port, 3456);
assert_eq!(set.daemons["api"].exports["API_PORT"], "3456");
assert_eq!(
set.daemons["api"].table["port"]["expect"][0].as_integer(),
Some(3456)
);
}
#[test]
fn invalid_auto_port_declarations_are_rejected() {
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\nrun = 'server'\nport = 'auto'\n",
)]);
assert!(
load(&config)
.unwrap_err()
.to_string()
.contains("needs a base port")
);
let config = files(&[(
"/project/mise.toml",
"[daemons.db]\npreset = 'postgres'\nversion = '18'\n[daemons.db.port]\nexpect = [5432]\n",
)]);
assert!(load(&config).is_err());
let config = files(&[(
"/project/mise.toml",
"[daemons.api]\nrun = 'server'\n[daemons.api.port]\nexpect = [3000, 3001]\nbump = true\n",
)]);
let set = load(&config).unwrap();
assert_eq!(
set.daemons["api"].table["port"]["bump"].as_bool(),
Some(true)
);
assert!(set.daemons["api"].port.is_none());
}
#[test]
fn groups_expand_members_and_nested_groups() {
let set = load(&files(&[(
"/project/mise.toml",
r#"
[daemons.postgres]
run = 'postgres'
[daemons.nats]
run = 'nats'
[daemons.core]
run = 'core'
[daemons.core2]
run = 'core2'
[daemons.node0]
run = 'node0'
[daemon_groups]
default = ["postgres", "nats", "core", "node0"]
two-cluster = ["default", "core2"]
[daemon_groups.explicit]
daemons = ["core", "core2"]
"#,
)]))
.unwrap();
assert_eq!(
set.group("default").unwrap().daemons,
["postgres", "nats", "core", "node0"]
);
assert_eq!(
set.group("two-cluster").unwrap().daemons,
["postgres", "nats", "core", "node0", "core2"]
);
assert_eq!(set.group("explicit").unwrap().daemons, ["core", "core2"]);
assert_eq!(set.expand("default").unwrap().len(), 4);
assert!(set.expand("postgres").is_none());
let root = set.daemons["postgres"].root.clone();
assert_eq!(set.for_root(&root).groups.len(), 3);
assert!(set.for_root(root.parent().unwrap()).groups.is_empty());
}
#[test]
fn groups_nest_more_than_one_level() {
let set = load(&files(&[(
"/project/mise.toml",
r#"
[daemons.a]
run = 'a'
[daemons.b]
run = 'b'
[daemons.c]
run = 'c'
[daemon_groups]
one = ["a"]
two = ["one", "b"]
three = ["two", "c"]
"#,
)]))
.unwrap();
assert_eq!(set.group("three").unwrap().daemons, ["a", "b", "c"]);
let err = load(&files(&[(
"/project/mise.toml",
"[daemons.a]\nrun = 'a'\n[daemon_groups]\none = ['two']\ntwo = ['three']\nthree = ['one']\n",
)]))
.unwrap_err()
.to_string();
assert!(err.contains("references itself"), "{err}");
}
#[test]
fn the_table_form_rejects_unknown_keys() {
let parse = |body: &str| MiseToml::from_str(body, &PathBuf::from("/project/mise.toml"));
assert!(parse("[daemon_groups.web]\ndaemons = ['api']\n").is_ok());
let err = parse("[daemon_groups.web]\ndaemons = ['api']\ndeamons = ['api']\n")
.unwrap_err()
.to_string();
assert!(err.contains("TOML"), "{err}");
assert!(parse("[daemon_groups]\nweb = ['api']\n").is_ok());
}
#[test]
fn same_group_name_in_two_projects_is_kept() {
let set = load(&files(&[
(
"/parent/child/mise.toml",
"[daemons.web]\nrun = 'web'\n[daemon_groups]\ndefault = ['web']\n",
),
(
"/parent/mise.toml",
"[daemons.api]\nrun = 'api'\n[daemon_groups]\ndefault = ['api']\n",
),
]))
.unwrap();
assert_eq!(set.groups.len(), 2);
let child = set.daemons["web"].root.clone();
let parent = set.daemons["api"].root.clone();
assert_ne!(child, parent);
assert_eq!(
set.for_root(&child).group("default").unwrap().daemons,
["web"]
);
assert_eq!(
set.for_root(&parent).group("default").unwrap().daemons,
["api"]
);
}
#[test]
fn a_child_overriding_a_daemon_keeps_the_parent_group_loadable() {
let set = load(&files(&[
(
"/parent/child/mise.toml",
"[daemons.postgres]\nrun = 'child postgres'\n",
),
(
"/parent/mise.toml",
"[daemons.postgres]\nrun = 'parent postgres'\n[daemons.api]\nrun = 'api'\n[daemon_groups]\ndefault = ['postgres', 'api']\n",
),
]))
.unwrap();
let parent = set.daemons["api"].root.clone();
assert_eq!(
set.for_root(&parent).group("default").unwrap().daemons,
["postgres", "api"]
);
assert_eq!(
set.daemons["postgres"].table["run"].as_str(),
Some("child postgres")
);
assert!(!set.for_root(&parent).daemons.contains_key("postgres"));
}
#[test]
fn nested_group_references_stay_inside_one_project() {
let err = load(&files(&[
(
"/parent/child/mise.toml",
"[daemons.web]\nrun = 'web'\n[daemon_groups]\nall = ['web', 'shared']\n",
),
(
"/parent/mise.toml",
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nshared = ['api']\n",
),
]))
.unwrap_err()
.to_string();
assert!(
err.contains("is not a daemon or group declared for"),
"{err}"
);
}
#[test]
fn groups_are_scoped_to_one_project_root() {
let err = load(&files(&[
(
"/parent/child/mise.toml",
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nall = ['api', 'outer']\n",
),
("/parent/mise.toml", "[daemons.outer]\nrun = 'outer'\n"),
]))
.unwrap_err()
.to_string();
assert!(
err.contains("is not a daemon or group declared for"),
"{err}"
);
}
#[test]
fn invalid_groups_are_rejected() {
let cases = [
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nweb = ['missing']\n",
"is not a daemon or group",
),
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nweb = []\n",
"requires at least one daemon or group",
),
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\napi = ['api']\n",
"conflicts with the daemon of the same name",
),
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nweb = ['other']\nother = ['web']\n",
"references itself",
),
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nweb = ['web']\n",
"references itself",
),
(
"[daemons.api]\nrun = 'api'\n[daemon_groups]\n'bad name' = ['api']\n",
"invalid daemon group name",
),
];
for (body, expected) in cases {
let err = load(&files(&[("/project/mise.toml", body)]))
.unwrap_err()
.to_string();
assert!(err.contains(expected), "{body:?} produced {err}");
}
}
#[test]
fn daemon_names_cannot_escape_persistent_data_directory() {
for name in [".", "..", "../outside", "a/b", "a\\b"] {
let config = files(&[(
"/project/mise.toml",
&format!(
"[daemons.{}]\npreset = 'postgres'\nversion = '18'",
toml::Value::String(name.into())
),
)]);
assert!(load(&config).is_err());
}
}
fn checkout_pair(tmp: &Path) -> (PathBuf, PathBuf) {
let primary = tmp.join("shop");
std::fs::create_dir_all(primary.join(".git")).unwrap();
std::fs::write(primary.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
let linked = tmp.join("shop-pr-42");
std::fs::create_dir_all(&linked).unwrap();
let private = primary.join(".git").join("worktrees").join("pr-42");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
std::fs::write(
linked.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
(primary, linked)
}
#[test]
fn a_hostname_names_the_daemon_the_checkout_and_the_project() {
let tmp = tempfile::tempdir().unwrap();
let (primary, linked) = checkout_pair(tmp.path());
let body =
"[daemons_settings]\nnamespace = 'shop'\n[daemons.api]\nrun = 'server'\nport = 3000\n";
let host = |root: &Path| {
let set = load(&files(&[(root.join("mise.toml").to_str().unwrap(), body)])).unwrap();
set.daemons["api"].host.clone()
};
assert_eq!(host(&primary).as_deref(), Some("api.shop.localhost"));
assert_eq!(
host(&linked).as_deref(),
Some("api.shop-pr-42.shop.localhost")
);
std::fs::write(linked.join("pitchfork.toml"), "worktree_label = 'pr-42'\n").unwrap();
assert_eq!(host(&linked).as_deref(), Some("api.pr-42.shop.localhost"));
let bare = "[daemons.api]\nrun = 'server'\nport = 3000\n";
for root in [&primary, &linked] {
let set = load(&files(&[(root.join("mise.toml").to_str().unwrap(), bare)])).unwrap();
assert!(
set.daemons["api"]
.host
.as_deref()
.unwrap()
.ends_with("shop.localhost"),
"{:?}",
set.daemons["api"].host
);
}
}
#[test]
fn a_daemons_url_is_exported_beside_its_port() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let set = load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'shop'\n\
[daemons.api]\nrun = 'server'\nport = 3000\n\
[daemons.web]\nrun = 'web'\nport = 5173\nproxy = 'front'\n\
[daemons.cache]\nrun = 'cache'\nproxy = false\nport = 6380\n",
)]))
.unwrap();
assert_eq!(set.daemons["api"].exports["API_PORT"], "3000");
assert_eq!(
set.daemons["api"].exports["API_URL"],
"https://api.shop.localhost"
);
assert_eq!(
set.daemons["web"].exports["WEB_URL"],
"https://front.shop.localhost"
);
assert_eq!(set.daemons["cache"].exports["CACHE_PORT"], "6380");
assert!(!set.daemons["cache"].exports.contains_key("CACHE_URL"));
assert!(set.daemons["cache"].host.is_none());
assert!(set.env_entries().iter().any(|(d, _)| matches!(
d,
EnvDirective::Val(k, v, _) if k == "API_URL" && v == "https://api.shop.localhost"
)));
}
#[test]
fn proxy_declarations_are_validated_and_forwarded() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let load_body = |body: &str| {
load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
&format!("[daemons_settings]\nnamespace = 'shop'\n{body}"),
)]))
};
let set = load_body(
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = 'front'\nproxy_tls = 'passthrough'\n",
)
.unwrap();
let table = &set.daemons["api"].table;
assert_eq!(table["proxy"].as_str(), Some("front"));
assert_eq!(table["proxy_tls"].as_str(), Some("passthrough"));
assert_eq!(
load_body("[daemons.api]\nrun = 'server'\nport = 3000\nproxy = false\n")
.unwrap()
.daemons["api"]
.table["proxy"]
.as_bool(),
Some(false)
);
let set =
load_body("[daemons.db]\npreset = 'postgres'\nversion = '18'\nproxy = true\n").unwrap();
assert_eq!(set.daemons["db"].host.as_deref(), Some("db.shop.localhost"));
for invalid in [
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = 3000\n",
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = 'Front'\n",
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = 'front_end'\n",
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy_tls = 'reencrypt'\n",
"[daemons.api]\nrun = 'server'\nport = 3000\nproxy = false\nproxy_tls = 'terminate'\n",
] {
assert!(load_body(invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn an_imported_daemon_does_not_claim_this_projects_variables() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'mirror'\n[daemons.worker]\nrun = 'exec worker'\nport = 4000\n",
);
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join(".git")).unwrap();
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons_settings]\nnamespace = 'app'\n\
[daemons.worker]\nrun = 'exec worker'\nport = 4000\n\
[daemons.remote]\nproject = '{}'\nname = 'worker'\n",
mirror.display()
),
)]))
.unwrap();
assert_eq!(
set.daemons["worker"].exports["WORKER_URL"],
"https://worker.app.localhost"
);
assert!(set.env_entries().iter().any(|(d, _)| matches!(
d,
EnvDirective::Val(k, _, _) if k == "WORKER_URL"
)));
}
#[test]
fn two_daemons_cannot_share_one_hostname() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let load_body = |body: &str| {
load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
&format!("[daemons_settings]\nnamespace = 'shop'\n{body}"),
)]))
};
for body in [
"[daemons.api]\nrun = 'a'\nport = 3000\n[daemons.web]\nrun = 'b'\nport = 3001\nproxy = 'api'\n",
"[daemons.web]\nrun = 'b'\nport = 3001\nproxy = 'api'\n[daemons.api]\nrun = 'a'\nport = 3000\n",
"[daemons.web-ui]\nrun = 'a'\nport = 3000\n[daemons.web_ui]\nrun = 'b'\nport = 3001\n",
"[daemons.web]\nrun = 'b'\nport = 3001\nproxy = 'front'\n[daemons.api]\nrun = 'a'\nport = 3000\nproxy = 'front'\n",
] {
let set = load_body(body).unwrap();
for daemon in set.daemons.values() {
assert!(daemon.host.is_none(), "{} in {body:?}", daemon.name);
assert_eq!(daemon.table["proxy"].as_bool(), Some(false), "{body:?}");
assert!(
!daemon.exports.keys().any(|key| key.ends_with("_URL")),
"{body:?}"
);
}
assert!(set.daemons.values().all(|d| d.port.is_some()), "{body:?}");
}
let set = load_body(
"[daemons.db]\npreset = 'postgres'\nversion = '18'\nproxy = true\n\
[daemons.web]\nrun = 'b'\nport = 3001\nproxy = 'db'\n",
)
.unwrap();
for name in ["db", "web"] {
assert!(set.daemons[name].host.is_none(), "{name} kept a hostname");
assert_eq!(set.daemons[name].table["proxy"].as_bool(), Some(false));
}
let set = load_body(
"[daemons.redis]\npreset = 'redis'\nversion = '8'\nproxy = true\n\
[daemons.web]\nrun = 'b'\nport = 3001\nproxy = 'redis'\n",
)
.unwrap();
assert!(set.daemons["redis"].host.is_none());
assert!(
set.daemons["redis"].exports["REDIS_URL"].starts_with("redis://"),
"the preset's own connection string is not the withdrawn URL: {:?}",
set.daemons["redis"].exports
);
let set = load_body(
"[daemons.api]\nrun = 'a'\nport = 3000\n[daemons.web]\nrun = 'b'\nport = 3001\n",
)
.unwrap();
assert_eq!(
set.daemons["api"].host.as_deref(),
Some("api.shop.localhost")
);
assert_eq!(
set.daemons["web"].host.as_deref(),
Some("web.shop.localhost")
);
}
#[test]
fn two_projects_sharing_a_directory_name_collide_on_hostname() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("one").join("api");
std::fs::create_dir_all(mirror.join(".git")).unwrap();
referenced_project(&mirror, "[daemons.web]\nrun = 'exec web'\nport = 3000\n");
let root = tmp.path().join("two").join("api");
std::fs::create_dir_all(root.join(".git")).unwrap();
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.web]\nrun = 'exec web'\nport = 3001\n\
[daemons.remote]\nproject = '{}'\nname = 'web'\n",
mirror.display()
),
)]))
.unwrap();
assert!(set.daemons["web"].host.is_none());
assert_eq!(set.daemons["web"].table["proxy"].as_bool(), Some(false));
assert!(
!set.daemons
.values()
.any(|d| d.exports.contains_key("WEB_URL"))
);
let imported = set.daemons.values().find(|d| d.imported).unwrap();
assert_eq!(imported.host.as_deref(), Some("web.api.localhost"));
assert_eq!(imported.table["proxy"].as_str(), Some("web"));
assert_eq!(set.daemons["web"].exports["WEB_PORT"], "3001");
}
#[test]
fn two_daemons_cannot_claim_one_port() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let load_body = |body: &str| {
load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
body,
)]))
};
for body in [
"[daemons.main]\npreset = 'postgres'\nversion = '18'\nport = 'auto'\n\
[daemons.analytics]\npreset = 'postgres'\nversion = '18'\nport = 'auto'\n",
"[daemons.api]\nrun = 'a'\nport = 3000\n[daemons.web]\nrun = 'b'\nport = 3000\n",
"[daemons.api]\nrun = 'a'\n[daemons.api.port]\nauto = true\nbase = 3000\n\
[daemons.web]\nrun = 'b'\n[daemons.web.port]\nauto = true\nbase = 3000\n",
] {
let err = load_body(body).unwrap_err().to_string();
assert!(err.contains("both use port"), "{body:?} produced {err}");
}
#[cfg(unix)]
{
let link = tmp.path().join("linked");
std::os::unix::fs::symlink(&primary, &link).unwrap();
let err = load(&files(&[
(
primary.join("mise.toml").to_str().unwrap(),
"[daemons.api]\nrun = 'a'\n[daemons.api.port]\nauto = true\nbase = 3000\n",
),
(
link.join("mise.local.toml").to_str().unwrap(),
"[daemons.web]\nrun = 'b'\n[daemons.web.port]\nauto = true\nbase = 3000\n",
),
]))
.unwrap_err()
.to_string();
assert!(err.contains("both use port 3000"), "{err}");
}
let set = load_body(
"[daemons.main]\npreset = 'postgres'\nversion = '18'\nport = 'auto'\n\
[daemons.analytics]\npreset = 'postgres'\nversion = '18'\n\
[daemons.analytics.port]\nauto = true\nbase = 5500\n",
)
.unwrap();
assert_ne!(
set.daemons["main"].port.unwrap().port,
set.daemons["analytics"].port.unwrap().port
);
}
#[test]
fn a_nearer_daemon_outranks_an_ancestors_group_of_the_same_name() {
let set = load(&files(&[
(
"/parent/child/mise.toml",
"[daemons.web]\nrun = 'child web'\n",
),
(
"/parent/mise.toml",
"[daemons.api]\nrun = 'api'\n[daemon_groups]\nweb = ['api']\n",
),
]))
.unwrap();
let child = set.daemons["web"].root.clone();
let parent = set.daemons["api"].root.clone();
assert!(
child != parent && child.starts_with(&parent),
"the two projects must be nested and distinct for this to mean anything: {child:?} under {parent:?}"
);
assert!(matches!(
set.resolve_bare(&child, "web"),
Some(BareName::Daemon)
));
assert!(matches!(
set.resolve_bare(&parent, "web"),
Some(BareName::Group)
));
}
#[test]
fn a_group_cannot_name_an_imported_daemon() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
let mirror = tmp.path().join("mirror");
referenced_project(
&mirror,
"[daemons_settings]\nnamespace = 'mirror'\n[daemons.worker]\nrun = 'exec worker'\n",
);
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join(".git")).unwrap();
let err = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons_settings]\nnamespace = 'app'\n\
[daemons.api]\nrun = 'exec api'\n\
[daemons.remote]\nproject = '{}'\nname = 'worker'\n\
[daemon_groups]\nstack = ['api', 'remote']\n",
mirror.display()
),
)]))
.unwrap_err()
.to_string();
assert!(err.contains("reaches with `project`"), "{err}");
}
#[test]
fn a_presets_export_is_not_replaced_by_a_derived_one() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let set = load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'shop'\n\
[daemons.db]\npreset = 'postgres'\nversion = '18'\n\
[daemons.database]\nrun = 'gateway'\nport = 8080\n",
)]))
.unwrap();
assert!(
set.daemons["db"].exports["DATABASE_URL"].starts_with("postgresql://"),
"{:?}",
set.daemons["db"].exports
);
assert!(!set.daemons["database"].exports.contains_key("DATABASE_URL"));
assert_eq!(
set.daemons["database"].host.as_deref(),
Some("database.shop.localhost")
);
}
#[test]
fn two_imported_claimants_leave_no_url_to_advertise() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
for (dir, port) in [("one", 3000), ("two", 3001)] {
let project = tmp.path().join(dir).join("api");
std::fs::create_dir_all(project.join(".git")).unwrap();
referenced_project(
&project,
&format!("[daemons.web]\nrun = 'exec web'\nport = {port}\n"),
);
}
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join(".git")).unwrap();
let set = load(&files(&[(
root.join("mise.toml").to_str().unwrap(),
&format!(
"[daemons.first]\nproject = '{}'\nname = 'web'\n\
[daemons.second]\nproject = '{}'\nname = 'web'\n",
tmp.path().join("one").join("api").display(),
tmp.path().join("two").join("api").display(),
),
)]))
.unwrap();
let imported: Vec<_> = set.daemons.values().filter(|d| d.imported).collect();
assert_eq!(imported.len(), 2, "both imports must have resolved");
for daemon in imported {
assert!(daemon.host.is_none(), "{} kept a hostname", daemon.name);
}
let mut declared = set.clone();
for daemon in declared.daemons.values_mut() {
daemon.host = Some("web.api.localhost".into());
daemon
.exports
.insert("WEB_URL".into(), "https://web.api.localhost".into());
}
let visible = declared.restricted_to(&set);
assert_eq!(visible.daemons.len(), set.daemons.len());
for daemon in visible.daemons.values() {
assert!(daemon.host.is_none(), "{} still shows a URL", daemon.name);
assert!(!daemon.exports.contains_key("WEB_URL"));
}
}
#[test]
fn only_a_lone_imported_side_keeps_a_collided_hostname() {
assert_eq!(routed_claimant(&[false, false]), None);
assert_eq!(routed_claimant(&[true, true]), None);
assert_eq!(routed_claimant(&[true, false]), Some(0));
assert_eq!(routed_claimant(&[false, true]), Some(1));
assert_eq!(routed_claimant(&[false, false, true]), Some(2));
assert_eq!(routed_claimant(&[true, false, true]), None);
}
#[test]
fn an_imported_presets_exports_do_not_take_a_local_ones() {
let _serial = import_lock();
let tmp = tempfile::tempdir().unwrap();
referenced_project(
&tmp.path().join("owner"),
"[daemons_settings]\nnamespace = 'remote'\n\
[daemons.db]\npreset = 'postgres'\nversion = '18'\n",
);
let app = tmp.path().join("app");
std::fs::create_dir_all(&app).unwrap();
let set = load(&files(&[(
app.join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'shop'\n\
[daemons.store]\nproject = '../owner'\nname = 'db'\n\
[daemons.database]\nrun = 'gateway'\nport = 8080\n",
)]))
.unwrap();
assert!(set.daemons["remote/db"].imported);
let exported: Vec<_> = set
.env_entries()
.into_iter()
.filter_map(|(directive, _)| match directive {
EnvDirective::Val(key, value, _) if key == "DATABASE_URL" => Some(value),
_ => None,
})
.collect();
assert_eq!(exported, ["https://database.shop.localhost"]);
}
#[test]
fn database_presets_stay_off_the_proxy() {
let tmp = tempfile::tempdir().unwrap();
let (primary, _) = checkout_pair(tmp.path());
let set = load(&files(&[(
primary.join("mise.toml").to_str().unwrap(),
"[daemons_settings]\nnamespace = 'shop'\n[daemons]\npostgres = '18'\nredis = '8'\n",
)]))
.unwrap();
for name in ["postgres", "redis"] {
let daemon = &set.daemons[name];
assert!(daemon.host.is_none(), "{name} is not an HTTP service");
assert_eq!(daemon.table["proxy"].as_bool(), Some(false));
assert!(
!daemon
.exports
.values()
.any(|value| value.starts_with("http")),
"{name} must not be handed a proxy URL"
);
}
assert!(set.daemons["postgres"].exports.contains_key("DATABASE_URL"));
assert!(set.daemons["redis"].exports["REDIS_URL"].starts_with("redis://"));
}
}