Hey there! Last time, we stopped after implementing the list command, which prints a list of all the users connected to the server. As a part of that, we also did a lot of bug squashing. Today weā€™re gonna tackle the rest of the commands mentioned in last log, starting with glide to send a share request to someone. But before that we should probably do some

Structuring šŸ‘·šŸ¾ā€ā™‚ļø!

Letā€™s start by making enum variants, and a method to parse a command into one of those enum variants. This can help us evaluate the commands.

glide-server/src/main.rs 10:0
enum Command {
    List,
    Requests,
    Glide { path: String, to: String },
    Ok(String),
    No(String),
    Help(Option<String>),
    InvalidCommand(String),
}
 
impl Command {
    fn parse(input: &str) -> Command {
        let glide_re = Regex::new(r"^glide\s+(.+)\s+@(.+)$").unwrap();
        let ok_re = Regex::new(r"^ok\s+@(.+)$").unwrap();
        let no_re = Regex::new(r"^no\s+@(.+)$").unwrap();
        let help_re = Regex::new(r"^help(?:\s+(.+))?$").unwrap();
 
        if input == "list" {
            Command::List
        } else if input == "reqs" {
            Command::Requests
        } else if let Some(caps) = glide_re.captures(input) {
            let path = caps[1].to_string();
            let to = caps[2].to_string();
            Command::Glide { path, to }
        } else if let Some(caps) = ok_re.captures(input) {
            let username = caps[1].to_string();
            Command::Ok(username)
        } else if let Some(caps) = no_re.captures(input) {
            let username = caps[1].to_string();
            Command::No(username)
        } else if let Some(caps) = help_re.captures(input) {
            let command = caps.get(1).map(|m| m.as_str().to_string());
            Command::Help(command)
        } else {
            Command::InvalidCommand(input.to_string())
        }
    }
 
    fn get_str(&self) -> Result<String, String> {
        Ok(match self {
            Command::List => "list".to_string(),
            Command::Requests => "reqs".to_string(),
            Command::Glide { path, to } => format!("glide {} @{}", path, to),
            Command::Ok(user) => format!("ok @{}", user),
            Command::No(user) => format!("no @{}", user),
            Command::Help(command) => {
                format!("help {}", command.as_ref().unwrap_or(&String::new()))
                    .trim()
                    .to_string()
            }
            Command::InvalidCommand(s) => return Err(s.to_string()),
        })
    }
}

I have also implemented a get_str method which converts a Command back to a String. It is not immediately useful to us, but I have gut feeling that it will come in handy. Now letā€™s implement this logic in our handle_commands function.

glide-server/src/main.rs
async fn handle_command(
    command: &str,
    _username: &str,
    socket: &mut TcpStream,
    state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
    let command = Command::parse(command);
    match command {
        Command::List => {
			// -- snip --
        }
        Command::Requests => todo!(),
        Command::Glide { path, to } => todo!(),
        Command::Ok(user) => todo!(),
        Command::No(user) => todo!(),
        Command::Help(cmd) => {
			// -- snip --
        }
        Command::InvalidCommand(cmd) => {
            let response = format!(
                "Unknown command: {}\nType 'help' for available commands.",
                cmd,
            );
            socket.write_all(response.as_bytes()).await?;
        }
    }
 
    Ok(())
}

And now, we are back on track! See commit for more details

Adding Commands

Letā€™s get to adding the rest of the commands now!

Command - reqs

So you may have noticed this reqs or Requests command. It wasnā€™t mentioned in the commands description, but the purpose of this command will be to see if we have any requests coming to us. But for that we need to keep track of all the requests right? So letā€™s take a

Detour - Modifying State to Handle Requests

Right now, our shared state looks something like

glide-server/src/main.rs
type SharedState = Arc<Mutex<HashMap<String, String>>>;

So letā€™s modify the value of the hash map to be a custom Request struct which can handle both the socket and requests. But wait, should it be outgoing or incoming requests? With how small scale our project is, and our current CLI structureā€™s limitations, we wont be having more than one request at a time from one user, so it doesnā€™t matter. But this is purely a problem with our client application. Whatā€™s the problem? Well, skill issue šŸ˜¬. Right now in the client, our inputs and outputs are blocking, meaning, when weā€™re waiting for input we canā€™t run anything in the background until the input is submitted, meaning, we canā€™t notify the users of a new request (hence the reqs command), nor can we notify the sender that their request. So, for now, our client application is a bit lacking.

We could receive many requests and have the receiver check with reqs, but thereā€™s no reliable way to notify the sender that their requests have been accepted or rejected. Unless we show both incoming and outgoing requests with reqs. Progress bars on the senders side is out of question though. I will cook up something with the crossterm crate soon for a better client, but for now, this is what we have.

So letā€™s consider that yes there can be multiple requests, as the number of users increase, viewing and accepting requests become far easier if we store incoming requests instead, because look up time for a user is constant (because of the HashMap), and the receiver only has to check itā€™s requests list instead of looking at every other user.

