use std::path::Path;
use serde::Serialize;
use void_core::config;
use crate::context::find_void_dir;
use crate::output::{run_command, CliError, CliOptions};
#[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>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RemoteListOutput {
pub remotes: Vec<RemoteEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RemoteAddOutput {
pub name: String,
pub added: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RemoteRemoveOutput {
pub name: String,
pub removed: bool,
}
pub enum RemoteSubcommand {
List,
Add {
name: String,
host: Option<String>,
key: Option<String>,
peer: Option<String>,
},
Remove {
name: String,
},
Show {
name: String,
},
}
pub struct RemoteArgs {
pub subcommand: RemoteSubcommand,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum RemoteOutput {
List(RemoteListOutput),
Add(RemoteAddOutput),
Remove(RemoteRemoveOutput),
Show(RemoteEntry),
}
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(),
}
}
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
)));
}
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();
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();
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());
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")); }
}