ztnet 0.1.20

ZTNet CLI — manage ZeroTier networks via ZTNet
use serde_json::Value;

use crate::context::EffectiveConfig;
use crate::error::CliError;

use super::trpc_client::TrpcClient;

pub(super) async fn resolve_org_id(trpc: &TrpcClient, org: &str) -> Result<String, CliError> {
	let org = org.trim();
	if org.is_empty() {
		return Err(CliError::InvalidArgument("org cannot be empty".to_string()));
	}

	let value = trpc.query("org.getOrgIdbyUserid", Value::Null).await?;
	let Some(items) = value.as_array() else {
		return Ok(org.to_string());
	};

	if items
		.iter()
		.any(|o| o.get("id").and_then(|v| v.as_str()) == Some(org))
	{
		return Ok(org.to_string());
	}

	let mut matches = Vec::new();
	for item in items {
		let id = item.get("id").and_then(|v| v.as_str());
		let name = item.get("orgName").and_then(|v| v.as_str());
		if let (Some(id), Some(name)) = (id, name) {
			if name.eq_ignore_ascii_case(org) {
				matches.push(id.to_string());
			}
		}
	}

	match matches.len() {
		0 => Err(CliError::InvalidArgument(format!(
			"org '{org}' not found (pass org id or exact orgName)"
		))),
		1 => Ok(matches.remove(0)),
		_ => Err(CliError::InvalidArgument(format!(
			"org name '{org}' is ambiguous"
		))),
	}
}

pub(super) async fn resolve_personal_network_id(
	trpc: &TrpcClient,
	network: &str,
) -> Result<String, CliError> {
	let network = network.trim();
	if network.is_empty() {
		return Err(CliError::InvalidArgument("network cannot be empty".to_string()));
	}

	if is_network_id(network) {
		return Ok(network.to_string());
	}

	let input = Value::Object(Default::default());
	let value = trpc.query("network.getUserNetworks", input).await?;
	let Some(items) = value.as_array() else {
		return Err(CliError::InvalidArgument(
			"failed to list networks for name resolution".to_string(),
		));
	};

	let mut matches = Vec::new();
	for item in items {
		let id = item.get("nwid").and_then(|v| v.as_str());
		let name = item.get("name").and_then(|v| v.as_str());
		if let Some(id) = id {
			if id == network {
				return Ok(id.to_string());
			}
		}
		if let (Some(id), Some(name)) = (id, name) {
			if name.eq_ignore_ascii_case(network) {
				matches.push(id.to_string());
			}
		}
	}

	match matches.len() {
		0 => Err(CliError::InvalidArgument(format!(
			"network '{network}' not found (tRPC commands require a network id; name resolution works for personal networks only)"
		))),
		1 => Ok(matches.remove(0)),
		_ => Err(CliError::InvalidArgument(format!(
			"network name '{network}' is ambiguous"
		))),
	}
}

pub(super) async fn resolve_network_org_id(
	trpc: &TrpcClient,
	_effective: &EffectiveConfig,
	cli_org: Option<&str>,
	network_details: &Value,
) -> Result<Option<String>, CliError> {
	let inferred = network_details
		.get("network")
		.and_then(|n| n.get("organizationId"))
		.and_then(|v| v.as_str())
		.map(str::to_string);

	let explicit = match cli_org {
		Some(org) => Some(resolve_org_id(trpc, org).await?),
		None => None,
	};

	match inferred {
		Some(inferred) => {
			if let Some(explicit) = explicit {
				if explicit != inferred {
					return Err(CliError::InvalidArgument(
						"network belongs to a different org than --org".to_string(),
					));
				}
			}
			Ok(Some(inferred))
		}
		None => Ok(explicit),
	}
}

fn is_network_id(value: &str) -> bool {
	value.len() == 16 && value.chars().all(|c| c.is_ascii_hexdigit())
}

#[cfg(test)]
mod tests {
	use super::is_network_id;

	#[test]
	fn is_network_id_accepts_16_hex_chars() {
		assert!(is_network_id("9ad07d01093a69e3"));
	}

	#[test]
	fn is_network_id_rejects_10_hex_chars() {
		assert!(!is_network_id("b621d170ad"));
	}

	#[test]
	fn is_network_id_rejects_non_hex() {
		assert!(!is_network_id("9ad07d01093a69eg"));
	}
}