ztnet 0.1.14

ZTNet CLI — manage ZeroTier networks via ZTNet
use std::path::PathBuf;

use reqwest::Method;
use serde_json::{json, Value};

use crate::cli::{ExportCommand, GlobalOpts};
use crate::context::resolve_effective_config;
use crate::error::CliError;
use crate::http::{ClientUi, HttpClient};

use super::common::{load_config_store, write_text_output};
use super::resolve::{resolve_network_id, resolve_org_id};

pub(super) async fn run(global: &GlobalOpts, command: ExportCommand) -> Result<(), CliError> {
	let (_config_path, cfg) = load_config_store()?;
	let effective = resolve_effective_config(global, &cfg)?;

	let client = HttpClient::new(
		&effective.host,
		effective.token.clone(),
		effective.timeout,
		effective.retries,
		global.dry_run,
		ClientUi::from_context(global, &effective),
	)?;

	match command {
		ExportCommand::Hosts(args) => export_hosts(global, &effective, &client, args).await,
	}
}

async fn export_hosts(
	global: &GlobalOpts,
	effective: &crate::context::EffectiveConfig,
	client: &HttpClient,
	args: crate::cli::ExportHostsArgs,
) -> Result<(), CliError> {
	if args.authorized_only && args.include_unauthorized {
		return Err(CliError::InvalidArgument(
			"cannot combine --authorized-only with --include-unauthorized".to_string(),
		));
	}

	let zone = args.zone.trim().trim_end_matches('.').to_string();
	if zone.is_empty() {
		return Err(CliError::InvalidArgument("--zone cannot be empty".to_string()));
	}

	let org = args.org.or(effective.org.clone());
	let org_id = match org {
		Some(ref org) => Some(resolve_org_id(client, org).await?),
		None => None,
	};

	let network_id = resolve_network_id(client, org_id.as_deref(), &args.network).await?;

	let network_get_path = match org_id.as_deref() {
		Some(org_id) => format!("/api/v1/org/{org_id}/network/{network_id}"),
		None => format!("/api/v1/network/{network_id}"),
	};

	let _network = client
		.request_json(Method::GET, &network_get_path, None, Default::default(), true)
		.await?;

	let member_list_path = match org_id.as_deref() {
		Some(org_id) => format!("/api/v1/org/{org_id}/network/{network_id}/member"),
		None => format!("/api/v1/network/{network_id}/member"),
	};

	let members = client
		.request_json(Method::GET, &member_list_path, None, Default::default(), true)
		.await?;

	let Some(items) = members.as_array() else {
		return Err(CliError::InvalidArgument("expected array response".to_string()));
	};

	let include_unauthorized = args.include_unauthorized;

	let mut records = Vec::new();
	for item in items {
		let authorized = item.get("authorized").and_then(|v| v.as_bool()).unwrap_or(false);
		if !include_unauthorized && !authorized {
			continue;
		}

		let member_id = item
			.get("id")
			.and_then(|v| v.as_str())
			.unwrap_or("")
			.to_string();

		let raw_name = item
			.get("name")
			.and_then(|v| v.as_str())
			.filter(|s| !s.trim().is_empty())
			.unwrap_or(member_id.as_str());

		let label = sanitize_hostname_label(raw_name);
		let hostname = format!("{label}.{zone}");

		let ips: Vec<String> = item
			.get("ipAssignments")
			.and_then(|v| v.as_array())
			.map(|arr| {
				arr.iter()
					.filter_map(|v| v.as_str().map(str::to_string))
					.collect::<Vec<_>>()
			})
			.unwrap_or_default();

		for ip in ips {
			records.push(json!({
				"ip": ip,
				"hostname": hostname,
				"memberId": member_id,
				"name": raw_name,
				"authorized": authorized,
			}));
		}
	}

	match args.format {
		crate::cli::ExportHostsFormat::Json => {
			let value = Value::Array(records);
			write_export_output(&value, args.out.as_ref(), global)?;
		}
		crate::cli::ExportHostsFormat::Csv => {
			let mut out = String::new();
			out.push_str("ip,hostname,memberId,name,authorized\n");
			for r in &records {
				let ip = r.get("ip").and_then(|v| v.as_str()).unwrap_or("");
				let hostname = r.get("hostname").and_then(|v| v.as_str()).unwrap_or("");
				let member_id = r.get("memberId").and_then(|v| v.as_str()).unwrap_or("");
				let name = r.get("name").and_then(|v| v.as_str()).unwrap_or("");
				let authorized = r
					.get("authorized")
					.and_then(|v| v.as_bool())
					.unwrap_or(false);

				out.push_str(&format!(
					"{},{},{},{},{}\n",
					csv_escape(ip),
					csv_escape(hostname),
					csv_escape(member_id),
					csv_escape(name),
					authorized
				));
			}
			write_text_output(&out, args.out.as_ref(), global)?;
		}
		crate::cli::ExportHostsFormat::Hosts => {
			let mut out = String::new();
			for r in &records {
				let ip = r.get("ip").and_then(|v| v.as_str()).unwrap_or("");
				let hostname = r.get("hostname").and_then(|v| v.as_str()).unwrap_or("");
				out.push_str(&format!("{ip}\t{hostname}\n"));
			}
			write_text_output(&out, args.out.as_ref(), global)?;
		}
	}

	Ok(())
}

fn sanitize_hostname_label(value: &str) -> String {
	let mut out = String::with_capacity(value.len());
	for c in value.chars() {
		let c = c.to_ascii_lowercase();
		if matches!(c, 'a'..='z' | '0'..='9' | '-') {
			out.push(c);
		} else if c.is_whitespace() || matches!(c, '_' | '.') {
			out.push('-');
		}
	}

	let out = out.trim_matches('-').to_string();
	if out.is_empty() {
		"member".to_string()
	} else {
		out
	}
}

fn csv_escape(value: &str) -> String {
	if value.contains([',', '\"', '\n', '\r']) {
		format!("\"{}\"", value.replace('\"', "\"\""))
	} else {
		value.to_string()
	}
}

fn write_export_output(
	value: &Value,
	out: Option<&PathBuf>,
	global: &GlobalOpts,
) -> Result<(), CliError> {
	let json = serde_json::to_string_pretty(value)?;
	write_text_output(&json, out, global)
}