diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..2dede54 --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,3 @@ +# Clippy configuration +avoid-breaking-exported-api = false + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b3e44c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Format check + run: cargo fmt --all -- --check + + - name: Build + run: cargo build --verbose --all-targets + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Run all tests + run: cargo test --verbose --all + diff --git a/.gitignore b/.gitignore index 6985cf1..bc4d834 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,5 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - -# These are backup files generated by rustfmt +/target/ **/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information +Cargo.lock *.pdb +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9758297 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "simple-chat" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "server" +path = "src/bin/server.rs" + +[[bin]] +name = "client" +path = "src/bin/client.rs" + +[dependencies] +tokio = { version = "1.35", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.4", features = ["derive", "env"] } +futures = "0.3" + +[dev-dependencies] +tokio-test = "0.4" + diff --git a/Recording 2025-11-24 003708.mp4 b/Recording 2025-11-24 003708.mp4 new file mode 100644 index 0000000..da164a6 Binary files /dev/null and b/Recording 2025-11-24 003708.mp4 differ diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..9beb966 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,124 @@ +# Usage Guide + +## Building + +```bash +cargo build --release +``` + +## Running the Server + +The server can be run with default settings (127.0.0.1:8080) or with custom host/port via environment variables: + +```bash +# Default (127.0.0.1:8080) +cargo run --bin server + +# Custom host and port +HOST=0.0.0.0 PORT=9000 cargo run --bin server +``` + +## Running the Client + +The client requires a username and can connect to a server using command-line arguments or environment variables: + +```bash +# Using command-line arguments +cargo run --bin client -- --username alice --host 127.0.0.1 --port 8080 + +# Using environment variables +USERNAME=alice HOST=127.0.0.1 PORT=8080 cargo run --bin client + +# Mix of both (CLI args take precedence) +USERNAME=alice cargo run --bin client -- --host 127.0.0.1 +``` + +## Client Commands + +Once connected, the client supports the following commands: + +- `send ` - Send a message to the chat room +- `leave` - Disconnect from the server and exit + +Example: +``` +> send Hello, everyone! +> send How are you? +> leave +``` + +## Running Tests + +```bash +# Run all tests +cargo test + +# Run only unit tests +cargo test --lib + +# Run only integration tests +cargo test --test integration_test +cargo test --test ci_integration_test +``` + +## Code Quality Checks + +```bash +# Format code +cargo fmt + +# Check formatting +cargo fmt --all -- --check + +# Run clippy +cargo clippy --all-targets -- -D warnings + +# Check compilation +cargo check --all-targets +``` + +## Setting Up Pre-commit Hook + +To install the pre-commit hook that automatically checks formatting, compilation, and clippy: + +```bash +# On Unix-like systems (Linux, macOS, WSL) +chmod +x setup-hooks.sh +./setup-hooks.sh + +# Or manually +cp hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit +``` + +## Example Session + +Terminal 1 (Server): +```bash +$ cargo run --bin server +Server listening on 127.0.0.1:8080 +``` + +Terminal 2 (Client 1): +```bash +$ cargo run --bin client -- --username alice +Connecting to 127.0.0.1:8080... +You joined the chat +> send Hello! +alice: Hello! +> +``` + +Terminal 3 (Client 2): +```bash +$ cargo run --bin client -- --username bob +Connecting to 127.0.0.1:8080... +[System] alice joined the chat +You joined the chat +> alice: Hello! +> send Hi alice! +bob: Hi alice! +> leave +Leaving chat... +``` + diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100644 index 0000000..4d70c92 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,41 @@ +#!/bin/bash + +# Pre-commit hook to ensure code is formatted, compiles, and passes clippy + +set -e + +echo "Running pre-commit checks..." + +# Check if cargo is available +if ! command -v cargo &> /dev/null; then + echo "Error: cargo is not installed or not in PATH" + exit 1 +fi + +# Format code +echo "Formatting code with rustfmt..." +cargo fmt --all -- --check +if [ $? -ne 0 ]; then + echo "Error: Code is not formatted. Run 'cargo fmt' to fix." + exit 1 +fi + +# Check compilation +echo "Checking compilation..." +cargo check --all-targets +if [ $? -ne 0 ]; then + echo "Error: Code does not compile." + exit 1 +fi + +# Run clippy +echo "Running clippy..." +cargo clippy --all-targets -- -D warnings +if [ $? -ne 0 ]; then + echo "Error: Clippy found issues." + exit 1 +fi + +echo "All pre-commit checks passed!" +exit 0 + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f682db0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,5 @@ +edition = "2021" +max_width = 100 +tab_spaces = 4 +newline_style = "Unix" + diff --git a/setup-hooks.sh b/setup-hooks.sh new file mode 100644 index 0000000..2fe9d8c --- /dev/null +++ b/setup-hooks.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Setup script to install pre-commit hook + +if [ ! -d ".git" ]; then + echo "Error: This script must be run from the repository root" + exit 1 +fi + +if [ ! -f "hooks/pre-commit" ]; then + echo "Error: hooks/pre-commit not found" + exit 1 +fi + +cp hooks/pre-commit .git/hooks/pre-commit +# Convert line endings to Unix format (LF) if needed +sed -i 's/\r$//' .git/hooks/pre-commit 2>/dev/null || sed -i '' 's/\r$//' .git/hooks/pre-commit 2>/dev/null || true +chmod +x .git/hooks/pre-commit + +echo "Pre-commit hook installed successfully!" + diff --git a/src/bin/client.rs b/src/bin/client.rs new file mode 100644 index 0000000..d681e01 --- /dev/null +++ b/src/bin/client.rs @@ -0,0 +1,150 @@ +use clap::Parser; +use simple_chat::Message; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Server host + #[arg(long, env = "HOST", default_value = "127.0.0.1")] + host: String, + + /// Server port + #[arg(long, env = "PORT", default_value = "8080")] + port: String, + + /// Username + #[arg(long, env = "USERNAME")] + username: String, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + + if args.username.is_empty() { + eprintln!("Username is required. Use --username or set USERNAME environment variable."); + std::process::exit(1); + } + + let addr = format!("{}:{}", args.host, args.port); + println!("Connecting to {}...", addr); + + let stream = match TcpStream::connect(&addr).await { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to connect to server: {}", e); + std::process::exit(1); + } + }; + + let (reader, mut writer) = stream.into_split(); + + let join_msg = Message::Join { + username: args.username.clone(), + }; + let join_json = serde_json::to_string(&join_msg)?; + writer.write_all(join_json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + + let mut reader = BufReader::new(reader); + let mut line = String::new(); + reader.read_line(&mut line).await?; + + let response: Message = serde_json::from_str(line.trim())?; + match response { + Message::Error { message } => { + eprintln!("Error: {}", message); + std::process::exit(1); + } + Message::System { message } => { + println!("{}", message); + } + _ => {} + } + + let mut reader_clone = reader; + tokio::spawn(async move { + let mut line = String::new(); + loop { + line.clear(); + match reader_clone.read_line(&mut line).await { + Ok(0) => { + println!("\nServer disconnected"); + std::process::exit(0); + } + Ok(_) => { + if let Ok(msg) = serde_json::from_str::(line.trim()) { + match msg { + Message::Chat { username, message } => { + println!("{}: {}", username, message); + } + Message::System { message } => { + println!("[System] {}", message); + } + _ => {} + } + } + } + Err(_) => { + println!("\nConnection error"); + std::process::exit(0); + } + } + } + }); + + let stdin = tokio::io::stdin(); + let mut stdin_reader = BufReader::new(stdin); + let mut input = String::new(); + + loop { + print!("> "); + std::io::Write::flush(&mut std::io::stdout()).unwrap(); + input.clear(); + + match stdin_reader.read_line(&mut input).await { + Ok(0) => break, + Ok(_) => { + let input = input.trim(); + if input.is_empty() { + continue; + } + + if input == "leave" { + let leave_msg = Message::Leave { + username: args.username.clone(), + }; + let leave_json = serde_json::to_string(&leave_msg)?; + let _ = writer.write_all(leave_json.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + let _ = writer.flush().await; + println!("Leaving chat..."); + break; + } else if let Some(msg) = input.strip_prefix("send ") { + let chat_msg = Message::Chat { + username: args.username.clone(), + message: msg.to_string(), + }; + let chat_json = serde_json::to_string(&chat_msg)?; + if writer.write_all(chat_json.as_bytes()).await.is_err() { + break; + } + if writer.write_all(b"\n").await.is_err() { + break; + } + if writer.flush().await.is_err() { + break; + } + } else { + println!("Unknown command. Use 'send ' or 'leave'"); + } + } + Err(_) => break, + } + } + + Ok(()) +} diff --git a/src/bin/server.rs b/src/bin/server.rs new file mode 100644 index 0000000..606db88 --- /dev/null +++ b/src/bin/server.rs @@ -0,0 +1,13 @@ +use simple_chat::ChatServer; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let addr = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()) + + ":" + + &std::env::var("PORT").unwrap_or_else(|_| "8080".to_string()); + + let server = ChatServer::new(); + server.run(&addr).await?; + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ccc1659 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,259 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{broadcast, RwLock}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Message { + Join { username: String }, + Leave { username: String }, + Chat { username: String, message: String }, + Error { message: String }, + System { message: String }, +} + +pub struct ChatServer { + users: Arc>>>, + tx: broadcast::Sender, +} + +impl ChatServer { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(1024); + Self { + users: Arc::new(RwLock::new(HashMap::new())), + tx, + } + } + + pub async fn run(&self, addr: &str) -> Result<(), Box> { + let listener = TcpListener::bind(addr).await?; + println!("Server listening on {}", addr); + + loop { + let (stream, _) = listener.accept().await?; + let users = Arc::clone(&self.users); + let tx = self.tx.clone(); + + tokio::spawn(async move { + if let Err(e) = Self::handle_client(stream, users, tx).await { + eprintln!("Error handling client: {}", e); + } + }); + } + } + + async fn handle_client( + stream: TcpStream, + users: Arc>>>, + tx: broadcast::Sender, + ) -> Result<(), Box> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + #[allow(unused_assignments)] + let mut username: Option = None; + #[allow(unused_assignments)] + let mut rx: Option> = None; + + reader.read_line(&mut line).await?; + let msg: Message = serde_json::from_str(line.trim())?; + + match msg { + Message::Join { username: user } => { + let mut users_guard = users.write().await; + if users_guard.contains_key(&user) { + let error = Message::Error { + message: format!("Username '{}' is already taken", user), + }; + let error_json = serde_json::to_string(&error)?; + writer.write_all(error_json.as_bytes()).await?; + writer.write_all(b"\n").await?; + return Ok(()); + } + + let user_tx = tx.clone(); + let user_rx = user_tx.subscribe(); + users_guard.insert(user.clone(), user_tx); + username = Some(user.clone()); + + // notify others + let join_msg = Message::System { + message: format!("{} joined the chat", user), + }; + let join_json = serde_json::to_string(&join_msg)?; + let _ = tx.send(join_json.clone()); + + let confirm = Message::System { + message: "You joined the chat".to_string(), + }; + let confirm_json = serde_json::to_string(&confirm)?; + writer.write_all(confirm_json.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + + rx = Some(user_rx); + } + _ => { + let error = Message::Error { + message: "First message must be a Join message".to_string(), + }; + let error_json = serde_json::to_string(&error)?; + writer.write_all(error_json.as_bytes()).await?; + writer.write_all(b"\n").await?; + return Ok(()); + } + } + + let username = username.unwrap(); + let mut rx = rx.unwrap(); + + let mut writer_clone = writer; + let username_clone = username.clone(); + tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + // filter out messages from self + if let Ok(Message::Chat { + username: sender, .. + }) = serde_json::from_str::(&msg) + { + if sender == username_clone { + continue; + } + } + + if writer_clone.write_all(msg.as_bytes()).await.is_err() { + break; + } + if writer_clone.write_all(b"\n").await.is_err() { + break; + } + if writer_clone.flush().await.is_err() { + break; + } + } + }); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let msg: Message = match serde_json::from_str(line.trim()) { + Ok(m) => m, + Err(_) => continue, + }; + + match msg { + Message::Chat { message, .. } => { + let chat_msg = Message::Chat { + username: username.clone(), + message, + }; + let chat_json = serde_json::to_string(&chat_msg)?; + let _ = tx.send(chat_json); + } + Message::Leave { .. } => { + break; + } + _ => {} + } + } + Err(_) => break, + } + } + + // cleanup user + let mut users_guard = users.write().await; + users_guard.remove(&username); + drop(users_guard); + + let leave_msg = Message::System { + message: format!("{} left the chat", username), + }; + let leave_json = serde_json::to_string(&leave_msg)?; + let _ = tx.send(leave_json); + + Ok(()) + } +} + +impl Default for ChatServer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpStream; + + #[tokio::test] + async fn test_server_accepts_connections() { + let server = ChatServer::new(); + let addr = "127.0.0.1:0"; + let listener = TcpListener::bind(addr).await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + tokio::spawn(async move { + server.run(&local_addr.to_string()).await.unwrap(); + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let _stream = TcpStream::connect(local_addr).await; + assert!(_stream.is_ok()); + } + + #[tokio::test] + async fn test_username_uniqueness() { + let server = ChatServer::new(); + let addr = "127.0.0.1:0"; + let listener = TcpListener::bind(addr).await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + tokio::spawn(async move { + server.run(&local_addr.to_string()).await.unwrap(); + }); + + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + let mut stream1 = TcpStream::connect(local_addr).await.unwrap(); + let join1 = Message::Join { + username: "alice".to_string(), + }; + let join1_json = serde_json::to_string(&join1).unwrap(); + stream1.write_all(join1_json.as_bytes()).await.unwrap(); + stream1.write_all(b"\n").await.unwrap(); + stream1.flush().await.unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut stream2 = TcpStream::connect(local_addr).await.unwrap(); + let join2 = Message::Join { + username: "alice".to_string(), + }; + let join2_json = serde_json::to_string(&join2).unwrap(); + stream2.write_all(join2_json.as_bytes()).await.unwrap(); + stream2.write_all(b"\n").await.unwrap(); + stream2.flush().await.unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + let mut reader = BufReader::new(&mut stream2); + let mut response = String::new(); + reader.read_line(&mut response).await.unwrap(); + let error_msg: Message = serde_json::from_str(response.trim()).unwrap(); + + match error_msg { + Message::Error { .. } => {} + _ => panic!("Expected error message for duplicate username"), + } + } +} diff --git a/tests/ci_integration_test.rs b/tests/ci_integration_test.rs new file mode 100644 index 0000000..8f3bb90 --- /dev/null +++ b/tests/ci_integration_test.rs @@ -0,0 +1,80 @@ +use simple_chat::{ChatServer, Message}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::time::{sleep, timeout}; + +async fn connect_and_join( + addr: &str, + username: &str, +) -> Result< + ( + BufReader, + tokio::net::tcp::OwnedWriteHalf, + ), + Box, +> { + let stream = TcpStream::connect(addr).await?; + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + let join_msg = Message::Join { + username: username.to_string(), + }; + let join_json = serde_json::to_string(&join_msg)?; + let mut writer_clone = writer; + writer_clone.write_all(join_json.as_bytes()).await?; + writer_clone.write_all(b"\n").await?; + writer_clone.flush().await?; + + let mut line = String::new(); + reader.read_line(&mut line).await?; + let response: Message = serde_json::from_str(line.trim())?; + + if let Message::Error { .. } = response { + return Err("Failed to join".into()); + } + + Ok((reader, writer_clone)) +} + +#[tokio::test] +async fn test_server_and_client_integration() { + let server = ChatServer::new(); + let addr = "127.0.0.1:0"; + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + tokio::spawn(async move { + server.run(&local_addr.to_string()).await.unwrap(); + }); + + sleep(Duration::from_millis(100)).await; + let (_reader, mut writer) = match timeout( + Duration::from_secs(5), + connect_and_join(&local_addr.to_string(), "testuser"), + ) + .await + { + Ok(Ok(stream)) => stream, + Ok(Err(e)) => panic!("Failed to connect: {}", e), + Err(_) => panic!("Timeout connecting to server"), + }; + + let chat_msg = Message::Chat { + username: "testuser".to_string(), + message: "Hello from test".to_string(), + }; + let chat_json = serde_json::to_string(&chat_msg).unwrap(); + writer.write_all(chat_json.as_bytes()).await.unwrap(); + writer.write_all(b"\n").await.unwrap(); + writer.flush().await.unwrap(); + let leave_msg = Message::Leave { + username: "testuser".to_string(), + }; + let leave_json = serde_json::to_string(&leave_msg).unwrap(); + writer.write_all(leave_json.as_bytes()).await.unwrap(); + writer.write_all(b"\n").await.unwrap(); + writer.flush().await.unwrap(); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..e8c188d --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,185 @@ +use simple_chat::Message; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; +use tokio::time::timeout; + +async fn connect_and_join( + addr: &str, + username: &str, +) -> Result< + ( + BufReader, + tokio::net::tcp::OwnedWriteHalf, + ), + Box, +> { + let stream = TcpStream::connect(addr).await?; + let (reader, writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + + let join_msg = Message::Join { + username: username.to_string(), + }; + let join_json = serde_json::to_string(&join_msg)?; + let mut writer_clone = writer; + writer_clone.write_all(join_json.as_bytes()).await?; + writer_clone.write_all(b"\n").await?; + writer_clone.flush().await?; + + let mut line = String::new(); + reader.read_line(&mut line).await?; + let response: Message = serde_json::from_str(line.trim())?; + + if let Message::Error { .. } = response { + return Err("Failed to join".into()); + } + + Ok((reader, writer_clone)) +} + +#[tokio::test] +async fn test_multiple_users_can_join_and_chat() { + let server = simple_chat::ChatServer::new(); + let addr = "127.0.0.1:0"; + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + tokio::spawn(async move { + server.run(&local_addr.to_string()).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let (mut reader1, mut writer1) = connect_and_join(&local_addr.to_string(), "alice") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let (mut reader2, _writer2) = connect_and_join(&local_addr.to_string(), "bob") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + // send message + let chat_msg = Message::Chat { + username: "alice".to_string(), + message: "Hello Bob!".to_string(), + }; + let chat_json = serde_json::to_string(&chat_msg).unwrap(); + writer1.write_all(chat_json.as_bytes()).await.unwrap(); + writer1.write_all(b"\n").await.unwrap(); + writer1.flush().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut line = String::new(); + let mut found_chat = false; + for _ in 0..5 { + let result = timeout(Duration::from_secs(1), reader2.read_line(&mut line)).await; + if result.is_err() { + break; + } + let received: Message = serde_json::from_str(line.trim()).unwrap(); + match received { + Message::Chat { username, message } => { + assert_eq!(username, "alice"); + assert_eq!(message, "Hello Bob!"); + found_chat = true; + break; + } + Message::System { .. } => { + // Skip system messages + line.clear(); + continue; + } + _ => panic!("Unexpected message type"), + } + } + assert!(found_chat, "Bob should receive chat message from Alice"); + + // alice shouldn't get her own message + let mut line2 = String::new(); + let result2 = timeout(Duration::from_millis(200), reader1.read_line(&mut line2)).await; + if result2.is_ok() { + // If she received something, verify it's not her own chat message + let received: Message = serde_json::from_str(line2.trim()).unwrap(); + match received { + Message::Chat { username, .. } => { + assert_ne!( + username, "alice", + "Alice should not receive her own chat message" + ); + } + Message::System { .. } => { + // System messages are fine (like "bob joined") + } + _ => {} + } + } + // If timeout occurred, that's also fine - means no message received +} + +#[tokio::test] +async fn test_user_leaves_chat() { + let server = simple_chat::ChatServer::new(); + let addr = "127.0.0.1:0"; + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + drop(listener); + + tokio::spawn(async move { + server.run(&local_addr.to_string()).await.unwrap(); + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let (_reader1, mut writer1) = connect_and_join(&local_addr.to_string(), "alice") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let (mut reader2, _writer2) = connect_and_join(&local_addr.to_string(), "bob") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + // Alice leaves + let leave_msg = Message::Leave { + username: "alice".to_string(), + }; + let leave_json = serde_json::to_string(&leave_msg).unwrap(); + writer1.write_all(leave_json.as_bytes()).await.unwrap(); + writer1.write_all(b"\n").await.unwrap(); + writer1.flush().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut line = String::new(); + let mut found_leave = false; + for _ in 0..5 { + let result = timeout(Duration::from_secs(1), reader2.read_line(&mut line)).await; + if result.is_err() { + break; + } + let received: Message = serde_json::from_str(line.trim()).unwrap(); + match received { + Message::System { message } => { + if message.contains("left") && message.contains("alice") { + found_leave = true; + break; + } + // Skip other system messages (like join notifications) + } + _ => { + // Skip non-system messages + } + } + line.clear(); + } + assert!( + found_leave, + "Bob should receive leave notification about Alice" + ); +}