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
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[workspace]
resolver = "2"

members = [
"server",
"cli-client",
"common",
"integration_tests"
]
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,17 @@ without error, and is free of clippy errors.
send a message to the server from the client. Make sure that niether the server
or client exit with a failure. This action should be run anytime new code
is pushed to a branch or landed on the main branch.

## Start a server
`cargo run -p server -- --ip 127.0.0.1 --port 8090`

## Start a client
`cargo run -p cli-client -- --host 127.0.0.1 --port 8090 --username <username>`
or
`SIMPLE_CHAT_SERVER_HOST=127.0.0.1 SIMPLE_CHAT_SERVER_PORT=8090 cargo run -p cli-client -- --username <username>`

## Run tests
`cargo test`

## Demo video
https://github.com/user-attachments/assets/a4c163e2-d29b-40ab-97e2-037e86164486
9 changes: 9 additions & 0 deletions cli-client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "cli-client"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1.41.0", features = ["full"] }
clap = { version = "4.5.20", features = ["derive"] }
common = { path = "../common" }
145 changes: 145 additions & 0 deletions cli-client/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! This module contains types and functions to connect with the server.

use std::io;

use common::{extract_parts, messages};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
net::TcpStream,
};

/// A struct to encapsulate functionalities related to connect and messaging with the server.
pub struct Client {
/// Server host
pub host: String,
/// Server port
pub port: String,
/// Username representing human that uses this client
pub username: String,
}

/// Enum to represent the commands that can be entered by the user
#[derive(PartialEq)]
enum ConsoleCommand {
/// Command to leave the chat room
Leave,
/// Command to send a message to the chat room
Send,
InvalidCommand,
}

impl From<String> for ConsoleCommand {
fn from(str: String) -> Self {
match str.as_str() {
"leave" => ConsoleCommand::Leave,
"send" => ConsoleCommand::Send,
_ => ConsoleCommand::InvalidCommand,
}
}
}

impl From<&str> for ConsoleCommand {
fn from(command_str: &str) -> Self {
match command_str.to_lowercase().as_str() {
"leave" => ConsoleCommand::Leave,
"send" => ConsoleCommand::Send,
_ => ConsoleCommand::InvalidCommand,
}
}
}

impl Client {
pub fn new(host: String, port: String, username: String) -> Self {
Client {
host,
port,
username,
}
}
/// Starts the connection with the server and handles the communication between the client and the server.
pub async fn start(&self) -> io::Result<()> {
// Connect to the server
let mut stream = TcpStream::connect(format!("{}:{}", self.host, self.port)).await?;

// Disable Nagle's algorithm to send data immediately
stream.set_nodelay(true).unwrap();
let (reader, writer) = stream.split();

// Create a buffered writer and reader for network communication
let mut writer = BufWriter::new(writer);
let mut reader = BufReader::new(reader);
let mut line = String::new(); // Buffer to store received data

// Join the default room using supplied username
writer
.write_all(format!("<{}> {}\n", messages::JOIN_USER, self.username).as_bytes())
.await
.expect("ERROR: Unable to write to server");
writer.flush().await.expect("ERROR: Unable to flush writer");

reader
.read_line(&mut line)
.await
.expect("ERROR: Unable to read from server");

let (command, _, _) = extract_parts(&line);
if command == messages::DUPLICATE_USER {
eprintln!(
"ERROR: Username already in use. Please try again with a different username.\n"
);
return Ok(());
}

let mut input = String::new();

// Create a buffered writer and reader for stdin/stdout communication
let mut console_reader = BufReader::new(tokio::io::stdin());

line.clear();
loop {
tokio::select! {
_result = console_reader.read_line(&mut input) => {

// Handle sending here
input = input.trim().to_string();
if input.is_empty() || input == "\n" {
continue;
}

let user_input = input.split(" ").collect::<Vec<&str>>();

// Extract command from user input
let original_command = user_input[0].to_lowercase();
let command = ConsoleCommand::from(original_command.clone());

if command == ConsoleCommand::Leave {
writer.write_all(format!("<{}> {}\n", messages::LEAVE_USER, self.username).as_bytes()).await.expect("Unable to write to server");
writer.flush().await.expect("Unable to write to server");
return Ok(());
} else if command == ConsoleCommand::Send{
let usr_msg = &input[original_command.len() + 1..];
let usr_msg = format!("<{}> {} {}\n", messages::USER_MSG, self.username, usr_msg);
writer.write_all(usr_msg.as_bytes()).await.expect("Unable to write to server");
writer.flush().await.expect("Unable to write to server");
}

input.clear();
}

result = reader.read_line(&mut line) => {
if result.expect("ERROR: Unable to read from server") == 0 {
eprintln!("Server closed the connection.");
return Ok(());
}
let (command, username, data) = extract_parts(&line);
if command == messages::USER_MSG {
println!("{}> {}", username, data);
} else if command == messages::INVALID_CMD {
eprintln!("ERROR: Invalid command received from server");
}
line.clear();
}
}
}
}
}
48 changes: 48 additions & 0 deletions cli-client/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use std::{env, io, process::exit};

