ztnet 0.1.20

ZTNet CLI — manage ZeroTier networks via ZTNet
use reqwest::Method;
use serde_json::{json, Value};

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

use super::common::{load_config_store, print_human_or_machine};
use super::member;
use super::network_trpc;
use super::resolve::{extract_network_id, resolve_network_id, resolve_org_id};

pub(super) async fn run(global: &GlobalOpts, command: NetworkCommand) -> 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 {
		NetworkCommand::List(args) => {
			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 path = match org_id.as_deref() {
				Some(org_id) => format!("/api/v1/org/{org_id}/network"),
				None => "/api/v1/network".to_string(),
			};

			let mut response = client
				.request_json(Method::GET, &path, None, Default::default(), true)
				.await?;

			if let Some(filter) = args.filter.as_deref() {
				response = filter_network_list(response, filter)?;
			}

			if args.details {
				let Some(networks) = response.as_array() else {
					return Err(CliError::InvalidArgument("expected array response".to_string()));
				};

				let mut detailed = Vec::with_capacity(networks.len());
				for net in networks {
					let Some(id) = extract_network_id(net) else { continue };
					let detail_path = match org_id.as_deref() {
						Some(org_id) => format!("/api/v1/org/{org_id}/network/{id}"),
						None => format!("/api/v1/network/{id}"),
					};
					let detail = client
						.request_json(Method::GET, &detail_path, None, Default::default(), true)
						.await?;
					detailed.push(detail);
				}
				response = Value::Array(detailed);
			}

			if args.ids_only {
				let ids = response
					.as_array()
					.map(|arr| {
						arr.iter()
							.filter_map(extract_network_id)
							.map(str::to_string)
							.collect::<Vec<_>>()
					})
					.unwrap_or_default();

				if matches!(effective.output, OutputFormat::Table) {
					for id in ids {
						println!("{id}");
					}
					return Ok(());
				}

				let value = Value::Array(ids.into_iter().map(Value::String).collect());
				output::print_value(&value, effective.output, global.no_color)?;
				return Ok(());
			}

			output::print_value(&response, effective.output, global.no_color)?;
			Ok(())
		}
		NetworkCommand::Create(args) => {
			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 path = match org_id.as_deref() {
				Some(org_id) => format!("/api/v1/org/{org_id}/network"),
				None => "/api/v1/network".to_string(),
			};

			let body = args
				.name
				.map(|name| json!({ "name": name }))
				.unwrap_or_else(|| json!({}));

			let response = client
				.request_json(Method::POST, &path, Some(body), Default::default(), true)
				.await?;

			print_human_or_machine(&response, effective.output, global.no_color)?;
			Ok(())
		}
		NetworkCommand::Get(args) => {
			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 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 response = client
				.request_json(Method::GET, &path, None, Default::default(), true)
				.await?;
			print_human_or_machine(&response, effective.output, global.no_color)?;
			Ok(())
		}
		NetworkCommand::Update(args) => {
			let org_id = resolve_org_id(&client, &args.org).await?;
			let network_id = resolve_network_id(&client, Some(&org_id), &args.network).await?;
			let path = format!("/api/v1/org/{org_id}/network/{network_id}");

			let body = if let Some(body) = args.body {
				serde_json::from_str::<Value>(&body)
					.map_err(|err| CliError::InvalidArgument(format!("invalid --body json: {err}")))?
			} else if let Some(path) = args.body_file {
				let text = std::fs::read_to_string(&path)?;
				serde_json::from_str::<Value>(&text).map_err(|err| {
					CliError::InvalidArgument(format!("invalid --body-file json: {err}"))
				})?
			} else {
				build_network_update_body(&args)?
			};

			let response = client
				.request_json(Method::POST, &path, Some(body), Default::default(), true)
				.await?;
			print_human_or_machine(&response, effective.output, global.no_color)?;
			Ok(())
		}
		NetworkCommand::Member { command } => {
			member::run_network_member(global, &effective, &client, command).await
		}
		NetworkCommand::Delete(args) => network_trpc::delete(global, &effective, args).await,
		NetworkCommand::Routes(args) => network_trpc::routes(global, &effective, args).await,
		NetworkCommand::IpPool(args) => network_trpc::ip_pool(global, &effective, args).await,
		NetworkCommand::Dns(args) => network_trpc::dns(global, &effective, args).await,
		NetworkCommand::Ipv6(args) => network_trpc::ipv6(global, &effective, args).await,
		NetworkCommand::Multicast(args) => network_trpc::multicast(global, &effective, args).await,
		NetworkCommand::FlowRules(args) => network_trpc::flow_rules(global, &effective, args).await,
	}
}

