use std::process::ExitCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliResolvedIdentity {
Selected(i32),
SingleNodeDev,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CliIdentityResolveError {
ConflictingFlags,
NodeIdOutOfRange { value: i32 },
MissingNodeIdentity,
SingleNodeDevRefusedInProduction,
InvalidEnvFormat { value: String },
}
impl std::fmt::Display for CliIdentityResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::ConflictingFlags => write!(
f,
"--node-id and --single-node-dev are mutually exclusive; \
supply only one"
),
Self::NodeIdOutOfRange { value } => write!(
f,
"--node-id {} is out of range; must be 0..=511 (HeerId range)",
value
),
Self::MissingNodeIdentity => write!(
f,
"missing node identity for this operation — \
supply --node-id <id> or --single-node-dev"
),
Self::SingleNodeDevRefusedInProduction => write!(
f,
"--single-node-dev is not permitted in production; \
use --node-id with a registered cluster node identity"
),
Self::InvalidEnvFormat { value } => write!(
f,
"HEER_NODE_ID value {:?} is not a valid integer; \
supply a numeric node ID or unset the variable",
value
),
}
}
}
pub fn validate_node_id_range(node_id: i32) -> bool {
(0..=511).contains(&node_id)
}
fn is_production_env() -> bool {
std::env::var("DJOGI_ENV")
.map(|value| value.eq_ignore_ascii_case("production"))
.unwrap_or(false)
}
pub fn resolve_identity(
cli_node_id: Option<u32>,
single_node_dev: bool,
profile: &str,
_operation_name: &str,
) -> Result<CliResolvedIdentity, CliIdentityResolveError> {
if cli_node_id.is_some() && single_node_dev {
return Err(CliIdentityResolveError::ConflictingFlags);
}
if let Some(id) = cli_node_id {
let id_i32 = id as i32;
if !validate_node_id_range(id_i32) {
return Err(CliIdentityResolveError::NodeIdOutOfRange { value: id_i32 });
}
return Ok(CliResolvedIdentity::Selected(id_i32));
}
if single_node_dev {
let is_prod = profile == "production" || is_production_env();
if is_prod {
return Err(CliIdentityResolveError::SingleNodeDevRefusedInProduction);
}
return Ok(CliResolvedIdentity::SingleNodeDev);
}
if let Ok(env_val) = std::env::var("HEER_NODE_ID") {
match env_val.parse::<i32>() {
Ok(id) if validate_node_id_range(id) => {
return Ok(CliResolvedIdentity::Selected(id));
}
Ok(id) => {
return Err(CliIdentityResolveError::NodeIdOutOfRange { value: id });
}
Err(_) => {
return Err(CliIdentityResolveError::InvalidEnvFormat { value: env_val });
}
}
}
Err(CliIdentityResolveError::MissingNodeIdentity)
}
impl CliResolvedIdentity {
pub fn into_runner_identity(self) -> djogi::migrate::RunnerIdentity {
match self {
CliResolvedIdentity::Selected(id) => djogi::migrate::RunnerIdentity::Selected { id },
CliResolvedIdentity::SingleNodeDev => djogi::migrate::RunnerIdentity::SingleNodeDev,
}
}
}
pub fn print_identity_error(operation: &str, err: &CliIdentityResolveError) -> ExitCode {
eprintln!("djogi migrations {operation}: refused — {err}");
ExitCode::from(2)
}
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prior_heer_node_id: Option<String>,
prior_djogi_env: Option<String>,
}
impl EnvGuard {
fn new() -> Self {
Self {
_lock: crate::test_env_lock(),
prior_heer_node_id: std::env::var("HEER_NODE_ID").ok(),
prior_djogi_env: std::env::var("DJOGI_ENV").ok(),
}
}
fn set_heer_node_id(&self, value: &str) {
unsafe { std::env::set_var("HEER_NODE_ID", value) };
}
fn remove_heer_node_id(&self) {
unsafe { std::env::remove_var("HEER_NODE_ID") };
}
fn set_djogi_env(&self, value: &str) {
unsafe { std::env::set_var("DJOGI_ENV", value) };
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prior_heer_node_id {
Some(value) => unsafe { std::env::set_var("HEER_NODE_ID", value) },
None => unsafe { std::env::remove_var("HEER_NODE_ID") },
}
match &self.prior_djogi_env {
Some(value) => unsafe { std::env::set_var("DJOGI_ENV", value) },
None => unsafe { std::env::remove_var("DJOGI_ENV") },
}
}
}
#[test]
fn resolve_explicit_node_id_selected() {
let result = resolve_identity(Some(7), false, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::Selected(7)));
}
#[test]
fn resolve_explicit_node_id_zero_boundary() {
let result = resolve_identity(Some(0), false, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::Selected(0)));
}
#[test]
fn resolve_explicit_node_id_upper_boundary() {
let result = resolve_identity(Some(511), false, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::Selected(511)));
}
#[test]
fn resolve_node_id_out_of_range_below() {
let result = resolve_identity(Some(512), false, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::NodeIdOutOfRange { value: 512 })
);
}
#[test]
fn resolve_node_id_out_of_range_max() {
let result = resolve_identity(Some(u32::MAX), false, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::NodeIdOutOfRange {
value: u32::MAX as i32
})
);
}
#[test]
fn resolve_single_node_dev_allowed_in_dev() {
let env = EnvGuard::new();
env.set_djogi_env("development");
let result = resolve_identity(None, true, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::SingleNodeDev));
}
#[test]
fn resolve_single_node_dev_refused_in_production_profile() {
let result = resolve_identity(None, true, "production", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::SingleNodeDevRefusedInProduction)
);
}
#[test]
fn resolve_conflicting_flags() {
let result = resolve_identity(Some(1), true, "development", "apply");
assert_eq!(result, Err(CliIdentityResolveError::ConflictingFlags));
}
#[test]
fn resolve_missing_identity() {
let env = EnvGuard::new();
env.remove_heer_node_id();
let result = resolve_identity(None, false, "development", "apply");
assert_eq!(result, Err(CliIdentityResolveError::MissingNodeIdentity));
}
#[test]
fn resolve_env_var_fallback() {
let env = EnvGuard::new();
env.set_heer_node_id("42");
let result = resolve_identity(None, false, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::Selected(42)));
}
#[test]
fn resolve_explicit_wins_over_env() {
let env = EnvGuard::new();
env.set_heer_node_id("99");
let result = resolve_identity(Some(7), false, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::Selected(7)));
}
#[test]
fn resolve_env_var_out_of_range() {
let env = EnvGuard::new();
env.set_heer_node_id("9999");
let result = resolve_identity(None, false, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::NodeIdOutOfRange { value: 9999 })
);
}
#[test]
fn resolve_env_var_unparseable() {
let env = EnvGuard::new();
env.set_heer_node_id("not-a-number");
let result = resolve_identity(None, false, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::InvalidEnvFormat {
value: "not-a-number".to_string()
})
);
}
#[test]
fn validate_node_id_range_boundaries() {
assert!(validate_node_id_range(0));
assert!(validate_node_id_range(1));
assert!(validate_node_id_range(256));
assert!(validate_node_id_range(511));
assert!(!validate_node_id_range(-1));
assert!(!validate_node_id_range(512));
}
#[test]
fn resolve_single_node_dev_refused_in_djogi_env_production() {
let env = EnvGuard::new();
env.set_djogi_env("production");
let result = resolve_identity(None, true, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::SingleNodeDevRefusedInProduction)
);
}
#[test]
fn resolve_single_node_dev_allowed_in_djogi_env_development() {
let env = EnvGuard::new();
env.set_djogi_env("development");
let result = resolve_identity(None, true, "development", "apply");
assert_eq!(result, Ok(CliResolvedIdentity::SingleNodeDev));
}
#[test]
fn resolve_single_node_dev_refused_in_mixed_case_djogi_env_production() {
let env = EnvGuard::new();
env.set_djogi_env("Production");
let result = resolve_identity(None, true, "development", "apply");
assert_eq!(
result,
Err(CliIdentityResolveError::SingleNodeDevRefusedInProduction)
);
}
#[test]
fn env_guard_restores_prior_values() {
let env = EnvGuard::new();
let expected_heer_node_id = env.prior_heer_node_id.clone();
let expected_djogi_env = env.prior_djogi_env.clone();
let next_heer_node_id = if expected_heer_node_id.as_deref() == Some("42") {
"7"
} else {
"42"
};
let next_djogi_env = if expected_djogi_env.as_deref() == Some("Production") {
"development"
} else {
"Production"
};
env.set_heer_node_id(next_heer_node_id);
env.set_djogi_env(next_djogi_env);
drop(env);
assert_eq!(std::env::var("HEER_NODE_ID").ok(), expected_heer_node_id);
assert_eq!(std::env::var("DJOGI_ENV").ok(), expected_djogi_env);
}
}