Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
name: Rust CI

on:
push:
branches: [ "*" ]
pull_request:
branches: [ "*" ]

env:
CARGO_TERM_COLOR: always

jobs:
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Cache Cargo Registry
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Check Formatting
run: cargo fmt --all -- --check

- name: Lint with Clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings

- name: Run Unit & Integration Tests
run: cargo test --workspace

smoke-test:
name: End-to-End Smoke Test
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v3
- name: Build Release Binaries
run: cargo build --release --workspace

- name: Run Smoke Test Script
run: |
# 1. Start Server in background
./target/release/chat-server &
SERVER_PID=$!
echo "Server started (PID: $SERVER_PID)"
sleep 3 # Wait for startup

# 2. Start Client A (Roku1) in the background
# She stays online longer (sleep 5) to hear Roku2
(sleep 1; echo "send Hello I am waiting"; sleep 5; echo "leave") | ./target/release/chat-client 127.0.0.1 8080 Roku1 > roku1_output.txt &
ROKU1_PID=$!

# 3. Start Client B (Roku2)
# Roku2 joins AFTER Roku1, sends a message, then leaves
sleep 2
(echo "send Hello Roku1 from Roku2"; sleep 1; echo "leave") | ./target/release/chat-client 127.0.0.1 8080 Roku2 > roku2_output.txt &
ROKU2_PID=$!

# Wait for both clients to finish their scripts
wait $ROKU1_PID
wait $ROKU2_PID

# 4. Analyze Output
echo "--- Roku1's Logs ---"
cat roku1_output.txt
echo "--- Roku2's Logs ---"
cat roku2_output.txt

# Check 1: Did Roku2 connect successfully?
if grep -q "Welcome Roku2" roku2_output.txt; then
echo "✅ Roku2 Handshake Success"
else
echo "❌ Roku2 Handshake Failed"
kill $SERVER_PID
exit 1
fi

# Check 2: Did Roku1 receive Roku2's message?
if grep -q "Roku2: Hello Roku1 from Roku2" roku1_output.txt; then
echo "✅ Broadcast Success (Roku1 heard Roku2)"
else
echo "❌ Broadcast Failed (Roku1 did not hear Roku2)"
kill $SERVER_PID
exit 1
fi

kill $SERVER_PID
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[workspace]
members = [
"chat-common",
"chat-server-lib",
"chat-server",
"chat-client-lib",
"chat-client",
]
resolver = "2"
30 changes: 30 additions & 0 deletions READ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!-- below is the git-precommit hook -->

```bash
#!/bin/bash
set -e

echo "Running Pre-commit Checks..."

# 1. Formatting
# We use --check to fail if files are unformatted
if ! cargo fmt --all -- --check; then
echo "❌ Cargo Fmt failed. Run 'cargo fmt' to fix."
exit 1
fi

# 2. Clippy (Linting)
# We deny warnings to ensure clean code
if ! cargo clippy --workspace --all-targets --all-features -- -D warnings; then
echo "❌ Clippy failed. Fix warnings before committing."
exit 1
fi

# 3. Tests
if ! cargo test --workspace; then
echo "❌ Tests failed."
exit 1
fi

echo "✅ All checks passed."
```
11 changes: 11 additions & 0 deletions chat-client-lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[package]
name = "chat-client-lib"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1.36", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures = "0.3"
anyhow = "1.0"
thiserror = "1.0"
85 changes: 85 additions & 0 deletions chat-client-lib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio_util::codec::{Framed, LinesCodec};

pub async fn run_client(addr: String, username: String) -> Result<()> {
println!("Connecting to {}...", addr);
let stream = TcpStream::connect(&addr)
.await
.context("Failed to connect to chat server")?;

let mut lines = Framed::new(stream, LinesCodec::new());

if let Some(Ok(msg)) = lines.next().await {
println!("{}", msg);
}

lines.send(&username).await?;

match lines.next().await {
Some(Ok(msg)) => {
println!("{}", msg);
if msg.starts_with("Invalid") || msg.starts_with("Username") {
return Ok(());
}
}
_ => return Ok(()),
}

println!("Session started. Type 'send <MSG>' to chat, or 'leave' to quit.");

let (input_tx, mut input_rx) = mpsc::unbounded_channel::<String>();

std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut line = String::new();
loop {
line.clear();
if stdin.read_line(&mut line).is_ok() {
if input_tx.send(line.trim().to_string()).is_err() {
break;
}
} else {
break;
}
}
});

loop {
tokio::select! {
msg = lines.next() => {
match msg {
Some(Ok(text)) => println!("{}", text),
Some(Err(e)) => {
eprintln!("Network error: {}", e);
break;
}
None => {
println!("Server disconnected.");
break;
}
}
}

input = input_rx.recv() => {
match input {
Some(cmd) => {
if cmd == "leave" {
lines.send("leave").await?;
break;
} else if cmd.starts_with("send ") {
lines.send(&cmd).await?;
} else {
println!("Unknown command. Use 'send <MSG>' or 'leave'.");
}
}
None => break,
}
}
}
}

Ok(())
}
10 changes: 10 additions & 0 deletions chat-client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "chat-client"
version = "0.1.0"
edition = "2024"

[dependencies]
chat-client-lib = { path = "../chat-client-lib" }
tokio = { version = "1.36", features = ["full"] }
anyhow = "1.0"
clap = { version = "4.4", features = ["derive", "env"] }
21 changes: 21 additions & 0 deletions chat-client/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use anyhow::Result;
use chat_client_lib::run_client;
use clap::Parser;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
host: String,

port: u16,

username: String,
}

#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let addr = format!("{}:{}", args.host, args.port);

run_client(addr, args.username).await
}
7 changes: 7 additions & 0 deletions chat-common/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "chat-common"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
47 changes: 47 additions & 0 deletions chat-common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! Common definitions for the chat application.

pub const MAX_USERNAME_LEN: usize = 32;

pub const MAX_MESSAGE_LEN: usize = 1024;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BroadcastMessage {
pub sender_id: String,
pub content: String,
pub is_system: bool,
}

impl BroadcastMessage {
pub fn user_msg(sender: impl Into<String>, content: impl Into<String>) -> Self {
Self {
sender_id: sender.into(),
content: content.into(),
is_system: false,
}
}

pub fn system_msg(content: impl Into<String>) -> Self {
Self {
sender_id: "SYSTEM".to_string(),
content: content.into(),
is_system: true,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_message_creation() {
let msg = BroadcastMessage::user_msg("Alice", "Hello");
assert_eq!(msg.sender_id, "Alice");
assert_eq!(msg.content, "Hello");
assert!(!msg.is_system);

let sys = BroadcastMessage::system_msg("Alert");
assert_eq!(sys.sender_id, "SYSTEM");
assert!(sys.is_system);
}
}
17 changes: 17 additions & 0 deletions chat-server-lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "chat-server-lib"
version = "0.1.0"
edition = "2024"

[dependencies]
chat-common = { path = "../chat-common" }
tokio = { version = "1.36", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures = "0.3"
tracing = "0.1"
dashmap = "5.5"
anyhow = "1.0"
thiserror = "1.0"

[dev-dependencies]
tokio-test = "0.4"
Loading