void-cli 0.0.3

CLI for void — anonymous encrypted source control
//! Remote command — manage remote pinning targets.
//!
//! Remotes are stored in `.void/config.json` under the `remotes` key.
//! Each remote has a name and optional SSH/P2P connection details.

use std::path::Path;

use serde::Serialize;
use void_core::config;

use crate::context::find_void_dir;
use crate::output::{run_command, CliError, CliOptions};

// ============================================================================
// Output types
// ============================================================================

/// A single remote entry in JSON output.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteEntry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub host: Option<String>,
    pub user: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub peer_multiaddr: Option<String>,
}

/// Output for `remote list`.
#[derive(Debug, Clone, Serialize)]
pub struct RemoteListOutput {
    pub remotes: Vec<RemoteEntry>,
}

/// Output for `remote add`.
#[derive(Debug, Clone, Serialize)]
pub struct RemoteAddOutput {
    pub name: String,
    pub added: bool,
}

/// Output for `remote remove`.
#[derive(Debug, Clone, Serialize)]
pub struct RemoteRemoveOutput {
    pub name: String,
    pub removed: bool,
}

// ============================================================================
// Subcommand enum
// ============================================================================

/// Subcommand for `void remote`.
pub enum RemoteSubcommand {
    List,
    Add {
        name: String,
        host: Option<String>,
        key: Option<String>,
        peer: Option<String>,
    },
    Remove {
        name: String,
    },
    Show {
        name: String,
    },
}

/// Arguments for the remote command.
pub struct RemoteArgs {
    pub subcommand: RemoteSubcommand,
}

// ============================================================================
// Unified output enum
// ============================================================================

#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum RemoteOutput {
    List(RemoteListOutput),
    Add(RemoteAddOutput),
    Remove(RemoteRemoveOutput),
    Show(RemoteEntry),
}

// ============================================================================
// Implementation
// ============================================================================

fn build_entry(name: &str, remote: &config::RemoteConfig) -> RemoteEntry {
    RemoteEntry {
        name: name.to_string(),
        host: remote.host.clone(),
        user: remote.user.clone().unwrap_or_else(|| "root".to_string()),
        key_path: remote.key_path.clone(),
        peer_multiaddr: remote.peer_multiaddr.clone(),
    }
}

