void-cli 0.0.4

CLI for void — anonymous encrypted source control
//! Remote peer management — named peers for block replication.
//!
//! Stored at `~/.void/remotes.json`. All blocks entering the daemon's
//! store are replicated to configured remotes.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::output::CliError;

/// A named remote peer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Remote {
    /// libp2p PeerId (base58 encoded).
    pub peer_id: String,
    /// Multiaddr(s) to reach this peer.
    pub addrs: Vec<String>,
    /// Optional human label.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
}

/// All configured remotes (name → remote).
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Remotes {
    #[serde(flatten)]
    pub peers: BTreeMap<String, Remote>,
}

impl Remotes {
    /// Load remotes from `~/.void/remotes.json`.
    /// Returns empty set if file doesn't exist.
    pub fn load() -> Self {
        Self::load_from_path(&Self::default_path())
    }

    fn load_from_path(path: &Path) -> Self {
        if !path.exists() {
            return Self::default();
        }
        std::fs::read_to_string(path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Save remotes to `~/.void/remotes.json`.
    pub fn save(&self) -> Result<(), CliError> {
        let path = Self::default_path();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| CliError::internal(format!("create dir: {e}")))?;
        }
        let json = serde_json::to_string_pretty(self)
            .map_err(|e| CliError::internal(format!("serialize remotes: {e}")))?;
        std::fs::write(&path, json)
            .map_err(|e| CliError::internal(format!("write remotes: {e}")))?;
        Ok(())
    }

    fn default_path() -> PathBuf {
        dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(".void")
            .join("remotes.json")
    }

    /// Add a remote. Returns error if name already exists.
    pub fn add(&mut self, name: &str, remote: Remote) -> Result<(), CliError> {
        if self.peers.contains_key(name) {
            return Err(CliError::internal(format!(
                "remote '{name}' already exists — use `void remote remove {name}` first"
            )));
        }
        self.peers.insert(name.to_string(), remote);
        Ok(())
    }

    /// Remove a remote. Returns error if not found.
    pub fn remove(&mut self, name: &str) -> Result<(), CliError> {
        if self.peers.remove(name).is_none() {
            return Err(CliError::internal(format!("remote '{name}' not found")));
        }
        Ok(())
    }
}

/// Resolved remote connection info — the unified output of name resolution.
#[derive(Debug, Clone)]
pub struct ResolvedRemote {
    pub name: String,
    pub peer_id: String,
    pub addr: String,
    pub label: Option<String>,
}

/// Resolve a remote name to connection info.
///
/// Checks per-repo config first, then global `~/.void/remotes.json`.
/// Returns the first match. If the value looks like a multiaddr (starts with `/`),
/// it's parsed directly instead of treated as a name.
pub fn resolve_remote(
    name: &str,
    repo_config: Option<&void_core::config::Config>,
) -> Result<ResolvedRemote, CliError> {
    // If it looks like a raw multiaddr, parse directly.
    if name.starts_with('/') {
        let (addr, peer_id) = parse_remote_addr(name)?;
        return Ok(ResolvedRemote {
            name: "inline".to_string(),
            peer_id,
            addr,
            label: None,
        });
    }

    // Check per-repo config first.
    if let Some(cfg) = repo_config {
        if let Some(remote) = cfg.remote.get(name) {
            // Try P2P fields first, then fall back to peerMultiaddr.
            if let (Some(pid), Some(a)) = (remote.peer_id.as_deref(), remote.addrs.first()) {
                return Ok(ResolvedRemote {
                    name: name.to_string(),
                    peer_id: pid.to_string(),
                    addr: a.clone(),
                    label: remote.label.clone(),
                });
            }
            if let Some(ref multiaddr) = remote.peer_multiaddr {
                if let Some(idx) = multiaddr.rfind("/p2p/") {
                    return Ok(ResolvedRemote {
                        name: name.to_string(),
                        peer_id: multiaddr[idx + 5..].to_string(),
                        addr: multiaddr[..idx].to_string(),
                        label: remote.label.clone(),
                    });
                }
            }
        }
    }

    // Check global remotes.
    let global = Remotes::load();
    if let Some(remote) = global.peers.get(name) {
        if let Some(addr) = remote.addrs.first() {
            return Ok(ResolvedRemote {
                name: name.to_string(),
                peer_id: remote.peer_id.clone(),
                addr: addr.clone(),
                label: remote.label.clone(),
            });
        }
    }

    Err(CliError::not_found(format!("remote '{name}' not found")))
}

/// Resolve all configured remotes (per-repo + global, deduplicated by name).
pub fn resolve_all_remotes(
    repo_config: Option<&void_core::config::Config>,
) -> Vec<ResolvedRemote> {
    let mut result = Vec::new();
    let mut seen = std::collections::HashSet::new();

    // Per-repo first (takes precedence).
    if let Some(cfg) = repo_config {
        for name in cfg.remote.keys() {
            if let Ok(resolved) = resolve_remote(name, repo_config) {
                seen.insert(name.clone());
                result.push(resolved);
            }
        }
    }

    // Global remotes (skip duplicates).
    let global = Remotes::load();
    for (name, remote) in &global.peers {
        if !seen.contains(name) {
            if let Some(addr) = remote.addrs.first() {
                result.push(ResolvedRemote {
                    name: name.clone(),
                    peer_id: remote.peer_id.clone(),
                    addr: addr.clone(),
                    label: remote.label.clone(),
                });
            }
        }
    }

    result
}

/// Parse a multiaddr string like `/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW...`
/// into (addr_without_peer, peer_id_string).
pub fn parse_remote_addr(addr_str: &str) -> Result<(String, String), CliError> {
    // Find the /p2p/ component
    let p2p_idx = addr_str.rfind("/p2p/").ok_or_else(|| {
        CliError::invalid_args(format!(
            "remote address must include /p2p/<peer_id>: {addr_str}"
        ))
    })?;

    let addr = addr_str[..p2p_idx].to_string();
    let peer_id = addr_str[p2p_idx + 5..].to_string();

    // Validate peer_id parses
    peer_id.parse::<libp2p::PeerId>().map_err(|e| {
        CliError::invalid_args(format!("invalid peer ID in address: {e}"))
    })?;

    // Validate addr parses
    addr.parse::<libp2p::Multiaddr>().map_err(|e| {
        CliError::invalid_args(format!("invalid multiaddr: {e}"))
    })?;

    Ok((addr, peer_id))
}