use clap::Parser;
use client::Client;

mod client;

/// Struct to represent command line args
#[derive(Clone, Debug, Parser)]
#[command(version, about, long_about = None)]
struct Args {
/// A uniqie username
#[arg(short, long)]
username: String,

/// Host or IP to listen to
#[arg(short = 'o', long)]
host: Option<String>,

/// Port
#[arg(short, long)]
port: Option<String>,
}

#[tokio::main]
async fn main() -> io::Result<()> {
// Arg parsing. Priority is given to the command line arguments if provided.
let args = Args::parse();

let mut host = env::var("SIMPLE_CHAT_SERVER_HOST").unwrap_or_default();
let mut port = env::var("SIMPLE_CHAT_SERVER_PORT").unwrap_or_default();

host = args.host.unwrap_or(host);
port = args.port.unwrap_or(port);
let username = args.username;

let client = Client::new(host, port, username);

// Return the appripriate code based on error.
match client.start().await {
Ok(_) => {}
Err(e) => {
eprintln!("=>ERROR: {}", e);
exit(1);
}
};
Ok(())
}
6 changes: 6 additions & 0 deletions common/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"

[dependencies]
81 changes: 81 additions & 0 deletions common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! This module contains functionalities which are common to both the server and the client.

pub mod messages;

// Extract various parts from the string message
pub fn extract_parts(line: &str) -> (u16, String, String) {
let lines = line.split(" ").collect::<Vec<&str>>();
let command = lines[0].trim().to_lowercase();

let mut data = String::new();
let mut username = String::new();

match lines.len() {
2 => {
data = line[command.len()..].trim().to_string();
}
3.. => {
username = lines[1].trim().to_lowercase();
data = line[command.len() + username.len() + 2..]
.trim()
.to_string();
}
_ => {}
}

let command = command
.strip_prefix("<")
.unwrap()
.strip_suffix(">")
.unwrap();
// We can trust that command is something we can parse to u16. So we can use unwrap() here safely.
(command.parse::<u16>().unwrap(), username, data)
}

#[cfg(test)]
mod tests {

use super::*;

// Test extraction of message with more than three parts. This should return 3 parts.
#[test]
fn test_extract_more_than_three_parts() {
let input = "<107> testuser Hey this is sample message from a testuser";
let result = extract_parts(input);
assert_eq!(result.0, 107);
assert_eq!(result.1, "testuser");
assert_eq!(result.2, "Hey this is sample message from a testuser");
}

// Test extraction of message with three parts
#[test]
fn test_extract_three_parts() {
let input = "<107> testuser Hey";
let result = extract_parts(input);
assert_eq!(result.0, 107);
assert_eq!(result.1, "testuser");
assert_eq!(result.2, "Hey");
}

// Test extraction of message with two parts
#[test]
fn test_extract_two_parts() {
let input = "<101> testuser";
let result = extract_parts(input);
println!("{:?}", &result);
assert_eq!(result.0, 101);
assert_eq!(result.1, "");
assert_eq!(result.2, "testuser");
}

// Test extraction of message with two parts
#[test]
fn test_extract_one_part() {
let input = "<103>";
let result = extract_parts(input);
println!("{:?}", &result);
assert_eq!(result.0, 103);
assert_eq!(result.1, "");
assert_eq!(result.2, "");
}
}
10 changes: 10 additions & 0 deletions common/src/messages.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! This module conntains messages used for communication between Client and the Server.

pub const JOIN_USER: u16 = 101;
pub const USER_JOINED: u16 = 102;
pub const LEAVE_USER: u16 = 103;
pub const USER_LEFT: u16 = 104;
pub const DUPLICATE_USER: u16 = 105;
pub const INVALID_CMD: u16 = 106;
pub const USER_MSG: u16 = 107;
pub const WELCOME_MSG: u16 = 108;
6 changes: 6 additions & 0 deletions integration_tests/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[package]
name = "integration_tests"
version = "0.1.0"
edition = "2021"

[dependencies]
Loading