glide-server/src/main.rs 68:1
struct Request {
    from_username: String,
    filename: String,
    size: u64,
}
 
struct UserData {
    socket: String,
    incoming_requests: Vec<Request>,
}
 
type SharedState = Arc<Mutex<HashMap<String, UserData>>>;

Here we have a HashMap which maps usernames to UserData. Now letā€™s add a method to register a new user, and also modify remove_client to also remove all outgoing requests the user may have sent.

async fn add_client(
    username: &str,
    socket: &mut TcpStream,
    state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut clients = state.lock().await;
    clients.insert(
        username.to_string(),
        UserData {
            socket: socket.peer_addr()?.to_string(),
            incoming_requests: vec![],
        },
    );
    Ok(())
}
 
async fn remove_client(username: &str, state: &SharedState) {
    let mut clients = state.lock().await;
 
    // Remove the client
    clients.remove(username);
 
    // Collect requests to be removed
    let mut to_remove = Vec::new();
    for (user, client) in clients.iter() {
        for (i, req) in client.incoming_requests.iter().enumerate() {
            if req.from_username == username {
                to_remove.push((user.clone(), i));
            }
        }
    }
 
    // Remove the collected requests
    for (user, index) in to_remove {
        if let Some(client) = clients.get_mut(&user) {
            client.incoming_requests.remove(index);
        }
    }
 
    println!("Client @{} disconnected", username);
}

See commit for more details.

Roadblock

Wait. What good is the reqs command without any way to give new requests to users?? šŸ˜¬. My bad, guys. So we will have to implement the glide command first. So letā€™s get that done real quick

Command - glide path/to/file @username

So the glide command takes 2 arguments, path/to/file and @username. So we need to validate

  • If the file exists
  • If the recipient exists

So whatā€™re we waiting for?

glide-server/src/main.rs 106:1
async fn handle_command(
    command: &str,
    username: &str,
    socket: &mut TcpStream,
    state: &SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
    let command = Command::parse(command);
    match command {
        Command::List => {
			// -- snip --
		}
        Command::Requests => todo!(),
        Command::Glide { path, to } => {
            socket
                .write_all(cmd_glide(state, username, &path, &to).await.as_bytes())
                .await?
        }
        Command::Ok(user) => todo!(),
        Command::No(user) => todo!(),
        Command::Help(_) => {
			// --snip--
        }
        Command::InvalidCommand(cmd) => {
			// --snip--
        }
    }
 
    Ok(())
}
 
async fn cmd_glide(state: &SharedState, from: &str, path: &str, to: &str) -> String {
    // Check if file exists
    if !Path::new(path).exists() && fs::metadata(&path).unwrap().is_file() {
        return format!("Path '{}' is invalid. File does not exist", path);
    }
 
    // Check if user exists
    let mut clients = state.lock().await;
    if !clients.contains_key(to) {
        return format!("User @{} does not exist", to);
    }
 
    let file_size = fs::metadata(&path).unwrap().size();
 
    // Add request
    clients
        .get_mut(to)
        .unwrap()
        .incoming_requests
        .push(Request {
            from_username: from.to_string(),
            filename: path.to_string(),
            size: file_size,
        });
 
    format!("Successfully sent share request to @{} for {}", to, path)
}

As easy as that we have the glide command all ready to go. Shall we test this out? Itā€™s been a while since weā€™ve done some testing hasnā€™t it. Here goes nothing!

server
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63593
client
Connected to server!
Enter your username: nandu

Uhh? The server seems unresponsive to us sending the username through. I suspect an infinite loop somewhere on the server. Because even after the client disconnects, thereā€™s no response on the sever. So that specific thread on the server must be occupied by something. Time to put our debugging caps on šŸŽ©šŸ¤Ø.

Itā€™s debugging time.

After some debug statements in the client code

glide-client/src/main.rs 36:1
async fn main() -> Result<...> {
        dbg!("username validated");
 
        // Send the username to the server
        stream.write_all(username.as_bytes()).await?;
 
        dbg!("username sent");
 
        // Wait for the server's response
        let mut response = vec![0; CHUNK_SIZE];
        let bytes_read = stream.read(&mut response).await?;
        if bytes_read == 0 {
            println!("Server disconnected unexpectedly.");
            return Err("Connection closed by the server".into());
        }
 
        dbg!("Server responded");
 
        let response_str = String::from_utf8_lossy(&response[..bytes_read])
            .trim()
            .to_string();
}

and examining the output,

Connected to server!
Enter your username: nandu
[glide-client/src/main.rs:38:9] "username validated" = "username validated"
[glide-client/src/main.rs:43:9] "username sent" = "username sent"

we can confirm that our suspicions are correct!