/// Run the remote command.
pub fn run(cwd: &Path, args: RemoteArgs, opts: &CliOptions) -> Result<(), CliError> {
    run_command("remote", opts, |ctx| {
        let void_dir = find_void_dir(cwd)?;
        let mut cfg = config::load(&void_dir)
            .map_err(|e| CliError::internal(format!("failed to load config: {e}")))?;

        match args.subcommand {
            RemoteSubcommand::List => {
                let mut remotes: Vec<RemoteEntry> = cfg
                    .remote
                    .iter()
                    .map(|(name, remote)| build_entry(name, remote))
                    .collect();
                remotes.sort_by(|a, b| a.name.cmp(&b.name));

                if !ctx.use_json() {
                    if remotes.is_empty() {
                        ctx.info("No remotes configured. Use 'void remote add' to add one.");
                    } else {
                        for r in &remotes {
                            let host = r.host.as_deref().unwrap_or("(no host)");
                            ctx.info(format!("{}\t{}", r.name, host));
                        }
                    }
                }

                Ok(RemoteOutput::List(RemoteListOutput { remotes }))
            }

            RemoteSubcommand::Add {
                name,
                host,
                key,
                peer,
            } => {
                if cfg.remote.contains_key(&name) {
                    return Err(CliError::conflict(format!(
                        "remote '{}' already exists",
                        name
                    )));
                }

                // Parse host — supports "user@host" format
                let (parsed_user, parsed_host) = if let Some(ref h) = host {
                    if let Some(idx) = h.find('@') {
                        (Some(h[..idx].to_string()), Some(h[idx + 1..].to_string()))
                    } else {
                        (None, Some(h.clone()))
                    }
                } else {
                    (None, None)
                };

                let remote_config = config::RemoteConfig {
                    url: None,
                    host: parsed_host,
                    user: parsed_user,
                    key_path: key,
                    peer_multiaddr: peer,
                };

                cfg.remote.insert(name.clone(), remote_config);
                config::save(&void_dir, &cfg)
                    .map_err(|e| CliError::internal(format!("failed to save config: {e}")))?;

                if !ctx.use_json() {
                    ctx.info(format!("Added remote '{}'", name));
                }

                Ok(RemoteOutput::Add(RemoteAddOutput { name, added: true }))
            }

            RemoteSubcommand::Remove { name } => {
                if cfg.remote.remove(&name).is_none() {
                    return Err(CliError::not_found(format!("remote '{}' not found", name)));
                }

                config::save(&void_dir, &cfg)
                    .map_err(|e| CliError::internal(format!("failed to save config: {e}")))?;

                if !ctx.use_json() {
                    ctx.info(format!("Removed remote '{}'", name));
                }

                Ok(RemoteOutput::Remove(RemoteRemoveOutput {
                    name,
                    removed: true,
                }))
            }

            RemoteSubcommand::Show { name } => {
                let remote = cfg
                    .remote
                    .get(&name)
                    .ok_or_else(|| CliError::not_found(format!("remote '{}' not found", name)))?;

                let entry = build_entry(&name, remote);

                if !ctx.use_json() {
                    ctx.info(format!("Remote: {}", entry.name));
                    if let Some(ref host) = entry.host {
                        ctx.info(format!("  Host: {}", host));
                    }
                    ctx.info(format!("  User: {}", entry.user));
                    if let Some(ref key_path) = entry.key_path {
                        ctx.info(format!("  Key:  {}", key_path));
                    }
                    if let Some(ref peer) = entry.peer_multiaddr {
                        ctx.info(format!("  Peer: {}", peer));
                    }
                }

                Ok(RemoteOutput::Show(entry))
            }
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    fn default_opts() -> CliOptions {
        CliOptions {
            human: true,
            ..Default::default()
        }
    }

    fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
        let dir = tempdir().unwrap();
        let void_dir = dir.path().join(".void");
        fs::create_dir_all(&void_dir).unwrap();

        // Set up manifest-based key
        let key = hex::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef").unwrap();
        let key: [u8; 32] = key.try_into().unwrap();
        let home = tempdir().unwrap();
        let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());

        fs::write(void_dir.join("config.json"), "{}").unwrap();
        (dir, void_dir, home, guard)
    }

    #[test]
    fn test_remote_list_empty() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let args = RemoteArgs {
            subcommand: RemoteSubcommand::List,
        };
        let result = run(dir.path(), args, &default_opts());
        assert!(result.is_ok());
    }

    #[test]
    fn test_remote_add_and_list() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();

        // Add
        let args = RemoteArgs {
            subcommand: RemoteSubcommand::Add {
                name: "origin".to_string(),
                host: Some("ec2-user@1.2.3.4".to_string()),
                key: Some("~/.ssh/id_rsa".to_string()),
                peer: None,
            },
        };
        let result = run(dir.path(), args, &default_opts());
        assert!(result.is_ok());

        // List
        let args = RemoteArgs {
            subcommand: RemoteSubcommand::List,
        };
        let result = run(dir.path(), args, &default_opts());
        assert!(result.is_ok());
    }

    #[test]
    fn test_remote_add_duplicate() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();

        let add = || RemoteArgs {
            subcommand: RemoteSubcommand::Add {
                name: "origin".to_string(),
                host: Some("host1".to_string()),
                key: None,
                peer: None,
            },
        };

        assert!(run(dir.path(), add(), &default_opts()).is_ok());
        assert!(run(dir.path(), add(), &default_opts()).is_err());
    }

    #[test]
    fn test_remote_remove_nonexistent() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let args = RemoteArgs {
            subcommand: RemoteSubcommand::Remove {
                name: "nope".to_string(),
            },
        };
        assert!(run(dir.path(), args, &default_opts()).is_err());
    }

    #[test]
    fn test_remote_show_nonexistent() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let args = RemoteArgs {
            subcommand: RemoteSubcommand::Show {
                name: "nope".to_string(),
            },
        };
        assert!(run(dir.path(), args, &default_opts()).is_err());
    }

    #[test]
    fn test_remote_entry_serialization() {
        let entry = RemoteEntry {
            name: "origin".to_string(),
            host: Some("example.com".to_string()),
            user: "ec2-user".to_string(),
            key_path: Some("~/.ssh/id_rsa".to_string()),
            peer_multiaddr: None,
        };

        let json = serde_json::to_string(&entry).unwrap();
        assert!(json.contains("\"name\":\"origin\""));
        assert!(json.contains("\"host\":\"example.com\""));
        assert!(json.contains("\"user\":\"ec2-user\""));
        assert!(!json.contains("peerMultiaddr")); // skip None
    }
}