use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod updater;
#[derive(Parser, Debug)]
#[command(name = "saorsa-gossip")]
#[command(version, about = "Saorsa Gossip Network CLI Tool", long_about = None)]
struct Args {
#[arg(short, long, default_value = "~/.saorsa-gossip")]
config_dir: PathBuf,
#[arg(short, long)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Identity {
#[command(subcommand)]
action: IdentityAction,
},
Network {
#[command(subcommand)]
action: NetworkAction,
},
Pubsub {
#[command(subcommand)]
action: PubsubAction,
},
Presence {
#[command(subcommand)]
action: PresenceAction,
},
Groups {
#[command(subcommand)]
action: GroupAction,
},
Crdt {
#[command(subcommand)]
action: CrdtAction,
},
Rendezvous {
#[command(subcommand)]
action: RendezvousAction,
},
Demo {
#[arg(short, long, default_value = "basic")]
scenario: String,
},
Update {
#[arg(short, long)]
check_only: bool,
},
}
#[derive(Subcommand, Debug)]
enum IdentityAction {
Create {
#[arg(short, long)]
alias: String,
},
List,
Show {
alias: String,
},
Delete {
alias: String,
},
}
#[derive(Subcommand, Debug)]
enum NetworkAction {
Join {
#[arg(short, long)]
coordinator: String,
#[arg(short, long)]
identity: String,
#[arg(short, long, default_value = "0.0.0.0:0")]
bind: String,
},
Status,
Peers,
Leave,
}
#[derive(Subcommand, Debug)]
enum PubsubAction {
Subscribe {
#[arg(short, long)]
topic: String,
},
Publish {
#[arg(short, long)]
topic: String,
#[arg(short, long)]
message: String,
},
Unsubscribe {
#[arg(short, long)]
topic: String,
},
List,
}
#[derive(Subcommand, Debug)]
enum PresenceAction {
Start {
#[arg(short, long)]
topic: String,
},
Stop {
#[arg(short, long)]
topic: String,
},
Online {
#[arg(short, long)]
topic: String,
},
}
#[derive(Subcommand, Debug)]
enum GroupAction {
Create {
#[arg(short, long)]
name: String,
},
Join {
#[arg(short, long)]
group_id: String,
},
Leave {
#[arg(short, long)]
group_id: String,
},
List,
}
#[derive(Subcommand, Debug)]
enum CrdtAction {
LwwRegister {
value: String,
},
OrSet {
#[arg(short, long)]
action: String,
value: String,
},
Show,
}
#[derive(Subcommand, Debug)]
enum RendezvousAction {
Register {
#[arg(short, long)]
capability: String,
},
Find {
#[arg(short, long)]
capability: String,
},
Unregister,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
init_logging(args.verbose)?;
tracing::info!("Saorsa Gossip CLI v{}", env!("CARGO_PKG_VERSION"));
let config_dir = expand_path(&args.config_dir)?;
tracing::debug!("Config directory: {}", config_dir.display());
tokio::fs::create_dir_all(&config_dir).await?;
if !matches!(args.command, Commands::Update { .. }) {
updater::silent_update_check(&config_dir).await;
}
match args.command {
Commands::Identity { action } => handle_identity(action, &config_dir).await?,
Commands::Network { action } => handle_network(action, &config_dir).await?,
Commands::Pubsub { action } => handle_pubsub(action, &config_dir).await?,
Commands::Presence { action } => handle_presence(action, &config_dir).await?,
Commands::Groups { action } => handle_groups(action, &config_dir).await?,
Commands::Crdt { action } => handle_crdt(action, &config_dir).await?,
Commands::Rendezvous { action } => handle_rendezvous(action, &config_dir).await?,
Commands::Demo { scenario } => handle_demo(&scenario, &config_dir).await?,
Commands::Update { check_only } => handle_update(check_only).await?,
}
Ok(())
}
async fn handle_identity(action: IdentityAction, config_dir: &std::path::Path) -> Result<()> {
use saorsa_gossip_identity::Identity;
match action {
IdentityAction::Create { alias } => {
tracing::info!("Creating identity: {}", alias);
let identity = Identity::new(alias.clone())?;
let peer_id = identity.peer_id();
let keystore = config_dir.join("keystore");
let keystore_str = path_to_string(&keystore)?;
identity.save_to_keystore(&alias, &keystore_str).await?;
println!("✓ Created identity: {}", alias);
println!(" PeerId: {}", hex::encode(peer_id.as_bytes()));
println!(" Saved to: {}", keystore.display());
}
IdentityAction::List => {
tracing::info!("Listing identities");
let keystore = config_dir.join("keystore");
if !keystore.exists() {
println!("No identities found");
return Ok(());
}
let mut entries = tokio::fs::read_dir(&keystore).await?;
let mut count = 0;
println!("Identities:");
while let Some(entry) = entries.next_entry().await? {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".identity") {
let alias = name.trim_end_matches(".identity").replace('_', "-");
println!(" - {}", alias);
count += 1;
}
}
}
if count == 0 {
println!(" (none)");
}
}
IdentityAction::Show { alias } => {
tracing::info!("Showing identity: {}", alias);
let keystore = config_dir.join("keystore");
let identity =
Identity::load_from_keystore(&alias, &path_to_string(&keystore)?).await?;
println!("Identity: {}", alias);
println!(" PeerId: {}", hex::encode(identity.peer_id().as_bytes()));
println!(" Alias: {}", identity.alias());
}
IdentityAction::Delete { alias } => {
tracing::info!("Deleting identity: {}", alias);
let keystore = config_dir.join("keystore");
let filename = alias.replace('-', "_");
let file_path = keystore.join(format!("{}.identity", filename));
if file_path.exists() {
tokio::fs::remove_file(&file_path).await?;
println!("✓ Deleted identity: {}", alias);
} else {
println!("Identity not found: {}", alias);
}
}
}
Ok(())
}
async fn handle_network(action: NetworkAction, config_dir: &std::path::Path) -> Result<()> {
use saorsa_gossip_identity::Identity;
use saorsa_gossip_transport::AntQuicTransport;
match action {
NetworkAction::Join {
coordinator,
identity,
bind,
} => {
tracing::info!("Joining network with identity: {}", identity);
let keystore = config_dir.join("keystore");
let ident =
Identity::load_from_keystore(&identity, &path_to_string(&keystore)?).await?;
println!("✓ Loaded identity: {}", identity);
println!(" PeerId: {}", hex::encode(ident.peer_id().as_bytes()));
let bind_addr: std::net::SocketAddr = bind.parse()?;
let coordinator_addr: std::net::SocketAddr = coordinator.parse()?;
println!("\n🌐 Connecting to network...");
println!(" Coordinator: {}", coordinator);
println!(" Local bind: {}", bind);
println!(" Creating transport and establishing QUIC connection...");
let transport = AntQuicTransport::new(bind_addr, vec![coordinator_addr]).await?;
println!("\n✓ Transport initialized and connected!");
println!(
" Transport PeerId: {}",
hex::encode(transport.peer_id().as_bytes())
);
println!(" Ant PeerId: {:?}", transport.ant_peer_id());
println!("\n📡 Sending PING to coordinator...");
use saorsa_gossip_transport::GossipTransport;
use std::time::Instant;
let ping_start = Instant::now();
println!("⏳ Waiting for coordinator response (5s timeout)...");
let receive_task = tokio::spawn({
let transport = std::sync::Arc::new(transport);
async move {
match tokio::time::timeout(
std::time::Duration::from_secs(5),
transport.receive_message(),
)
.await
{
Ok(Ok((peer_id, stream_type, data))) => Some((peer_id, stream_type, data)),
Ok(Err(e)) => {
println!("❌ Error receiving: {}", e);
None
}
Err(_) => {
println!("⏱️ Timeout waiting for response");
None
}
}
}
});
if let Ok(Some((peer_id, _stream_type, data))) = receive_task.await {
let rtt = ping_start.elapsed();
println!(
"✓ Received response from peer {}",
hex::encode(peer_id.as_bytes())
);
println!(" RTT: {:?}", rtt);
println!(" Data: {}", String::from_utf8_lossy(&data));
}
println!("\n⚠️ Full network integration in progress!");
println!(" - Address reflection (IPv4/IPv6 observation)");
println!(" - NAT type detection");
println!(" - Peer discovery and listing");
println!("\nPress Ctrl+C to disconnect");
tokio::signal::ctrl_c().await?;
println!("\n👋 Disconnecting...");
}
NetworkAction::Status => {
println!("Network status - Coming soon!");
println!("This will show:");
println!(" - Connection state");
println!(" - Observed IPv4/IPv6 addresses");
println!(" - NAT type");
println!(" - Active peer count");
}
NetworkAction::Peers => {
println!("Peer list - Coming soon!");
println!("This will show:");
println!(" - Connected peers with IPs (IPv4/IPv6)");
println!(" - RTT to each peer");
println!(" - Connection type (direct/relayed)");
}
NetworkAction::Leave => {
println!("Leave network - Coming soon!");
}
}
Ok(())
}
async fn handle_pubsub(_action: PubsubAction, _config_dir: &std::path::Path) -> Result<()> {
println!("PubSub commands - Coming soon!");
println!("This will demonstrate:");
println!(" - Subscribing to topics");
println!(" - Publishing messages");
println!(" - Gossip-based message propagation");
println!(" - ML-DSA signatures on messages");
Ok(())
}
async fn handle_presence(_action: PresenceAction, _config_dir: &std::path::Path) -> Result<()> {
println!("Presence commands - Coming soon!");
println!("This will demonstrate:");
println!(" - Periodic presence beacons");
println!(" - Online peer discovery");
println!(" - Presence TTL and expiration");
Ok(())
}
async fn handle_groups(_action: GroupAction, _config_dir: &std::path::Path) -> Result<()> {
println!("Group commands - Coming soon!");
println!("This will demonstrate:");
println!(" - Creating encrypted groups");
println!(" - Joining with shared secrets");
println!(" - Group messaging");
Ok(())
}
async fn handle_crdt(_action: CrdtAction, _config_dir: &std::path::Path) -> Result<()> {
println!("CRDT commands - Coming soon!");
println!("This will demonstrate:");
println!(" - LWW Register operations");
println!(" - OR-Set add/remove");
println!(" - Anti-entropy synchronization");
Ok(())
}
async fn handle_rendezvous(_action: RendezvousAction, _config_dir: &std::path::Path) -> Result<()> {
println!("Rendezvous commands - Coming soon!");
println!("This will demonstrate:");
println!(" - Provider registration");
println!(" - Capability-based discovery");
println!(" - DHT-based lookups");
Ok(())
}
async fn handle_demo(scenario: &str, _config_dir: &std::path::Path) -> Result<()> {
match scenario {
"basic" => {
println!("=== Saorsa Gossip Basic Demo ===");
println!();
println!("This demo will showcase:");
println!(" 1. Identity creation with ML-DSA");
println!(" 2. Network bootstrap");
println!(" 3. Peer discovery");
println!(" 4. PubSub messaging");
println!(" 5. Presence beacons");
println!();
println!("To run individual commands, use:");
println!(" saorsa-gossip identity create --alias Alice");
println!(" saorsa-gossip network join --coordinator 127.0.0.1:7000 --identity Alice");
println!();
println!("Demo implementation coming soon!");
}
_ => {
println!("Unknown demo scenario: {}", scenario);
println!("Available scenarios: basic");
}
}
Ok(())
}
fn init_logging(verbose: bool) -> Result<()> {
use tracing_subscriber::EnvFilter;
let filter = if verbose {
EnvFilter::new("debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
Ok(())
}
fn expand_path(path: &std::path::Path) -> Result<PathBuf> {
let expanded = shellexpand::tilde(&path.to_string_lossy()).to_string();
Ok(PathBuf::from(expanded))
}
fn path_to_string(path: &std::path::Path) -> Result<String> {
path.to_str()
.map(str::to_owned)
.ok_or_else(|| anyhow!("Path contains invalid UTF-8: {}", path.display()))
}
async fn handle_update(check_only: bool) -> Result<()> {
if check_only {
println!("🔍 Checking for updates...");
match updater::check_for_update().await? {
Some(new_version) => {
println!("✓ Update available: {}", new_version);
println!(" Run 'saorsa-gossip update' to install");
}
None => {
println!("✓ Already on latest version: {}", env!("CARGO_PKG_VERSION"));
}
}
} else {
updater::perform_update().await?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_cli_parses() {
let _args = Args::try_parse_from(["saorsa-gossip", "demo", "--scenario", "basic"]);
}
#[test]
fn test_expand_path_no_tilde() {
let path = std::path::Path::new("/tmp/test");
let expanded = expand_path(path).expect("expand");
assert_eq!(expanded, path);
}
#[test]
fn test_expand_path_with_tilde() {
let path = std::path::Path::new("~/test");
let expanded = expand_path(path).expect("expand");
assert!(expanded.to_string_lossy().contains("test"));
assert!(!expanded.to_string_lossy().contains('~'));
}
}