glide-server/src/main.rs
async fn handle_client(
    socket: &mut TcpStream,
    state: SharedState,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut buffer = vec![0; CHUNK_SIZE];
    let mut username = String::new();
 
    // Loop until a valid username is provided
    loop {
        let bytes_read = socket.read(&mut buffer).await?;
        if bytes_read == 0 {
            return Ok(()); // Client disconnected
        }
 
        dbg!(bytes_read);
 
        username.clear();
        username.push_str(
            &String::from_utf8_lossy(&buffer[..bytes_read])
                .trim()
                .to_string(),
        );
 
        dbg!("username read", &username);
 
        // Check if the username is valid and available
        let response = {
            let clients = state.lock().await;
            if !validate_username(&username) {
                "INVALID_USERNAME"
            } else if clients.contains_key(&username) {
                "USERNAME_TAKEN"
            } else {
                add_client(&username, socket, &state).await?;
                "OK"
            }
        };
 
        dbg!(&response);
 
        // Send the response to the client
        socket.write_all(response.as_bytes()).await?;
 
        if response == "OK" {
            println!("Client @{} connected", username);
            break;
        }
    }
 
	// --snip--
}

with the help of these debug statements, and this output,

rust
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63640
[glide-server/src/main.rs:200:9] bytes_read = 5
[glide-server/src/main.rs:209:9] "username read" = "username read"
[glide-server/src/main.rs:209:9] &username = "nandu"

I think itā€™s fair to assume the problem is somewhere in add_client. Letā€™s head over there and see how things are.

After messing around in there I realized that let mut clients = state.lock().await; is not returning anything. Why? Well, I didnā€™t know either. Iā€™m pretty new to async programming, but after enough head scratches and googling, I found that

glide-server/src/main.rs 213:1
        // Check if the username is valid and available
        let response = {
            let clients = state.lock().await;
            if !validate_username(&username) {
                "INVALID_USERNAME"
            } else if clients.contains_key(&username) {
                "USERNAME_TAKEN"
            } else {
                add_client(&username, socket, &state).await?;
                "OK"
            }
        };

the state.lock() in handle_clients, blocks state from being accessed in add_client. Basically, we donā€™t want multiple asynchronous functions to access the same variable to eliminate race conditions. So after adding a drop(clients) before calling add_client, Everything seems to be working! Well,

server
Server is running on 127.0.0.1:8080
New connection from: 127.0.0.1:63717
Client @nandu connected
New connection from: 127.0.0.1:63720
Client @nandu2 connected
client @nandu2
Connected to server!
Enter your username: nandu2
You are now connected as @nandu2
Type 'help' to see available commands.
glide> list
Connected users:
 @nandu2
 @nandu
glide> glide src/main.rs @nandu
Successfully sent share request to @nandu for src/main.rs
glide> 

(GitHub commit)

Woo! Debugging sure is fun, huh? *he said, with eyes so sleepless they might just fall off*

Everything seems to be in order. Right? No. Why? Simple, how do we know without a reqs command??? So letā€™s get to

Finally implementing the reqs command

glide-server/src/main.rs
async fn cmd_reqs(state: &SharedState, username: &str) -> String {
    let clients = state.lock().await;
    let incoming_user_list: Vec<String> = clients
        .get(username)
        .unwrap()
        .incoming_requests
        .iter()
        .map(|x| {
            format!(
                " @{}, file: {}, size: {} bytes",
                x.from_username, x.filename, x.size,
            )
        })
        .collect();
 
    if incoming_user_list.is_empty() {
        "No incoming requests".to_string()
    } else {
        format!("Incoming requests:\n{}", incoming_user_list.join("\n"))
    }
}

With this simple function, we can wrap up the reqs command. Letā€™s test it šŸ¤žšŸ¾.

client @nandu2
Connected to server!
Enter your username: nandu2
You are now connected as @nandu2
Type 'help' to see available commands.
glide> list
Connected users:
 @nandu
 @nandu2
glide> reqs
No incoming requests
glide> glide src/main.rs @nandu
Successfully sent share request to @nandu for src/main.rs
glide> exit
Thank you for using Glide. Goodbye!

And for the moment of truthā€¦

client @nandu
Connected to server!
Enter your username: nandu
You are now connected as @nandu
Type 'help' to see available commands.
glide> list
Connected users:
 @nandu
glide> reqs
Incoming requests:
 nandu2, file: src/main.rs, size: 9509 bytes
glide> reqs
No incoming requests

Letā€™s GOOOOOOOOOOOOOOOOOOO! Everything seems to be in order. remove_clients is even deleting the requests when users exit! Letā€™s end this on a high note.

Thoughts

Iā€™ve been working on this one for the past couple days, admittedly. Uni and personal life took up a lot of my time, and just straight up lethargy, has held me back from publishing daily. But todayā€™s was a good one, got work done, but actual file transfer keeps getting pushed further and further back as new things come along haha. Weā€™ll get to it on the next one, no promises though. Haha. Thank you for your patience, and have a good one! Seeya soon.