use super::ports::PortClaim;
use super::{DaemonSet, DaemonSettings, state_dir};
use crate::cli::args::ToolArg;
use crate::cmd::CmdLineRunner;
use crate::config::Config;
use crate::env_diff::EnvMap;
use crate::toolset::{ToolRequest, ToolSource, Toolset, ToolsetBuilder};
use eyre::{Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct State {
pub root: PathBuf,
pub profile: Vec<String>,
pub namespace: String,
pub ids: Vec<String>,
pub bin: PathBuf,
pub config_hash: String,
#[serde(default)]
pub ports: BTreeMap<String, PortClaim>,
#[serde(default)]
pub ports_scan: String,
}
fn siblings_fingerprint(mine: &Path) -> String {
let entries = std::fs::read_dir(crate::dirs::STATE.join("daemons"));
let mut seen: Vec<String> = entries
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path() != mine)
.filter_map(|e| {
let state = e.path().join("state.json");
let meta = std::fs::metadata(&state).ok()?;
let stamp = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or_default();
Some(format!("{}:{}:{stamp}", state.display(), meta.len()))
})
.collect();
seen.sort();
crate::hash::hash_to_str(&seen)
}
pub(crate) struct Runtime {
pub bin: PathBuf,
pub env: EnvMap,
}
pub(crate) fn read_state(root: &Path) -> Result<State> {
let path = state_dir(root).join("state.json");
if !path.exists() {
return Ok(State {
root: root.into(),
..State::default()
});
}
Ok(serde_json::from_slice(&std::fs::read(path)?)?)
}
fn carry_port_claims(
previous: &BTreeMap<String, PortClaim>,
set: &DaemonSet,
) -> BTreeMap<String, PortClaim> {
let mut ports: BTreeMap<String, PortClaim> = previous
.iter()
.filter(|(name, _)| !set.daemons.contains_key(*name))
.map(|(name, claim)| (name.clone(), *claim))
.collect();
ports.extend(
set.daemons
.values()
.filter_map(|d| d.port.map(|claim| (d.name.clone(), claim))),
);
ports
}
fn ports_being_started(
ports: &BTreeMap<String, PortClaim>,
starting: &[String],
) -> BTreeMap<String, PortClaim> {
ports
.iter()
.filter(|(name, _)| starting.iter().any(|s| s == *name))
.map(|(name, claim)| (name.clone(), *claim))
.collect()
}
fn claimed_ports(mine: &Path) -> Vec<(State, String, u16)> {
let Ok(entries) = std::fs::read_dir(crate::dirs::STATE.join("daemons")) else {
return Vec::new();
};
let mut found = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
if dir == mine || !dir.is_dir() {
continue;
}
let Ok(other) = std::fs::read(dir.join("state.json")) else {
continue;
};
let Ok(other) = serde_json::from_slice::<State>(&other) else {
continue;
};
if !other.root.is_dir() {
continue;
}
for (name, claim) in other.ports.clone() {
found.push((other.clone(), name, claim.port));
}
}
found
}
pub(crate) fn write_if_changed(path: &Path, content: &[u8]) -> Result<bool> {
if std::fs::read(path).ok().as_deref() == Some(content) {
return Ok(false);
}
let parent = path
.parent()
.ok_or_else(|| eyre::eyre!("missing parent directory"))?;
std::fs::create_dir_all(parent)?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
temp.write_all(content)?;
temp.as_file().sync_all()?;
temp.persist(path)?;
Ok(true)
}
pub(crate) async fn config_for_root(config: &Arc<Config>, root: &Path) -> Result<Arc<Config>> {
let (paths, idiomatic) = crate::config::load_config_hierarchy_from_dir(root).await?;
let files = crate::config::load_config_files_from_paths(&paths, &idiomatic).await?;
Ok(config.with_config_files(files))
}
pub(crate) async fn toolset_resolved(
config: &Arc<Config>,
include_pitchfork: bool,
) -> Result<Toolset> {
let pitchfork: ToolArg = "pitchfork".parse()?;
let args = if include_pitchfork
&& !config
.get_tool_request_set()
.await?
.tools
.contains_key(&pitchfork.ba)
{
vec![pitchfork]
} else {
vec![]
};
ToolsetBuilder::new()
.with_args(&args)
.with_default_to_latest(true)
.with_resolve_options(crate::toolset::ResolveOptions {
offline: true,
..Default::default()
})
.build(config)
.await
}
pub(crate) async fn toolset(config: &Arc<Config>, install: bool) -> Result<(Arc<Config>, Toolset)> {
let mut config = config.clone();
let mut ts = toolset_resolved(&config, install).await?;
if install {
let (_, missing) = ts
.install_missing_versions(&mut config, &Default::default())
.await?;
ts.notify_missing_versions(missing);
}
Ok((config, ts))
}
impl Runtime {
pub(crate) async fn from_toolset(
config: &Arc<Config>,
ts: &Toolset,
fallback: Option<&Path>,
) -> Result<Self> {
let env = ts.env_with_path(config).await?;
let bin = which::which_in(
"pitchfork",
env.get(&*crate::env::PATH_KEY),
crate::dirs::CWD.as_deref().unwrap_or(Path::new(".")),
)
.ok()
.or_else(|| fallback.filter(|p| p.is_file()).map(Path::to_path_buf))
.ok_or_else(|| {
eyre::eyre!(
"daemons require pitchfork; run `mise use pitchfork` or `mise daemons start`"
)
})?;
Ok(Self { bin, env })
}
pub(crate) async fn output(&self, root: &Path, args: &[String]) -> Result<String> {
let output = self.raw_output(root, args).await?;
if !output.status.success() {
bail!(
"pitchfork {}: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?)
}
pub(crate) async fn raw_output(
&self,
root: &Path,
args: &[String],
) -> Result<std::process::Output> {
let mut command = Command::new(&self.bin);
command
.args(args)
.envs(&self.env)
.env_remove("PITCHFORK_CONFIG")
.current_dir(root)
.kill_on_drop(true);
Ok(tokio::time::timeout(Duration::from_secs(15), command.output()).await??)
}
pub(crate) async fn supports_external_config(&self, root: &Path) -> Result<()> {
let usage = self.output(root, &["usage".into()]).await?;
if !usage.lines().any(|line| {
line.trim_start().starts_with("cmd config ")
|| line.trim_start().starts_with("cmd \"config\" ")
}) {
bail!(
"pitchfork lacks external configuration support; upgrade to pitchfork 2.25.0 or later"
);
}
Ok(())
}
pub(crate) async fn status(&self, root: &Path, id: &str) -> Result<serde_json::Value> {
let out = self
.output(root, &["status".into(), id.into(), "--json".into()])
.await?;
Ok(serde_json::from_str(&out)?)
}
pub(crate) async fn supervisor_up(&self, root: &Path) -> Result<bool> {
let out = self
.output(
root,
&["supervisor".into(), "status".into(), "--json".into()],
)
.await?;
let status: serde_json::Value = serde_json::from_str(&out)?;
match status["status"].as_str() {
Some("up") => Ok(true),
Some("down") => Ok(false),
_ => bail!("cannot establish pitchfork supervisor status: {status}"),
}
}
pub(crate) async fn active(&self, root: &Path, state: &State) -> Result<bool> {
for id in &state.ids {
if let Ok(value) = self.status(root, id).await
&& matches!(
value["status"].as_str(),
Some("running" | "waiting" | "stopping")
)
{
return Ok(true);
}
}
if self.supervisor_up(root).await? {
let sessions: Vec<serde_json::Value> = serde_json::from_str(
&self
.output(root, &["project".into(), "list".into(), "--json".into()])
.await?,
)?;
if sessions.iter().any(|s| {
s["directory"]
.as_str()
.is_some_and(|d| Path::new(d) == root)
&& s["liveness_status"] != "dead"
}) {
return Ok(true);
}
}
Ok(false)
}
async fn check_port_conflicts(
&self,
root: &Path,
ports: &BTreeMap<String, PortClaim>,
starting: &[String],
) -> Result<bool> {
let claimed: Vec<_> = claimed_ports(&state_dir(root))
.into_iter()
.filter(|(other, _, _)| other.root != root)
.collect();
let clear = !ports
.values()
.any(|claim| claimed.iter().any(|(_, _, port)| *port == claim.port));
let ports = &ports_being_started(ports, starting);
if ports.is_empty() {
return Ok(clear);
}
for (other, other_name, port) in claimed {
let Some((name, _)) = ports.iter().find(|(_, claim)| claim.port == port) else {
continue;
};
let id = other
.ids
.iter()
.find(|id| id.rsplit('/').next() == Some(other_name.as_str()))
.cloned()
.unwrap_or_else(|| format!("{}/{other_name}", other.namespace));
let serving = self
.status(&other.root, &id)
.await
.ok()
.and_then(|value| value["status"].as_str().map(String::from))
.is_some_and(|status| {
matches!(status.as_str(), "running" | "waiting" | "stopping")
});
if !serving {
continue;
}
bail!(
"daemon {name} would use port {port}, in use by {other_name} running in {}. \
Stop it, set an explicit port on one of them, or use \
port = {{ auto = true, base = <port> }} to move this project's range.",
other.root.display()
);
}
Ok(clear)
}
pub(crate) async fn prepare(
&self,
root: &Path,
set: &DaemonSet,
force_registration: bool,
owns_profile: bool,
starting: &[String],
) -> Result<(State, super::ProjectLock)> {
let providers = if starting.is_empty() {
set.clone()
} else {
set.with_dependencies(starting)
};
super::providers::prepare_set(self, &providers, force_registration).await?;
let lock = super::ProjectLock::acquire(root)?;
let previous = read_state(root)?;
let profile = if root.starts_with(crate::dirs::STATE.join("daemon-providers")) {
vec![]
} else {
crate::env::MISE_ENV.clone()
};
if !previous.namespace.is_empty()
&& previous.profile != profile
&& self.active(root, &previous).await?
{
if owns_profile {
bail!(
"another daemon profile is active for {}; stop its daemons and leave its shell sessions before switching MISE_ENV",
root.display()
);
}
bail!(
"{} has daemons running under MISE_ENV {:?}, and this project would register it under {:?}; stop them, or match that profile, before starting a daemon imported from it",
root.display(),
previous.profile.join(","),
profile.join(",")
);
}
let desired = match set.namespace_for(root) {
Some(namespace) => namespace.to_string(),
None => namespace(root)?,
};
let changed = !previous.namespace.is_empty() && previous.namespace != desired;
if changed && self.active(root, &previous).await? {
bail!(
"daemons for {} are registered under namespace {:?} but the configuration now asks for {:?}; stop them before changing the namespace",
root.display(),
previous.namespace,
desired
);
}
let ports = carry_port_claims(&previous.ports, set);
let mut state = State {
root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
profile,
namespace: desired,
ids: if changed { Vec::new() } else { previous.ids },
bin: self.bin.clone(),
config_hash: String::new(),
ports,
ports_scan: String::new(),
};
for daemon in set.daemons.values() {
let id = format!("{}/{}", state.namespace, daemon.name);
if !state.ids.contains(&id) {
state.ids.push(id);
}
}
let content = render(set, &state)?;
let file = state_dir(root).join("pitchfork.toml");
state.config_hash = crate::hash::hash_to_str(&content);
let scan = siblings_fingerprint(&state_dir(root));
let reusable = !force_registration
&& !previous.ports_scan.is_empty()
&& scan == previous.ports_scan
&& state.ports == previous.ports;
state.ports_scan = if reusable
|| self
.check_port_conflicts(root, &state.ports, starting)
.await?
{
scan
} else {
String::new()
};
if !force_registration
&& !changed
&& state.config_hash == previous.config_hash
&& std::fs::read(&file).ok().as_deref() == Some(content.as_bytes())
{
write_if_changed(
&state_dir(root).join("state.json"),
&serde_json::to_vec_pretty(&state)?,
)?;
return Ok((state, lock));
}
self.supports_external_config(root).await?;
if changed || set.daemons.is_empty() {
self.output(
root,
&[
"config".into(),
"remove".into(),
file.to_string_lossy().into_owned(),
],
)
.await?;
}
write_if_changed(&file, content.as_bytes())?;
if !set.daemons.is_empty() {
self.output(
root,
&[
"config".into(),
"add".into(),
file.to_string_lossy().into_owned(),
"--dir".into(),
root.to_string_lossy().into_owned(),
"--namespace".into(),
state.namespace.clone(),
],
)
.await?;
}
write_if_changed(
&state_dir(root).join("state.json"),
&serde_json::to_vec_pretty(&state)?,
)?;
Ok((state, lock))
}
pub(crate) async fn exec(&self, root: &Path, args: Vec<String>) -> Result<()> {
let mut runner = CmdLineRunner::new(&self.bin)
.args(args)
.envs(&self.env)
.env_remove("PITCHFORK_CONFIG")
.current_dir(root)
.raw(true);
runner.with_pass_signals();
runner.execute_async().await
}
}
pub(crate) fn resolve_namespace(root: &Path, settings: Option<&DaemonSettings>) -> Result<String> {
let Some(explicit) = settings.and_then(|s| s.namespace.as_deref()) else {
return namespace(root);
};
crate::daemons::validate_namespace(explicit)?;
if settings.is_none_or(|s| s.namespace_per_worktree()) && crate::git::in_linked_worktree(root) {
return Ok(format!(
"{explicit}-{}",
crate::hash::hash_to_str(&root.canonicalize().unwrap_or_else(|_| root.to_path_buf()))
));
}
Ok(explicit.into())
}
pub(crate) fn namespace(root: &Path) -> Result<String> {
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let mut has_native = false;
for name in [
"pitchfork.local.toml",
"pitchfork.toml",
".config/pitchfork.local.toml",
".config/pitchfork.toml",
] {
let path = root.join(name);
if path.exists() {
let doc: toml::Value = toml::from_str(&std::fs::read_to_string(path)?)?;
if let Some(namespace) = doc.get("namespace").and_then(toml::Value::as_str) {
return Ok(namespace.into());
}
has_native = true;
}
}
if has_native {
return Ok(root
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned());
}
let base: String = root
.file_name()
.unwrap_or_default()
.to_string_lossy()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
c
} else {
'-'
}
})
.collect();
let base = base
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
let base = if base.is_empty() { "mise" } else { &base };
Ok(format!("{base}-{}", crate::hash::hash_to_str(&root)))
}
pub(crate) fn render(set: &DaemonSet, state: &State) -> Result<String> {
let mut daemons = toml::Table::new();
let mut header =
String::from("# Generated by mise; edit [daemons] in the source configuration.\n");
if set.daemons.values().any(|d| d.host.is_some()) {
header.push_str(&format!(
"# Hostname routing (per-daemon proxy labels) needs pitchfork {}.\n",
crate::daemons::urls::REQUIRED_PITCHFORK
));
}
for daemon in set.daemons.values() {
header.push_str(&format!(
"# {} from {}\n",
daemon.name,
daemon.source.to_string_lossy().replace(['\r', '\n'], " ")
));
let mut table = daemon.table.clone();
if let Some(binding) = &daemon.provider {
table.insert(
"depends".into(),
toml::Value::Array(vec![binding.provider.id().into()]),
);
}
if cfg!(unix)
&& daemon.preset.is_none()
&& daemon.task.is_none()
&& table.get("mise").and_then(toml::Value::as_bool) != Some(false)
{
super::presets::wrap_probe_commands(&mut table);
}
if !state.profile.is_empty()
&& (daemon.task.is_some()
|| table.get("mise").and_then(toml::Value::as_bool) != Some(false))
{
let env = table
.entry("env".to_string())
.or_insert(toml::Value::Table(toml::Table::new()))
.as_table_mut()
.ok_or_else(|| eyre::eyre!("daemon env must be a table"))?;
env.insert(
"MISE_ENV".into(),
toml::Value::String(state.profile.join(",")),
);
}
daemons.insert(daemon.name.clone(), toml::Value::Table(table));
}
let mut groups = toml::Table::new();
for group in &set.groups {
let members = group
.daemons
.iter()
.filter(|name| set.daemons.contains_key(*name))
.map(|name| toml::Value::String(format!("{}/{name}", state.namespace)))
.collect::<Vec<_>>();
if members.is_empty() {
continue;
}
groups.insert(
group.name.clone(),
toml::Value::Table(toml::Table::from_iter([(
"daemons".into(),
toml::Value::Array(members),
)])),
);
}
let mut doc = toml::Table::new();
doc.insert(
"settings".into(),
toml::Value::Table(toml::Table::from_iter([(
"general".into(),
toml::Value::Table(toml::Table::from_iter([(
"mise_bin".into(),
toml::Value::String(crate::env::MISE_BIN.to_string_lossy().into_owned()),
)])),
)])),
);
doc.insert("daemons".into(), toml::Value::Table(daemons));
if !groups.is_empty() {
doc.insert("groups".into(), toml::Value::Table(groups));
}
Ok(header + &toml::to_string_pretty(&doc)?)
}
pub(crate) async fn validate_tools(
set: &DaemonSet,
config: &Arc<Config>,
ts: &Toolset,
) -> Result<()> {
for daemon in set.daemons.values() {
let Some((tool, version)) = daemon.tool.as_ref().filter(|_| !daemon.imported) else {
continue;
};
if cfg!(windows) {
bail!("daemon presets are not supported on Windows yet");
}
let ba: crate::cli::args::BackendArg = tool.as_str().into();
let Some(versions) = ts.versions.get(&ba) else {
bail!("daemon {} requires {tool}@{version}", daemon.name);
};
let actual = versions
.versions
.first()
.ok_or_else(|| eyre::eyre!("missing {tool}"))?;
let compatible = ba
.backend()?
.list_installed_versions_matching(version)
.contains(&actual.version);
if compatible {
continue;
}
let requested = ToolRequest::new(
ba.into(),
version,
ToolSource::MiseTomlDaemon(daemon.source.clone()),
)?
.resolve(
config,
&crate::toolset::ResolveOptions {
offline: true,
..Default::default()
},
)
.await?;
if actual.version != requested.version {
bail!(
"daemon {} requires {tool}@{version} ({}), but [tools] selects {}",
daemon.name,
requested.version,
actual.version
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn symlinked_roots_share_namespace_and_state() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let link = tmp.path().join("alias");
std::fs::create_dir(&root).unwrap();
std::os::unix::fs::symlink(&root, &link).unwrap();
assert_eq!(namespace(&root).unwrap(), namespace(&link).unwrap());
assert_eq!(state_dir(&root), state_dir(&link));
}
fn settings(namespace: &str, per_worktree: bool) -> DaemonSettings {
DaemonSettings {
namespace: Some(namespace.to_string()),
namespace_per_worktree: Some(per_worktree),
}
}
#[test]
fn explicit_namespace_wins_over_the_hashed_default() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
std::fs::create_dir(&root).unwrap();
assert_eq!(
resolve_namespace(&root, Some(&settings("entiredb", true))).unwrap(),
"entiredb"
);
assert_eq!(
resolve_namespace(&root, None).unwrap(),
namespace(&root).unwrap()
);
assert!(
resolve_namespace(&root, Some(&settings("entire--db", true)))
.unwrap_err()
.to_string()
.contains("invalid daemon namespace")
);
}
#[test]
fn linked_worktrees_get_their_own_namespace_unless_disabled() {
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("repo");
let private = main.join(".git").join("worktrees").join("feature");
std::fs::create_dir_all(&private).unwrap();
std::fs::write(private.join("commondir"), "../..\n").unwrap();
std::fs::write(main.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
let worktree = tmp.path().join("feature");
std::fs::create_dir(&worktree).unwrap();
std::fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", private.display()),
)
.unwrap();
let suffixed = resolve_namespace(&worktree, Some(&settings("entiredb", true))).unwrap();
assert!(
suffixed.starts_with("entiredb-") && suffixed != "entiredb",
"{suffixed}"
);
let nested = worktree.join("services");
std::fs::create_dir(&nested).unwrap();
assert!(
resolve_namespace(&nested, Some(&settings("entiredb", true)))
.unwrap()
.starts_with("entiredb-")
);
assert_eq!(
resolve_namespace(&worktree, Some(&settings("entiredb", false))).unwrap(),
"entiredb"
);
assert_eq!(
resolve_namespace(&main, Some(&settings("entiredb", true))).unwrap(),
"entiredb"
);
let submodule = tmp.path().join("sub");
std::fs::create_dir(&submodule).unwrap();
std::fs::write(submodule.join(".git"), "gitdir: ../repo/.git/modules/sub\n").unwrap();
assert_eq!(
resolve_namespace(&submodule, Some(&settings("entiredb", true))).unwrap(),
"entiredb"
);
let forged = tmp.path().join("forged");
std::fs::create_dir(&forged).unwrap();
std::fs::write(
forged.join(".git"),
format!("gitdir: {}\n", main.join(".git/worktrees/pruned").display()),
)
.unwrap();
assert_eq!(
resolve_namespace(&forged, Some(&settings("entiredb", true))).unwrap(),
"entiredb"
);
}
#[test]
fn only_live_roots_with_a_matching_port_become_conflict_candidates() {
let tmp = tempfile::tempdir().unwrap();
let mine = tmp.path().join("mine");
let theirs = tmp.path().join("theirs");
std::fs::create_dir_all(&mine).unwrap();
std::fs::create_dir_all(&theirs).unwrap();
let write_state = |root: &Path, port: u16| {
let state = State {
root: root.to_path_buf(),
ports: BTreeMap::from([("db".to_string(), PortClaim::fixed(port))]),
..State::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();
};
write_state(&theirs, 5432);
write_state(&mine, 5432);
let found = claimed_ports(&state_dir(&mine));
let ours: Vec<_> = found.iter().filter(|(s, _, _)| s.root == mine).collect();
assert!(ours.is_empty(), "own claim must be skipped");
let theirs_found: Vec<_> = found
.iter()
.filter(|(s, name, port)| s.root == theirs && name == "db" && *port == 5432)
.collect();
assert_eq!(theirs_found.len(), 1, "live sibling claim must be reported");
std::fs::remove_dir_all(&theirs).unwrap();
assert!(
!claimed_ports(&state_dir(&mine))
.iter()
.any(|(s, _, _)| s.root == theirs)
);
}
#[test]
fn a_scan_is_only_clear_when_no_neighbour_names_any_of_our_ports() {
let tmp = tempfile::tempdir().unwrap();
let mine = tmp.path().join("mine");
let other = tmp.path().join("other");
std::fs::create_dir_all(&mine).unwrap();
std::fs::create_dir_all(&other).unwrap();
let state = State {
root: other.clone(),
ports: BTreeMap::from([("db".to_string(), PortClaim::fixed(5432))]),
..State::default()
};
let dir = state_dir(&other);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
let claimed: Vec<_> = claimed_ports(&state_dir(&mine))
.into_iter()
.filter(|(o, _, _)| o.root != mine)
.collect();
let clear = |ports: &BTreeMap<String, PortClaim>| {
!ports
.values()
.any(|claim| claimed.iter().any(|(_, _, port)| *port == claim.port))
};
assert!(clear(&BTreeMap::from([(
"redis".to_string(),
PortClaim::fixed(6379)
)])));
assert!(!clear(&BTreeMap::from([(
"pg".to_string(),
PortClaim::fixed(5432)
)])));
assert!(!clear(&BTreeMap::from([
("redis".to_string(), PortClaim::fixed(6379)),
("pg".to_string(), PortClaim::fixed(5432)),
])));
}
#[test]
fn the_fingerprint_notices_a_neighbour_changing() {
let tmp = tempfile::tempdir().unwrap();
let mine = tmp.path().join("mine");
let other = tmp.path().join("other");
let write = |root: &Path, port: u16| {
let state = State {
root: root.to_path_buf(),
ports: BTreeMap::from([("db".to_string(), PortClaim::fixed(port))]),
..State::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();
};
std::fs::create_dir_all(&mine).unwrap();
std::fs::create_dir_all(&other).unwrap();
let before = siblings_fingerprint(&state_dir(&mine));
assert_eq!(before, siblings_fingerprint(&state_dir(&mine)), "stable");
write(&other, 5432);
let appeared = siblings_fingerprint(&state_dir(&mine));
assert_ne!(before, appeared);
write(&other, 15432);
assert_ne!(appeared, siblings_fingerprint(&state_dir(&mine)));
let neighbours = siblings_fingerprint(&state_dir(&mine));
write(&mine, 6379);
assert_eq!(neighbours, siblings_fingerprint(&state_dir(&mine)));
}
#[test]
fn a_removed_daemon_keeps_its_claim_while_it_may_still_run() {
let with_port = |name: &str, port: u16| super::super::Daemon {
name: name.to_string(),
source: PathBuf::from("/project/mise.toml"),
root: PathBuf::from("/project"),
table: toml::Table::new(),
preset: None,
data_dir: None,
task: None,
tool: None,
provider: None,
exports: Default::default(),
port: Some(PortClaim::fixed(port)),
imported: false,
host: None,
};
let set = |daemons: Vec<super::super::Daemon>| DaemonSet {
daemons: daemons.into_iter().map(|d| (d.name.clone(), d)).collect(),
..Default::default()
};
let previous = BTreeMap::from([
("postgres".to_string(), PortClaim::fixed(5432)),
("redis".to_string(), PortClaim::fixed(6379)),
]);
let kept = carry_port_claims(&previous, &set(vec![with_port("postgres", 5432)]));
assert_eq!(kept["redis"].port, 6379, "a removed daemon keeps its claim");
assert_eq!(kept["postgres"].port, 5432);
let moved = carry_port_claims(&previous, &set(vec![with_port("redis", 6400)]));
assert_eq!(moved["redis"].port, 6400);
let mut bare = with_port("redis", 0);
bare.port = None;
let dropped = carry_port_claims(&previous, &set(vec![bare]));
assert!(!dropped.contains_key("redis"));
assert_eq!(dropped["postgres"].port, 5432);
}
#[test]
fn only_the_daemons_being_started_have_their_ports_checked() {
let ports = BTreeMap::from([
("postgres".to_string(), PortClaim::fixed(5432)),
("redis".to_string(), PortClaim::fixed(6379)),
]);
let names = |v: &[&str]| v.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
let scoped = ports_being_started(&ports, &names(&["redis"]));
assert_eq!(scoped.keys().collect::<Vec<_>>(), ["redis"]);
assert_eq!(scoped["redis"].port, 6379);
assert_eq!(
ports_being_started(&ports, &names(&["postgres", "redis"])).len(),
2
);
assert!(ports_being_started(&ports, &names(&["web"])).is_empty());
assert!(ports_being_started(&ports, &[]).is_empty());
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_does_not_conflict_with_itself() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("project");
std::fs::create_dir_all(&real).unwrap();
let link = tmp.path().join("linked");
std::os::unix::fs::symlink(&real, &link).unwrap();
let dir = state_dir(&real);
std::fs::create_dir_all(&dir).unwrap();
let state = State {
root: real.clone(),
ports: BTreeMap::from([("db".to_string(), PortClaim::fixed(5432))]),
..State::default()
};
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
assert_eq!(state_dir(&link), dir, "both spellings share a state dir");
assert!(
!claimed_ports(&state_dir(&link))
.iter()
.any(|(other, _, _)| other.root == real),
"a project must not find its own claim through a symlink"
);
}
#[test]
fn a_stopped_daemon_does_not_reserve_its_port() {
let tmp = tempfile::tempdir().unwrap();
let theirs = tmp.path().join("stopped");
std::fs::create_dir_all(&theirs).unwrap();
let state = State {
root: theirs.clone(),
ports: BTreeMap::from([("db".to_string(), PortClaim::fixed(5432))]),
..State::default()
};
let dir = state_dir(&theirs);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
let found = claimed_ports(&state_dir(tmp.path()));
assert!(
found.iter().any(|(s, _, p)| s.root == theirs && *p == 5432),
"a persisted claim is only a candidate, not a verdict"
);
}
#[test]
fn the_generated_config_carries_hostname_routing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("shop");
std::fs::create_dir_all(&root).unwrap();
let daemon = |name: &str, proxy: toml::Value, host: Option<&str>| super::super::Daemon {
name: name.to_string(),
source: root.join("mise.toml"),
root: root.clone(),
table: toml::Table::from_iter([
("run".into(), toml::Value::String(format!("run {name}"))),
("proxy".into(), proxy),
(
"proxy_tls".into(),
toml::Value::String("passthrough".into()),
),
]),
preset: None,
data_dir: None,
task: None,
tool: None,
provider: None,
exports: Default::default(),
imported: false,
port: None,
host: host.map(str::to_string),
};
let set = DaemonSet {
daemons: indexmap::IndexMap::from_iter([
(
"api".to_string(),
daemon(
"api",
toml::Value::String("front".into()),
Some("front.shop.localhost"),
),
),
(
"cache".to_string(),
daemon("cache", toml::Value::Boolean(false), None),
),
]),
..Default::default()
};
let state = State {
namespace: "shop".into(),
root: root.clone(),
..State::default()
};
let rendered = render(&set, &state).unwrap();
let parsed: toml::Table = toml::from_str(&rendered).unwrap();
assert_eq!(parsed["daemons"]["api"]["proxy"].as_str(), Some("front"));
assert_eq!(
parsed["daemons"]["api"]["proxy_tls"].as_str(),
Some("passthrough")
);
assert_eq!(parsed["daemons"]["cache"]["proxy"].as_bool(), Some(false));
assert!(rendered.contains("needs pitchfork"), "{rendered}");
let mut unrouted = set.clone();
for daemon in unrouted.daemons.values_mut() {
daemon.host = None;
}
unrouted.labels.insert(
root.clone(),
super::super::urls::RootLabels {
project: Some("shop".into()),
worktree: None,
},
);
let rendered = render(&unrouted, &state).unwrap();
assert!(!rendered.contains("needs pitchfork"), "{rendered}");
}
#[test]
fn groups_render_with_qualified_daemon_ids() {
let daemon = |name: &str| super::super::Daemon {
name: name.to_string(),
source: PathBuf::from("/project/mise.toml"),
root: PathBuf::from("/project"),
table: toml::Table::from_iter([(
"run".into(),
toml::Value::String(format!("run {name}")),
)]),
preset: None,
data_dir: None,
task: None,
tool: None,
provider: None,
exports: Default::default(),
imported: false,
port: None,
host: None,
};
let set = DaemonSet {
daemons: ["api", "worker"]
.into_iter()
.map(|name| (name.to_string(), daemon(name)))
.collect(),
groups: vec![super::super::Group {
name: "web".into(),
source: PathBuf::from("/project/mise.toml"),
root: PathBuf::from("/project"),
members: vec!["api".into(), "worker".into()],
daemons: vec!["api".into(), "worker".into()],
}],
..Default::default()
};
let state = State {
namespace: "proj".into(),
..State::default()
};
let rendered = render(&set, &state).unwrap();
assert!(rendered.contains("[groups.web]"), "{rendered}");
assert!(rendered.contains("\"proj/api\""), "{rendered}");
assert!(rendered.contains("\"proj/worker\""), "{rendered}");
let mut overridden = set.clone();
overridden.groups[0].daemons.push("elsewhere".into());
let rendered = render(&overridden, &state).unwrap();
assert!(rendered.contains("[groups.web]"), "{rendered}");
assert!(!rendered.contains("elsewhere"), "{rendered}");
let mut empty = set.clone();
empty.groups[0].daemons = vec!["elsewhere".into()];
assert!(!render(&empty, &state).unwrap().contains("[groups"));
let bare = DaemonSet {
groups: Vec::new(),
..set
};
assert!(!render(&bare, &state).unwrap().contains("[groups"));
}
#[test]
fn task_daemons_carry_the_profile_despite_opting_out_of_mise() {
let daemon = |task: Option<&str>, mise: bool| super::super::Daemon {
name: "core".into(),
source: PathBuf::from("/project/mise.toml"),
root: PathBuf::from("/project"),
table: toml::Table::from_iter([
("run".into(), toml::Value::String("server".into())),
("mise".into(), toml::Value::Boolean(mise)),
]),
preset: None,
data_dir: None,
task: task.map(str::to_string),
tool: None,
provider: None,
exports: Default::default(),
imported: false,
port: None,
host: None,
};
let state = State {
profile: vec!["dev".into()],
..State::default()
};
let rendered = |d: super::super::Daemon| {
render(
&DaemonSet {
daemons: indexmap::IndexMap::from_iter([("core".to_string(), d)]),
..Default::default()
},
&state,
)
.unwrap()
};
assert!(rendered(daemon(Some("dev"), false)).contains("MISE_ENV = \"dev\""));
assert!(!rendered(daemon(None, false)).contains("MISE_ENV"));
assert!(rendered(daemon(None, true)).contains("MISE_ENV = \"dev\""));
}
#[test]
fn writes_are_atomic_and_unchanged_content_keeps_metadata() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
assert!(write_if_changed(&path, b"one").unwrap());
let before = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(!write_if_changed(&path, b"one").unwrap());
assert_eq!(
std::fs::metadata(&path).unwrap().modified().unwrap(),
before
);
assert!(write_if_changed(&path, b"two").unwrap());
}
}