//! v2 REPL — thin shell over `quicproquo_sdk::QpqClient`. //! //! Provides an interactive command-line interface with categorized `/help`, //! tab-completion, and a background event listener. Delegates all crypto, //! MLS, and RPC work to the SDK. //! //! Build: `cargo build -p quicproquo-client --features v2` use std::path::PathBuf; use std::process::{Child, Command as ProcessCommand}; use std::sync::Arc; use std::time::Duration; use anyhow::Context; use quicproquo_core::{GroupMember, IdentityKeypair}; use quicproquo_sdk::client::QpqClient; use quicproquo_sdk::conversation::{ConversationId, ConversationKind, StoredMessage}; use quicproquo_sdk::events::ClientEvent; use rustyline::completion::{Completer, Pair}; use rustyline::error::ReadlineError; use rustyline::highlight::Highlighter; use rustyline::hint::Hinter; use rustyline::validate::Validator; use rustyline::{Config, Editor, Helper}; use tokio::sync::broadcast; use super::display; // ── ANSI helpers ──────────────────────────────────────────────────────────── const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; const DIM: &str = "\x1b[2m"; const GREEN: &str = "\x1b[32m"; const CYAN: &str = "\x1b[36m"; const YELLOW: &str = "\x1b[33m"; // ── Command categories ────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Eq)] enum Category { Messaging, Groups, Account, Keys, Utility, Debug, } impl Category { fn label(self) -> &'static str { match self { Self::Messaging => "Messaging", Self::Groups => "Groups", Self::Account => "Account", Self::Keys => "Keys", Self::Utility => "Utility", Self::Debug => "Debug", } } fn all() -> &'static [Category] { &[ Self::Messaging, Self::Groups, Self::Account, Self::Keys, Self::Utility, Self::Debug, ] } } // ── Static command table ──────────────────────────────────────────────────── struct CmdDef { name: &'static str, aliases: &'static [&'static str], category: Category, description: &'static str, usage: &'static str, } const COMMANDS: &[CmdDef] = &[ CmdDef { name: "/send", aliases: &["/s"], category: Category::Messaging, description: "Send a message to the current conversation", usage: "/send " }, CmdDef { name: "/dm", aliases: &[], category: Category::Messaging, description: "Start or switch to a DM with a user", usage: "/dm " }, CmdDef { name: "/recv", aliases: &["/r"], category: Category::Messaging, description: "Fetch and display new messages", usage: "/recv" }, CmdDef { name: "/history", aliases: &[], category: Category::Messaging, description: "Show recent message history", usage: "/history [count]" }, CmdDef { name: "/list", aliases: &["/ls"], category: Category::Messaging, description: "List all conversations", usage: "/list" }, CmdDef { name: "/switch", aliases: &["/sw"], category: Category::Messaging, description: "Switch active conversation", usage: "/switch " }, CmdDef { name: "/group", aliases: &["/g"], category: Category::Groups, description: "create | invite | leave | list | members | remove | rename | rotate-keys", usage: "/group [args]" }, CmdDef { name: "/devices", aliases: &[], category: Category::Account, description: "list | add | remove — manage linked devices", usage: "/devices [args]" }, CmdDef { name: "/register", aliases: &[], category: Category::Account, description: "Register a new account", usage: "/register " }, CmdDef { name: "/login", aliases: &[], category: Category::Account, description: "Log in to an existing account", usage: "/login " }, CmdDef { name: "/logout", aliases: &[], category: Category::Account, description: "Log out (clear session)", usage: "/logout" }, CmdDef { name: "/whoami", aliases: &[], category: Category::Account, description: "Show current identity", usage: "/whoami" }, CmdDef { name: "/refresh-key", aliases: &[], category: Category::Keys, description: "Upload a fresh KeyPackage", usage: "/refresh-key" }, CmdDef { name: "/safety-number", aliases: &["/verify"], category: Category::Keys, description: "Show safety number for verification", usage: "/safety-number " }, CmdDef { name: "/resolve", aliases: &[], category: Category::Utility, description: "Resolve username to identity key", usage: "/resolve " }, CmdDef { name: "/help", aliases: &["/?"], category: Category::Utility, description: "Show this help message", usage: "/help" }, CmdDef { name: "/quit", aliases: &["/q", "/exit"], category: Category::Utility, description: "Exit the REPL", usage: "/quit" }, CmdDef { name: "/clear", aliases: &[], category: Category::Utility, description: "Clear the terminal", usage: "/clear" }, CmdDef { name: "/health", aliases: &[], category: Category::Debug, description: "Check server connection health", usage: "/health" }, CmdDef { name: "/status", aliases: &[], category: Category::Debug, description: "Show connection and auth state", usage: "/status" }, ]; // ── REPL state ────────────────────────────────────────────────────────────── struct ReplState { current_conversation: Option, current_display_name: Option, identity: Option>, } impl ReplState { fn new() -> Self { Self { current_conversation: None, current_display_name: None, identity: None, } } fn prompt(&self) -> String { let name = self .current_display_name .as_deref() .unwrap_or("no conversation"); format!("{DIM}[{RESET}{BOLD}{name}{RESET}{DIM}]{RESET} > ") } fn set_conversation(&mut self, id: ConversationId, name: String) { self.current_conversation = Some(id); self.current_display_name = Some(name); } fn require_identity(&self) -> anyhow::Result> { self.identity .clone() .ok_or_else(|| anyhow::anyhow!("not logged in — use /login or /register first")) } fn require_conversation(&self) -> anyhow::Result<&ConversationId> { self.current_conversation .as_ref() .ok_or_else(|| anyhow::anyhow!("no active conversation — use /dm or /group first")) } } // ── Tab completion ────────────────────────────────────────────────────────── struct QpqCompleter { names: Vec, } impl QpqCompleter { fn new() -> Self { let mut names = Vec::new(); for cmd in COMMANDS { names.push(cmd.name.to_string()); for a in cmd.aliases { names.push(a.to_string()); } } for sub in &["create", "invite", "leave", "list", "members", "remove", "rename", "rotate-keys"] { names.push(format!("/group {sub}")); } for sub in &["list", "add", "remove"] { names.push(format!("/devices {sub}")); } Self { names } } } impl Completer for QpqCompleter { type Candidate = Pair; fn complete( &self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>, ) -> rustyline::Result<(usize, Vec)> { let prefix = &line[..pos]; if !prefix.starts_with('/') { return Ok((0, Vec::new())); } let matches: Vec = self .names .iter() .filter(|n| n.starts_with(prefix)) .map(|n| Pair { display: n.clone(), replacement: n.clone(), }) .collect(); Ok((0, matches)) } } impl Hinter for QpqCompleter { type Hint = String; } impl Highlighter for QpqCompleter {} impl Validator for QpqCompleter {} impl Helper for QpqCompleter {} // ── Auto-start server ─────────────────────────────────────────────────────── struct ServerGuard(Option); impl Drop for ServerGuard { fn drop(&mut self) { if let Some(ref mut child) = self.0 { let _ = child.kill(); let _ = child.wait(); } } } fn find_server_binary() -> Option { if let Ok(exe) = std::env::current_exe() { let sibling = exe.with_file_name("qpq-server"); if sibling.exists() { return Some(sibling); } } std::env::var_os("PATH").and_then(|paths| { std::env::split_paths(&paths) .map(|dir| dir.join("qpq-server")) .find(|p| p.exists()) }) } async fn auto_start_server(addr: &str) -> ServerGuard { if tokio::net::TcpStream::connect(addr).await.is_ok() { return ServerGuard(None); } let binary = match find_server_binary() { Some(b) => b, None => { display::print_status("server not reachable and qpq-server binary not found"); return ServerGuard(None); } }; display::print_status(&format!("starting server on {addr}...")); let child = match ProcessCommand::new(&binary) .args(["--allow-insecure-auth", "--listen", addr]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { Ok(c) => c, Err(e) => { display::print_error(&format!("failed to spawn server: {e}")); return ServerGuard(None); } }; let guard = ServerGuard(Some(child)); let mut delay = Duration::from_millis(100); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); loop { tokio::time::sleep(delay).await; if tokio::net::TcpStream::connect(addr).await.is_ok() { display::print_status("server ready"); return guard; } if tokio::time::Instant::now() > deadline { display::print_error("server did not become ready within 5 s"); return guard; } delay = (delay * 2).min(Duration::from_secs(1)); } } // ── Background event listener ─────────────────────────────────────────────── fn spawn_event_listener(mut rx: broadcast::Receiver) { tokio::spawn(async move { loop { match rx.recv().await { Ok(event) => show_event(&event), Err(broadcast::error::RecvError::Lagged(n)) => { display::print_status(&format!("(skipped {n} events)")); } Err(broadcast::error::RecvError::Closed) => break, } } }); } fn show_event(event: &ClientEvent) { match event { ClientEvent::MessageReceived { sender_name, sender_key, body, .. } => { let sender = match sender_name.as_deref() { Some(n) if !n.is_empty() => n.to_string(), _ => hex::encode(&sender_key[..4.min(sender_key.len())]), }; display::print_incoming(&sender, body); } ClientEvent::ConversationCreated { display_name, .. } => { display::print_status(&format!("new conversation: {display_name}")); } ClientEvent::MemberAdded { member_key, .. } => { display::print_status(&format!( "member added: {}", hex::encode(&member_key[..4.min(member_key.len())]) )); } ClientEvent::Error { message } => display::print_error(message), _ => {} } } // ── Help ──────────────────────────────────────────────────────────────────── fn print_help() { println!("\n{BOLD}quicproquo v2 REPL{RESET}\n"); for cat in Category::all() { println!("{BOLD}{}{RESET}", cat.label()); for cmd in COMMANDS.iter().filter(|c| c.category == *cat) { let aliases = if cmd.aliases.is_empty() { String::new() } else { format!(" {DIM}({}){RESET}", cmd.aliases.join(", ")) }; println!(" {GREEN}{:<24}{RESET} {}{aliases}", cmd.usage, cmd.description); } println!(); } println!("{DIM}Bare text (without /) sends to the current conversation.{RESET}\n"); } // ── Formatting helpers ────────────────────────────────────────────────────── fn ts(ms: u64) -> String { let s = ms / 1000; format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60) } fn print_stored(msg: &StoredMessage) { let t = ts(msg.timestamp_ms); if msg.is_outgoing { println!("{DIM}[{t}]{RESET} {GREEN}> {}{RESET}", msg.body); } else { let fallback = hex::encode(&msg.sender_key[..4.min(msg.sender_key.len())]); let sender = msg.sender_name.as_deref().unwrap_or(&fallback); println!("{DIM}[{t}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {}", msg.body); } } // ── Command parsing ───────────────────────────────────────────────────────── fn split_cmd(input: &str) -> Option<(&str, &str)> { let s = input.trim(); if !s.starts_with('/') { return None; } match s.find(char::is_whitespace) { Some(i) => Some((&s[..i], s[i..].trim())), None => Some((s, "")), } } // ── Command dispatch ──────────────────────────────────────────────────────── /// Returns `Ok(true)` when the REPL should exit. async fn dispatch( cmd: &str, args: &str, client: &mut QpqClient, st: &mut ReplState, ) -> anyhow::Result { match cmd { "/quit" | "/q" | "/exit" => return Ok(true), "/help" | "/?" => print_help(), "/clear" => print!("\x1b[2J\x1b[H"), "/status" => do_status(client, st), "/health" => do_health(client), "/whoami" => do_whoami(client), "/register" => do_register(client, st, args).await?, "/login" => do_login(client, st, args).await?, "/logout" => do_logout(client)?, "/resolve" => do_resolve(client, args).await?, "/safety-number" | "/verify" => do_safety(client, st, args).await?, "/refresh-key" => do_refresh_key(client, st).await?, "/dm" => do_dm(client, st, args).await?, "/send" | "/s" => do_send(client, st, args).await?, "/recv" | "/r" => do_recv(client, st).await?, "/history" => do_history(client, st, args)?, "/list" | "/ls" => do_list(client)?, "/switch" | "/sw" => do_switch(client, st, args)?, "/group" | "/g" => do_group(client, st, args).await?, "/devices" => do_devices(client, args).await?, _ => display::print_error(&format!("unknown command: {cmd} (try /help)")), } Ok(false) } // ── Command implementations ───────────────────────────────────────────────── fn do_status(client: &QpqClient, st: &ReplState) { println!("{BOLD}Status{RESET}"); println!(" connected: {}", if client.is_connected() { "yes" } else { "no" }); println!(" authenticated: {}", if client.is_authenticated() { "yes" } else { "no" }); println!(" username: {}", client.username().unwrap_or("(none)")); println!(" conversation: {}", st.current_display_name.as_deref().unwrap_or("(none)")); if let Some(key) = client.identity_key() { println!(" identity: {}", hex::encode(key)); } } fn do_health(client: &QpqClient) { if client.is_connected() { display::print_status("connected to server"); } else { display::print_error("not connected"); } } fn do_whoami(client: &QpqClient) { match client.username() { Some(u) => { println!("{BOLD}{u}{RESET}"); if let Some(key) = client.identity_key() { println!("{DIM}identity: {}{RESET}", hex::encode(key)); } } None => display::print_status("not logged in"), } } async fn do_register(client: &mut QpqClient, _st: &mut ReplState, args: &str) -> anyhow::Result<()> { let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect(); if parts.len() < 2 || parts[1].is_empty() { display::print_error("usage: /register "); return Ok(()); } let (user, pass) = (parts[0], parts[1]); client.register(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?; // After registration the SDK has set the identity key (public). // To get the full keypair we need the seed. Since `register` internally // generates a fresh keypair, we load it from the state file if available, // or generate a stand-in for this session. if let Some(pub_key) = client.identity_key() { if pub_key.len() == 32 { display::print_status(&format!("registered as {user}")); display::print_status(&format!("identity: {}", hex::encode(pub_key))); } } // Note: identity keypair is set during login (which gives us the seed via state). display::print_status("use /login to authenticate"); Ok(()) } async fn do_login(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect(); if parts.len() < 2 || parts[1].is_empty() { display::print_error("usage: /login "); return Ok(()); } let (user, pass) = (parts[0], parts[1]); client.login(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?; // Try to load identity keypair from state file. let state_path = &client.config_state_path(); if state_path.exists() { match quicproquo_sdk::state::load_state(state_path, Some(pass)) { Ok(stored) => { let kp = IdentityKeypair::from_seed(stored.identity_seed); st.identity = Some(Arc::new(kp)); } Err(_) => { // Try without password (unencrypted state). if let Ok(stored) = quicproquo_sdk::state::load_state(state_path, None) { let kp = IdentityKeypair::from_seed(stored.identity_seed); st.identity = Some(Arc::new(kp)); } } } } display::print_status(&format!("logged in as {user}")); Ok(()) } fn do_logout(client: &mut QpqClient) -> anyhow::Result<()> { client.logout().map_err(|e| anyhow::anyhow!("{e}"))?; display::print_status("logged out"); Ok(()) } async fn do_resolve(client: &QpqClient, args: &str) -> anyhow::Result<()> { let name = args.trim(); if name.is_empty() { display::print_error("usage: /resolve "); return Ok(()); } let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; match quicproquo_sdk::users::resolve_user(rpc, name).await? { Some(key) => println!(" {name} -> {}", hex::encode(&key)), None => display::print_error(&format!("user '{name}' not found")), } Ok(()) } async fn do_safety(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> { let name = args.trim(); if name.is_empty() { display::print_error("usage: /safety-number "); return Ok(()); } let identity = st.require_identity()?; let my_key = identity.public_key_bytes(); let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let peer_key = quicproquo_sdk::users::resolve_user(rpc, name) .await? .ok_or_else(|| anyhow::anyhow!("user '{name}' not found"))?; if peer_key.len() != 32 { anyhow::bail!("peer key is not 32 bytes"); } let mut peer_arr = [0u8; 32]; peer_arr.copy_from_slice(&peer_key); let sn = quicproquo_core::compute_safety_number(&my_key, &peer_arr); println!("\n{BOLD}Safety number with {name}:{RESET}"); println!(" {sn}\n"); println!("{DIM}Compare with {name} over a trusted channel.{RESET}"); Ok(()) } async fn do_refresh_key(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> { let identity = st.require_identity()?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let mut member = GroupMember::new(Arc::clone(&identity)); let kp_bytes = member .generate_key_package() .map_err(|e| anyhow::anyhow!("generate key package: {e}"))?; let pub_key = identity.public_key_bytes(); let fp = quicproquo_sdk::keys::upload_key_package(rpc, &pub_key, &kp_bytes).await?; display::print_status(&format!( "KeyPackage uploaded (fp: {})", hex::encode(&fp[..8.min(fp.len())]) )); Ok(()) } async fn do_dm(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { let username = args.trim(); if username.is_empty() { display::print_error("usage: /dm "); return Ok(()); } let identity = st.require_identity()?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let peer_key = quicproquo_sdk::users::resolve_user(rpc, username) .await? .ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?; // Check for existing DM. if let Some(existing) = conv_store.find_dm_by_peer(&peer_key)? { st.set_conversation(existing.id, format!("@{username}")); display::print_status(&format!("switched to DM with @{username}")); return Ok(()); } let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key) .await? .ok_or_else(|| anyhow::anyhow!("peer has no available KeyPackage"))?; let mut member = GroupMember::new(Arc::clone(&identity)); let (conv_id, was_new) = quicproquo_sdk::groups::create_dm( rpc, conv_store, &mut member, &identity, &peer_key, &peer_kp, None, None, ).await?; st.set_conversation(conv_id, format!("@{username}")); if was_new { display::print_status(&format!("DM created with @{username}")); } else { display::print_status(&format!("DM with @{username} — waiting for Welcome")); } Ok(()) } async fn do_send(client: &QpqClient, st: &ReplState, msg: &str) -> anyhow::Result<()> { if msg.is_empty() { display::print_error("usage: /send "); return Ok(()); } let conv_id = st.require_conversation()?; let identity = st.require_identity()?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let conv = conv_store .load_conversation(conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; let my_pub = identity.public_key_bytes(); let recipients: Vec> = conv .member_keys .iter() .filter(|k| k.as_slice() != my_pub.as_slice()) .cloned() .collect(); if recipients.is_empty() { display::print_error("no recipients in conversation"); return Ok(()); } let hybrid_keys = vec![None; recipients.len()]; quicproquo_sdk::messaging::send_message( rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(), ).await?; quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?; let now = quicproquo_sdk::conversation::now_ms(); conv_store.save_message(&StoredMessage { conversation_id: conv_id.clone(), message_id: None, sender_key: my_pub.to_vec(), sender_name: client.username().map(|s| s.to_string()), body: msg.to_string(), msg_type: "chat".to_string(), ref_msg_id: None, timestamp_ms: now, is_outgoing: true, })?; println!("{GREEN}> {msg}{RESET}"); Ok(()) } async fn do_recv(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> { let conv_id = st.require_conversation()?; let identity = st.require_identity()?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let conv = conv_store .load_conversation(conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; let my_pub = identity.public_key_bytes(); let messages = quicproquo_sdk::messaging::receive_messages( rpc, &mut member, &my_pub, None, conv_id.0.as_slice(), &[], ).await?; if messages.is_empty() { display::print_status("no new messages"); return Ok(()); } quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?; for m in &messages { let sender_name = quicproquo_sdk::users::resolve_identity(rpc, &m.sender_key) .await .ok() .flatten(); let sender_hex = hex::encode(&m.sender_key[..4]); let sender = sender_name.as_deref().unwrap_or(&sender_hex); let body = match &m.message { quicproquo_core::AppMessage::Chat { body, .. } => { String::from_utf8_lossy(body).to_string() } other => format!("{other:?}"), }; let now = quicproquo_sdk::conversation::now_ms(); println!("{DIM}[{}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {body}", ts(now)); conv_store.save_message(&StoredMessage { conversation_id: conv_id.clone(), message_id: None, sender_key: m.sender_key.to_vec(), sender_name: sender_name.clone(), body, msg_type: "chat".to_string(), ref_msg_id: None, timestamp_ms: now, is_outgoing: false, })?; } display::print_status(&format!("{} message(s) received", messages.len())); Ok(()) } fn do_history(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> { let conv_id = st.require_conversation()?; let count = args.trim().parse::().unwrap_or(20); let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let msgs = conv_store.load_recent_messages(conv_id, count)?; if msgs.is_empty() { display::print_status("no messages yet"); } else { for m in &msgs { print_stored(m); } } Ok(()) } fn do_list(client: &QpqClient) -> anyhow::Result<()> { let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let convs = conv_store.list_conversations()?; if convs.is_empty() { display::print_status("no conversations — try /dm "); return Ok(()); } println!("\n{BOLD}Conversations{RESET}"); for c in &convs { let kind_label = match &c.kind { ConversationKind::Dm { .. } => "dm", ConversationKind::Group { .. } => "group", }; let unread = if c.unread_count > 0 { format!(" {YELLOW}({} new){RESET}", c.unread_count) } else { String::new() }; println!( " {BOLD}{}{RESET} {DIM}[{kind_label}, {} members]{RESET}{unread}", c.display_name, c.member_keys.len() ); } println!(); Ok(()) } fn do_switch(client: &QpqClient, st: &mut ReplState, name: &str) -> anyhow::Result<()> { let name = name.trim(); if name.is_empty() { display::print_error("usage: /switch "); return Ok(()); } let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let convs = conv_store.list_conversations()?; let lower = name.to_lowercase(); let found = convs.iter().find(|c| c.display_name.to_lowercase().contains(&lower)); match found { Some(c) => { st.set_conversation(c.id.clone(), c.display_name.clone()); display::print_status(&format!("switched to {}", c.display_name)); } None => display::print_error(&format!("no conversation matching '{name}'")), } Ok(()) } async fn do_group(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> { let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect(); let sub = parts.first().copied().unwrap_or(""); match sub { "create" => { let name = parts.get(1).copied().unwrap_or("").trim(); if name.is_empty() { display::print_error("usage: /group create "); return Ok(()); } let identity = st.require_identity()?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let mut member = GroupMember::new(Arc::clone(&identity)); let conv_id = quicproquo_sdk::groups::create_group(conv_store, &mut member, name)?; st.set_conversation(conv_id, format!("#{name}")); display::print_status(&format!("group #{name} created")); } "invite" => { let group = parts.get(1).copied().unwrap_or("").trim(); let user = parts.get(2).copied().unwrap_or("").trim(); if group.is_empty() || user.is_empty() { display::print_error("usage: /group invite "); return Ok(()); } let identity = st.require_identity()?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let peer_key = quicproquo_sdk::users::resolve_user(rpc, user) .await? .ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?; let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key) .await? .ok_or_else(|| anyhow::anyhow!("peer has no KeyPackage"))?; let conv_id = ConversationId::from_group_name(group); let conv = conv_store .load_conversation(&conv_id)? .ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; quicproquo_sdk::groups::invite_to_group( rpc, conv_store, &mut member, &identity, &conv_id, &peer_key, &peer_kp, None, None, ).await?; display::print_status(&format!("invited @{user} to #{group}")); } "leave" => { let identity = st.require_identity()?; let conv_id = st.require_conversation()?.clone(); let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let conv = conv_store .load_conversation(&conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; quicproquo_sdk::groups::leave_group(rpc, conv_store, &mut member, &conv_id).await?; display::print_status("left group"); } "list" => do_list(client)?, "members" => { let conv_id = st.require_conversation()?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let conv = conv_store .load_conversation(conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; println!("\n{BOLD}Members{RESET} ({})", conv.member_keys.len()); for key in &conv.member_keys { let short = hex::encode(&key[..4.min(key.len())]); if let Ok(rpc) = client.rpc() { if let Ok(Some(n)) = quicproquo_sdk::users::resolve_identity(rpc, key).await { println!(" @{n} {DIM}({short}){RESET}"); continue; } } println!(" {short}"); } println!(); } "remove" => { let user = parts.get(1).copied().unwrap_or("").trim(); if user.is_empty() { display::print_error("usage: /group remove "); return Ok(()); } let identity = st.require_identity()?; let conv_id = st.require_conversation()?.clone(); let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let peer_key = quicproquo_sdk::users::resolve_user(rpc, user) .await? .ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?; let conv = conv_store .load_conversation(&conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; quicproquo_sdk::groups::remove_member_from_group( rpc, conv_store, &mut member, &conv_id, &peer_key, ).await?; display::print_status(&format!("removed @{user} from group")); } "rename" => { let new_name = parts.get(1).copied().unwrap_or("").trim(); if new_name.is_empty() { display::print_error("usage: /group rename "); return Ok(()); } let conv_id = st.require_conversation()?.clone(); let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; quicproquo_sdk::groups::set_group_metadata( rpc, conv_store, &conv_id, new_name, "", &[], ).await?; st.set_conversation(conv_id, format!("#{new_name}")); display::print_status(&format!("group renamed to #{new_name}")); } "rotate-keys" => { let identity = st.require_identity()?; let conv_id = st.require_conversation()?.clone(); let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?; let conv = conv_store .load_conversation(&conv_id)? .ok_or_else(|| anyhow::anyhow!("conversation not found"))?; let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; quicproquo_sdk::groups::rotate_group_keys(rpc, conv_store, &mut member, &conv_id).await?; display::print_status("group keys rotated"); } _ => display::print_error("usage: /group [args]"), } Ok(()) } // ── Device management ────────────────────────────────────────────────────── async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> { let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect(); let sub = parts.first().copied().unwrap_or(""); match sub { "list" => { let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let devices = quicproquo_sdk::devices::list_devices(rpc).await?; if devices.is_empty() { display::print_status("no devices registered"); } else { println!("\n{BOLD}Devices{RESET} ({})", devices.len()); println!("{:<36} {:<20} REGISTERED AT", "DEVICE ID", "NAME"); for d in &devices { println!( "{:<36} {:<20} {}", hex::encode(&d.device_id), d.device_name, d.registered_at, ); } println!(); } } "add" => { let name = parts.get(1).copied().unwrap_or("").trim(); if name.is_empty() { display::print_error("usage: /devices add "); return Ok(()); } let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; // Generate a random device ID (16 bytes). use rand::RngCore; let mut dev_id = vec![0u8; 16]; rand::rngs::OsRng.fill_bytes(&mut dev_id); let was_new = quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?; if was_new { display::print_status(&format!( "device registered: {name} (id: {})", hex::encode(&dev_id) )); } else { display::print_status(&format!("device already exists: {name}")); } } "remove" => { let id_hex = parts.get(1).copied().unwrap_or("").trim(); if id_hex.is_empty() { display::print_error("usage: /devices remove "); return Ok(()); } let id_bytes = hex::decode(id_hex) .map_err(|e| anyhow::anyhow!("invalid device_id hex: {e}"))?; let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?; let revoked = quicproquo_sdk::devices::revoke_device(rpc, &id_bytes).await?; if revoked { display::print_status(&format!("device revoked: {id_hex}")); } else { display::print_error(&format!("device not found: {id_hex}")); } } _ => display::print_error("usage: /devices [args]"), } Ok(()) } // ── Entry point ───────────────────────────────────────────────────────────── /// Run the v2 REPL over a `QpqClient`. /// /// If `username` and `password` are provided, auto-login is attempted. pub async fn run_v2_repl( client: &mut QpqClient, username: Option<&str>, password: Option<&str>, ) -> anyhow::Result<()> { // Auto-start server. let _server_guard = auto_start_server(&client.server_addr_string()).await; // Connect to server. client.connect().await.context("connect to server")?; // Background event listener. let rx = client.subscribe(); spawn_event_listener(rx); let mut st = ReplState::new(); // Auto-login if credentials provided. if let (Some(user), Some(pass)) = (username, password) { match client.login(user, pass).await { Ok(()) => { display::print_status(&format!("logged in as {user}")); // Load identity from state. let state_path = client.config_state_path(); if state_path.exists() { if let Ok(stored) = quicproquo_sdk::state::load_state(&state_path, Some(pass)) .or_else(|_| quicproquo_sdk::state::load_state(&state_path, None)) { let kp = IdentityKeypair::from_seed(stored.identity_seed); st.identity = Some(Arc::new(kp)); } } } Err(e) => display::print_error(&format!("auto-login failed: {e}")), } } println!("\n{BOLD}quicproquo v2 REPL{RESET}"); println!("{DIM}Type /help for commands, /quit to exit.{RESET}\n"); if let Some(u) = client.username() { display::print_status(&format!("authenticated as {u}")); } // Rustyline editor with tab-completion. let config = Config::builder().auto_add_history(true).build(); let mut rl: Editor = Editor::with_config(config).context("init readline")?; rl.set_helper(Some(QpqCompleter::new())); loop { let prompt = st.prompt(); match rl.readline(&prompt) { Ok(line) => { let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Some((cmd, args)) = split_cmd(trimmed) { match dispatch(cmd, args, client, &mut st).await { Ok(true) => break, Ok(false) => {} Err(e) => display::print_error(&format!("{e:#}")), } } else { // Bare text → send to current conversation. if let Err(e) = do_send(client, &st, trimmed).await { display::print_error(&format!("{e:#}")); } } } Err(ReadlineError::Interrupted | ReadlineError::Eof) => { display::print_status("goodbye"); break; } Err(e) => { display::print_error(&format!("readline: {e}")); break; } } } client.disconnect(); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn split_cmd_basic_command() { assert_eq!(split_cmd("/help"), Some(("/help", ""))); } #[test] fn split_cmd_command_with_args() { assert_eq!(split_cmd("/send hello world"), Some(("/send", "hello world"))); } #[test] fn split_cmd_leading_whitespace() { assert_eq!(split_cmd(" /quit"), Some(("/quit", ""))); } #[test] fn split_cmd_non_command() { assert_eq!(split_cmd("hello"), None); } #[test] fn split_cmd_empty_input() { assert_eq!(split_cmd(""), None); assert_eq!(split_cmd(" "), None); } #[test] fn split_cmd_slash_only() { assert_eq!(split_cmd("/"), Some(("/", ""))); } #[test] fn split_cmd_args_trimmed() { assert_eq!(split_cmd("/dm alice"), Some(("/dm", "alice"))); } #[test] fn split_cmd_special_characters() { assert_eq!( split_cmd("/send hello! @alice #chan"), Some(("/send", "hello! @alice #chan")) ); } }