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
88 changes: 88 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: CI

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

env:
CARGO_TERM_COLOR: always

jobs:
check:
name: Build, Lint & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

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

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

- name: Check formatting
run: cargo fmt --check

- name: Clippy
run: cargo clippy -- -D warnings

- name: Build
run: cargo build

- name: Run tests
run: cargo test

integration:
name: Server-Client Integration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable

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

- name: Build server and client
run: cargo build

- name: Start server and send a message from client
run: |
# Start the server in the background
CHAT_HOST=127.0.0.1 CHAT_PORT=9090 cargo run --bin server &
SERVER_PID=$!
sleep 2

# Use a simple script to connect, send a message, and leave
(
echo '{"type":"Join","username":"ci-user"}'
sleep 1
echo '{"type":"Send","content":"Hello from CI!"}'
sleep 1
echo '{"type":"Leave"}'
sleep 1
) | nc 127.0.0.1 9090

# Give the server a moment to process
sleep 1

# Kill the server
kill $SERVER_PID 2>/dev/null || true
echo "Integration smoke test passed!"
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[workspace]
members = ["protocol", "server", "client"]
resolver = "2"
9 changes: 9 additions & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "client"
version = "0.1.0"
edition = "2021"

[dependencies]
protocol = { path = "../protocol" }
tokio = { version = "1", features = ["full"] }
clap = { version = "4", features = ["derive", "env"] }
134 changes: 134 additions & 0 deletions client/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use clap::Parser;
use protocol::{ClientMessage, ServerMessage};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;

#[derive(Parser)]
#[command(name = "chat-client", about = "Simple async chat client")]
struct Args {
/// Server host
#[arg(long, env = "CHAT_HOST", default_value = "127.0.0.1")]
host: String,

/// Server port
#[arg(long, env = "CHAT_PORT", default_value = "8080")]
port: u16,

/// Username for the chat
#[arg(long, env = "CHAT_USERNAME")]
username: String,
}

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

let stream = TcpStream::connect(&addr).await?;
println!("Connected to {addr}");

let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);

// Send join message
let join_msg = protocol::encode(&ClientMessage::Join {
username: args.username.clone(),
})?;
writer.write_all(join_msg.as_bytes()).await?;

// Read welcome/error response
let mut line = String::new();
reader.read_line(&mut line).await?;
match protocol::decode::<ServerMessage>(&line)? {
ServerMessage::Welcome { message } => println!("{message}"),
ServerMessage::Error { message } => {
eprintln!("Error: {message}");
return Ok(());
}
_ => {}
}

// Spawn task to read server messages
let recv_task = tokio::spawn(async move {
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
println!("Server disconnected.");
break;
}
Ok(_) => {
if let Ok(msg) = protocol::decode::<ServerMessage>(&line) {
match msg {
ServerMessage::Chat { username, content } => {
println!("[{username}]: {content}");
}
ServerMessage::UserJoined { username } => {
println!("* {username} joined the chat");
}
ServerMessage::UserLeft { username } => {
println!("* {username} left the chat");
}
ServerMessage::Error { message } => {
eprintln!("Server error: {message}");
}
ServerMessage::Welcome { message } => {
println!("{message}");
}
}
}
}
Err(e) => {
eprintln!("Read error: {e}");
break;
}
}
}
});

// Read user input from stdin
let stdin = tokio::io::stdin();
let mut stdin_reader = BufReader::new(stdin);
let mut input = String::new();

loop {
input.clear();
match stdin_reader.read_line(&mut input).await {
Ok(0) => break,
Ok(_) => {
let trimmed = input.trim();
if trimmed.is_empty() {
continue;
}

if trimmed == "leave" {
let leave_msg = protocol::encode(&ClientMessage::Leave)?;
writer.write_all(leave_msg.as_bytes()).await?;
println!("Disconnected from chat.");
break;
} else if let Some(msg) = trimmed.strip_prefix("send ") {
if msg.is_empty() {
println!("Usage: send <message>");
continue;
}
let send_msg = protocol::encode(&ClientMessage::Send {
content: msg.to_string(),
})?;
writer.write_all(send_msg.as_bytes()).await?;
} else {
println!("Unknown command. Available commands:");
println!(" send <MSG> - Send a message to the chat");
println!(" leave - Disconnect and exit");
}
}
Err(e) => {
eprintln!("Input error: {e}");
break;
}
}
}

recv_task.abort();
Ok(())
}
28 changes: 28 additions & 0 deletions hooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/sh
#
# Pre-commit hook: ensures code is formatted, compiles, and passes clippy.

set -e

echo "Running cargo fmt --check..."
cargo fmt --check
if [ $? -ne 0 ]; then
echo "Error: Code is not formatted. Run 'cargo fmt' before committing."
exit 1
fi

echo "Running cargo clippy..."
cargo clippy -- -D warnings
if [ $? -ne 0 ]; then
echo "Error: Clippy found warnings. Fix them before committing."
exit 1
fi

echo "Running cargo build..."
cargo build
if [ $? -ne 0 ]; then
echo "Error: Build failed. Fix compilation errors before committing."
exit 1
fi

echo "All pre-commit checks passed!"
8 changes: 8 additions & 0 deletions protocol/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "protocol"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
104 changes: 104 additions & 0 deletions protocol/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};

/// Messages sent from client to server.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum ClientMessage {
Join { username: String },
Leave,
Send { content: String },
}

/// Messages sent from server to client.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum ServerMessage {
Welcome { message: String },
Error { message: String },
UserJoined { username: String },
UserLeft { username: String },
Chat { username: String, content: String },
}

/// Encode a message as a newline-delimited JSON string.
pub fn encode<T: Serialize>(msg: &T) -> Result<String, serde_json::Error> {
let mut s = serde_json::to_string(msg)?;
s.push('\n');
Ok(s)
}

/// Decode a message from a JSON string.
pub fn decode<'a, T: Deserialize<'a>>(s: &'a str) -> Result<T, serde_json::Error> {
serde_json::from_str(s.trim())
}

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

#[test]
fn test_encode_decode_join() {
let msg = ClientMessage::Join {
username: "alice".to_string(),
};
let encoded = encode(&msg).unwrap();
assert!(encoded.ends_with('\n'));
let decoded: ClientMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_encode_decode_send() {
let msg = ClientMessage::Send {
content: "hello world".to_string(),
};
let encoded = encode(&msg).unwrap();
let decoded: ClientMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_encode_decode_leave() {
let msg = ClientMessage::Leave;
let encoded = encode(&msg).unwrap();
let decoded: ClientMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_encode_decode_server_chat() {
let msg = ServerMessage::Chat {
username: "bob".to_string(),
content: "hi there".to_string(),
};
let encoded = encode(&msg).unwrap();
let decoded: ServerMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_encode_decode_server_welcome() {
let msg = ServerMessage::Welcome {
message: "Welcome!".to_string(),
};
let encoded = encode(&msg).unwrap();
let decoded: ServerMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_encode_decode_server_error() {
let msg = ServerMessage::Error {
message: "username taken".to_string(),
};
let encoded = encode(&msg).unwrap();
let decoded: ServerMessage = decode(&encoded).unwrap();
assert_eq!(decoded, msg);
}

#[test]
fn test_decode_invalid_json() {
let result: Result<ClientMessage, _> = decode("not json");
assert!(result.is_err());
}
}
18 changes: 18 additions & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"

[lib]
name = "server_lib"
path = "src/lib.rs"

[[bin]]
name = "server"
path = "src/main.rs"

[dependencies]
protocol = { path = "../protocol" }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
Loading