fn filter_network_list(response: Value, expr: &str) -> Result<Value, CliError> {
	let Some(items) = response.as_array() else {
		return Ok(response);
	};

	let mut name_contains: Option<String> = None;
	let mut private_is: Option<bool> = None;

	for raw in expr.split(',').map(str::trim).filter(|s| !s.is_empty()) {
		if let Some((k, v)) = raw.split_once("~=") {
			if k.trim().eq_ignore_ascii_case("name") {
				name_contains = Some(v.trim().to_string());
			}
			continue;
		}
		if let Some((k, v)) = raw.split_once("==") {
			if k.trim().eq_ignore_ascii_case("private") {
				private_is = Some(matches!(
					v.trim().to_ascii_lowercase().as_str(),
					"true" | "1" | "yes"
				));
			}
			continue;
		}
	}

	let filtered: Vec<Value> = items
		.iter()
		.filter(|item| {
			if let Some(ref needle) = name_contains {
				let name = item
					.get("name")
					.and_then(|v| v.as_str())
					.or_else(|| item.get("nwname").and_then(|v| v.as_str()))
					.unwrap_or("");
				if !name
					.to_ascii_lowercase()
					.contains(&needle.to_ascii_lowercase())
				{
					return false;
				}
			}

			if let Some(expected) = private_is {
				let actual = item.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
				if actual != expected {
					return false;
				}
			}

			true
		})
		.cloned()
		.collect();

	Ok(Value::Array(filtered))
}

fn build_network_update_body(args: &crate::cli::NetworkUpdateArgs) -> Result<Value, CliError> {
	let mut body = serde_json::Map::new();

	if let Some(name) = args.name.clone() {
		body.insert("name".to_string(), Value::String(name));
	}
	if let Some(description) = args.description.clone() {
		body.insert("description".to_string(), Value::String(description));
	}
	if let Some(mtu) = args.mtu.clone() {
		body.insert("mtu".to_string(), Value::String(mtu));
	}
	if args.private {
		body.insert("private".to_string(), Value::Bool(true));
	} else if args.public {
		body.insert("private".to_string(), Value::Bool(false));
	}

	if args.flow_rule.is_some() || args.flow_rule_file.is_some() {
		let rule = if let Some(rule) = args.flow_rule.clone() {
			rule
		} else if let Some(path) = args.flow_rule_file.as_ref() {
			std::fs::read_to_string(path)?
		} else {
			unreachable!()
		};
		body.insert("flowRule".to_string(), Value::String(rule));
	}

	if args.dns_domain.is_some() || !args.dns_server.is_empty() {
		let domain = args.dns_domain.clone().ok_or_else(|| {
			CliError::InvalidArgument("dns settings require --dns-domain".to_string())
		})?;
		let servers: Vec<Value> = args.dns_server.iter().cloned().map(Value::String).collect();
		body.insert(
			"dns".to_string(),
			json!({
				"domain": domain,
				"servers": servers,
			}),
		);
	}

	if body.is_empty() {
		return Err(CliError::InvalidArgument(
			"no update fields provided (use flags or --body/--body-file)".to_string(),
		));
	}

	Ok(Value::Object(body))
}