Tuesday | 03 DEC 2024
[ previous ]
[ next ]

A Gopher Client in Rust

Title:
Date: 2020-08-28
Tags:  

RFC Notes

I'm going to implement a Gopher client in rust because it seems like a good project to try out. I'm going straight to the source for this which means that I'm going to implement the client based on my reading of the RFC. Which will be a first and I think quite fun. It's short RFC and the gopher protocol is very simple. I'm also going to use bare rust so I'll try to keep the number of dependencies down to 0.

Let's get started!

Gopher

The RFC to read:https://tools.ietf.org/html/rfc1436

  • Protocol goes over TCP on port 70
  • Clients send strings called 'selector' to the server
  • The Server responds with text and terminating chracter is a period on a line by itself.
  • Connection is automatically closed.
  • Gopher is designed to appear like a filesystem
  • Each line in a gopher listing 5 parts
  • - Type
  • - User visible name
  • - Path
  • - Domain name
  • - Port
  • User should only see the User Visible Name
  • Only time the Client sends back more than one string is on a search, selector is sent along with a space delimited string of words to search for
  • Server will respond with a regular listing but this may span multiple subnets and even the internet

Example of a Gopher listing:0About internet GopherFStuff:About usFrawBits.micro.umn.eduF70F is tab, so a gopher listing is a tab delimited string, with each string being on a new line.

TypeUser Visible NameFPathFDomain nameFPort

  • Note Type doesn't have a tab delimiter.

Path is the selector string the client will send to the Domain Name on Port.

  • User should only see the User Visible Name.
  • If there is more tabs than this, ignore the rest.
  • Displaying the types is up to the client and the author's taste.
  • There can be root level Gopher servers that hold listings pointing to other Gopher servers, this should be load balanced.
  • Recommended to have an About document with contact information as well as what the server holds on each Gopher server in the top level.
  • The base types are 0 for files, 1 for directories and 7 for search.
  • Client can save the paths the User navigates to to form a stack
  • Client can cache stuff to skip network calls
  • Types that are not known can be marked as unknown or ignored, up to the client
  • New features to Gopher are only done via the types field, any further changes to Gopher will be server changes.

This is the distilled down notes of the RFC and should be enough to get started. The design I'll go for is that of the Linux shell and I'll treat Gopher as a filesystem as the RFC mentioned.Something I've learned as I built the client and browsed around is the i type, it is sort of an unofficial addon to the gopher protocol such that you can attach links and text all in one document.

A very hacky but smart solution to a problem and I'm curious who came up with it :)

The Core Client

Alright! After a few days of playing with the Gopher protocol, I have the core of it working and added a whole bunch of things that I wanted as I was going around gopherholes.

  • Gopherholes are people's gopher directories/servers, maybe

First of all, some caveats. This project really highlighted how much of a rust and it's style I still don't get. Every thing works but something about the code doesn't feel right and it bothers me.Second, I very much wrote this only for myself, so there isn't much in the way of robustness and error handling is only match statements and some if checks. I think that is something worth fixing.

Third and lastly, I really enjoyed traveling the internet under my own power! Here I am viewing and reading people's thoughts with something that I wrote myself. As I read, I wanted features like bookmarks, and history and it was powerful to be able to just open up vim and add it.

Gopher is a very human place, at least the places I was in, it's all people's thoughts and ideas and there is nothing industrial about it. I had learned about gopher on hackernews.

https://news.ycombinator.com/item?id=23161922

The link points to a screenshot of a GUI browser pointed at baud.baby. I ended up learning about gopher and telneting in and I was hooked on his content. Just the writing style, and the fact I was telneting in scratched an itch that I always felt.

I very much recommend implementing this on your own and then reading this to see where we may have done things differently.

The full code is available in the third chapter.

The full list of commands I've implemented are:

  • visit - Go to a gopherhole
  • ls - List current menu
  • cd - Switch into a directory
  • more - Read a document
  • add - Add a directory to our bookmarks
  • b - List our bookmarks
  • b{num} - Select a bookmark and go there
  • h - List the documents we've read
  • h{num} - Show the document that we read
  • where - Show where we currently are
  • bye - Quit our client
  • Search - Search the menu for something

Feel free to add more or leave the one's you don't care for.The first 4 commands are really the ones you need for a minimal client.

Anyway! Let's move to the code.

Gopher Client

I'm going to link the entire source at the bottom along with the one dependency which is libc in Cargo.toml.

I'm not going to go over every line but just the functions I think were fun/hard to implement.

Visit Function

The core of gopher is you connect on a port, submit a selector string and ideally get either a list of lines which is a navigation or you get a dump of text which is a document.

With that said, the first step is to connect to a gopherhole and submit a selector string.

...
fn visit(domain: &str, port: &str, file_path: &str) -> Result<String, std::io::Error> {
    let gopher_hole = format!("{}:{}", domain, port);
    let stream = TcpStream::connect(&gopher_hole);

    match stream {
        Ok(mut stream) => {
            let selector = format!("{}\r\n", file_path);
            stream.write(selector.as_bytes()).unwrap();
            stream.flush().unwrap();

            let mut data: Vec<u8> = vec![];
            stream.read_to_end(&mut data).unwrap();
            Ok(String::from_utf8_lossy(&data).to_string())
        },
        Err(e) => {
            Err(e)
        }
    }
}
...

The visit function opens a connection to a specified domain, port and submits the file_path. This is the selector but as I worked on the client, selector has other meanings for me(I was thinking of css and jquery) so I changed it to something that made more intuitive sense for me.

Once we have our socket(stream) open we add a CRLF, carriage-return line-feed to our file_path and we write it as bytes to the socket. Voila! We have just done what everything does on the internet. You can't send anything but binary data over sockets so to send something we need to do the conversion. This is true for HTTP just as much as gopher!

We then read from the socket and add everything to a vector. We could read this into an array but we don't know how much data we'll be getting back.

We have now received binary data from the server! All we have to do is convert this binary data into text and we can then view the data. We use the from_utf8_lossy function because if there are letters we can't deal with, it will be replaced with a ?.

We then return the data as a string to our calling function.

The visit function is the core of our gopher client, I really like the term visit because browsing the gopherholes very much feels like visiting.

Parsing the Data

Let's now look at what happens to the data we received from the server. One thing to note here is visit is assumed to always return data for a menu. I think this may be in the spec, but it could also not be.

...
else if tokens[0] == "v" || tokens[0] == "visit" {
            let data = visit(tokens[1], "70", "");
            match data {
                Ok(data) => {
                    prompt = String::from(tokens[1]);
                    pwd.push(Location::new(tokens[1].to_string(), "70".to_string(), "".to_string(), tokens[1].to_string()));
                    stack.push(GopherListing::parse(data));
                },
                Err(e) => {
                    println!("{}", e);
                }
            }

...

In our program, when we type in visit, it will hit this if statement. We can do something like visit baud.baby and it will then run the visit function return with the menu for baud.baby.

I ended up adding a promp, a list of places we are nested in and a history. Everything is a stack which is neat!

The key part of this visit command is the parsing of the data.

Let's take a look.

...
#[derive(Debug)]
struct GopherListing {
    id: u32,
    gopher_type: String,
    user_visible_name: String,
    file_path: String,
    domain: String,
    port: String
}

impl GopherListing {
    fn parse(data: String) -> Vec<GopherListing> {
        let mut listings: Vec<GopherListing> = vec![];

        let mut counter = 0;
        for gopher_line in data.split("\r\n") {
            if gopher_line == "." {
                break;
            }

            if gopher_line == "" {
                continue;
            }

            let gopher_type = gopher_line[..1].to_string();

            if gopher_type == "0".to_string() || gopher_type == "1".to_string() {
                let line: Vec<&str> = gopher_line[1..].split("\t").collect();

                counter = counter + 1;
                listings.push(GopherListing {
                    id: counter,
                    gopher_type,
                    user_visible_name: line[0].to_string(),
                    file_path: line[1].to_string(),
                    domain: line[2].to_string(),
                    port: line[3].to_string()
                });
            }
        }

        listings
    }
}
...

We want to take the gopher menu data and create a GopherListing out of it. This way we can have a model for the data we receive. Our parsing function is very simple and the RFC make's quite clear that this was one of the goals.

All the parser does is, split on tabs, if it is type 0 or 1, create a GopherListing. I know there are more types in the wild and i is very much used everywhere but I didn't want to deal with it so I stayed with the what made sense to me.

Once we run our visit function, we then parse the data into a vector of gopher listings and then save that to our stack!

  • After finding that there were a number of gopherholes using the I type, I added a simple one line change to allow i types to get through the parsing step. As soon as I did, Type 1 Files, open into a listing of type I lines which I can see on the screen.
...
 if gopher_type == "0".to_string() || gopher_type == "1".to_string() || gopher_type == "i" {
...

The if statement simply lets i types get added to the GopherListing.

LS - List Gopher Items

Now that we have a vector of gopher listings, we can now start displaying them.

...
fn display(listings: &Vec<GopherListing>, needle: &str) {
    for listing in listings {
        if needle == "" || listing.user_visible_name.to_lowercase().contains(&needle.to_lowercase()) {
            if listing.gopher_type == "i" {
                println!("{}", listing.user_visible_name);
            } else {
                println!("{}. {}", listing.id, listing.user_visible_name);
            }
        }
    }
}
...

This function will just print out the user visible name and the id. I had originally had just the visible name but I changed it quickly after getting tired of typing in the full name to view the document. I should have saw in the RFC that numbers as menu options is also a core part of gopher!

This function actually does double duty as I also added rudimentary search here, this was a later addition.

This function also removes some of the clutter that would happen if we printed the i types like normal, this way we don't add unuseable numbers for the i type lines.

...

        if tokens[0] == "ls" {
            if stack.len() > 0 {
                display(&stack.last().unwrap(), "");
            } else {
                println!("Nothing to show.");
            }
            continue;
        }
...

The ls command calls the display function with the last item on the stack. This means that we are saving all of our visits to various gopher holes so we don't have to do another network call.

Now we can view a list of gopher items on our screen! The next step is to move into a type 1 gopher item which is a directory.

CD - Change Directory

...
       } else if tokens[0] == "cd" {
            let selected = tokens[1].trim();

            if stack.len() == 0 {
                println!("Nothing to change directory into.");
                continue;
            }

            if selected == ".." {
                pwd.pop();
                stack.pop();
                continue;
            }

            for listing in stack.last().unwrap() {
                if selected == listing.user_visible_name || selected == listing.id.to_string() {
                    if listing.gopher_type == "1" {
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);
                        match data {
                            Ok(data) => {
                                prompt = String::from(listing.domain.clone());
                                pwd.push(Location::new(listing.domain.clone(), listing.port.clone(), listing.file_path.clone(), listing.user_visible_name.clone()));
                                stack.push(GopherListing::parse(data));
                            },
                            Err(e) => {
                                println!("{}", e);
                            }
                        }
                    } else {
                        println!("Not a directory.");
                    }
                    break;
                }
            }
...

Once we display the listing of gopher items, a user can now do cd 2 to mean that they want to go inside the second line in the listing.

So we first figure out where we're going. We find the listing in our vector and visit it. Now instead of passing CRLF to get the base menu, we will pass in what the listing has for it's file_path. Once we get the data back, we will do what we did in our visit command.

We will add the listing to our pwd(path of working directory), we will then add it to our stack. Our stack is a vector of all the places we've been.

Now the next time we go to do ls, to list our gopher items, we will pick up the listings in the last part of our stack which is the newest place we're in.

Now we can travel down menus! The final thing we need to be able to do is view second type of gopher items - the documents!

More - Viewing Gopher Documents

...
        } else if tokens[0] == "more" {
            let selected = tokens[1].trim();
            if stack.len() == 0 {
                println!("Nothing to print.");
                continue;
            }

            for listing in stack.last().unwrap() {
                if selected == listing.id.to_string() {
                    if listing.gopher_type == "0" {
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);

                        let document = Location::new(
                            listing.domain.clone(),
                            listing.port.clone(),
                            listing.file_path.clone(),
                            format!("{} ({})", listing.user_visible_name, listing.domain)
                        );

                        save_in_file(history_path, &document);
                        history.push(document);

                        match data {
                            Ok(data) => display_document(data),
                            Err(e) => println!("{}", e)
                        }

                    } else {
                        println!("Not a file.");
                    }
                    break;
                }
            }
        }
...

More acts very similar to cd, we type in more 3 meaning we want to view 3, and if it is a type 0, it will output to the screen.We use the visit function again but the difference here is that instead of creating a GopherListing from the data, we will simply pass the data to our display_document function.

fn display_document(data: String) {
    let (_, window_height) = term_dimensions();

    let lines: Vec<&str> = data.split("\n").collect();
    let mut current_pos = 0;
    let mut done = false;

    while !done {
        for i in current_pos..(current_pos + window_height - 1) {
            if i >= lines.len()  {
                done = true;
                break;
            }
            println!("{}", lines[i]);
            current_pos = i;
        }

        if !done {
            myp!("\x1b[92m[Press Enter for Next page]\x1b[0m");
            let mut command = String::new();
            io::stdin().read_line(&mut command).unwrap();
            if command.trim() == "q" {
                done = true;
            }
        }
    }
}

The core of this function is that we split the data by newlines and display each line.

The extra logic is for pagination. We get the height of the window and then only display the lines that fit. We allow the user to hit anything to go to the next page or q to exit in the middle. The window height is actually stolen from the term_size crate. The rust crates site, docs.rs is very easy to use and viewing the source of rust crates is quite friendly.

use libc::{STDOUT_FILENO, c_int, c_ulong, winsize};
use std::mem::zeroed;
static TIOCGWINSZ: c_ulong = 0x5413;

extern "C" {
    fn ioctl(fd: c_int, request: c_ulong, ...) -> c_int;
}

unsafe fn get_dimensions_out() -> (usize, usize) {
    let mut window: winsize = zeroed();
    ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut window);
    (window.ws_col as usize, window.ws_row as usize)
}

fn term_dimensions() -> (usize, usize) {
    unsafe { get_dimensions_out() }
}

This code was mostly taken from term_size because I didn't want to add any dependencies, adding libc in my mind isn't that bad and term_size was really just a wrapped for libc so I went directly to the source. There are some magic numbers here that I don't understand but it works!

! We have the core functions of our client at this point, we have the ability to connect to a gopherhole, display the menu, travel the menu, and view a document.

That's it! In the next chapter, I'll go over some of the features I added such as history, bookmarks and search.

Bookmarks

Alright! We have the core of the gopher client done, now to add some extras. The big thing was that as I traveled around the gopherholes, there was no way for me keep track of where I was or to save someone's gopherhole so I could come back to it.

Maybe because it is a terminal or maybe because I wrote the client software, but it feels easier to see how everything is really connections when people link to other gopherholes. The web is the same way but it is masked and harder to see the lines connecting things.

Adding Bookmarks!

The first thing we need to do is create a bookmark struct and figure out how we're going to save the bookmark information. We will need to write out to disk and we'll keep it simple and simply write out the strings delimited by some character so we can parse them quickly back into structs.

The first thing we need to do is create the struct.

...
#[derive(Debug, Clone)]
struct Location {
    user_visible_name: String,
    file_path: String,
    domain: String,
    port: String
}

impl Location {
    fn new(domain: String, port: String, file_path: String, user_visible_name: String) -> Self {
        Location { domain, port, file_path, user_visible_name }
    }
}
...

This is a simple struct and is very similar to our GopherListing, we could have reused it but I didn't want to add an ID to our location, though we could just randomly generate one and hide it.

Now let's look at our save function.

...
fn save_in_file(path: &str, location: &Location) {
    let mut location_file = OpenOptions::new()
        .read(true)
        .append(true)
        .create(true)
        .open(path)
        .unwrap();

    writeln!(location_file,
        "{}|{}|{}|{}",
        location.domain,
        location.port,
        location.file_path,
        location.user_visible_name)
        .unwrap();
}
...

All this function does is write out the structs to a path in an append only function. This has the bonus of creating a file that is very easy to edit as well!

Now for the load.

...

fn load_location_file(path: &str) -> Vec<Location> {
    let mut locations :Vec<Location> = vec![];

    let mut location_file = OpenOptions::new()
        .read(true)
        .append(true)
        .create(true)
        .open(path)
        .unwrap();

    let mut location_file_data = String::new();
    location_file.read_to_string(&mut location_file_data).unwrap();
    let location_lines: Vec<&str> = location_file_data.split("\n").collect();

    for location in location_lines {
        let loc: Vec<&str> = location.split("|").collect();
        if loc.len() == 4 {
            locations.push(Location::new(
                    loc[0].to_string(),
                    loc[1].to_string(),
                    loc[2].to_string(),
                    loc[3].to_string()
            )
            );
        }
    }

    locations
}
...

Just as simple! We read in the file and then each line gets split on the delimiter, in this case | and we then create new Location objects out of them.

...
    let bookmark_path = "/home/nivethan/gopher.bookmarks";
    let mut bookmarks = load_location_file(bookmark_path);
...
        } else if tokens[0] == "add" {
            if tokens[1] == "." {
                let location = pwd.last().unwrap().clone();

                let mut found = false;
                for bookmark in &bookmarks {
                    if bookmark.file_path == location.file_path
                        && bookmark.domain == location.domain
                    {
                        found = true;
                        break;
                    }
                }

                if !found {
                    println!("Added: {}", location.user_visible_name);
                    save_in_file(bookmark_path, &location);
                    bookmarks.push(location);
                } else {
                    println!("Already added - {}", location.user_visible_name);
                }
            }
            continue;

        }
...

We first load the existing bookmarks into our client.

Then we have our add command, add . allows us to add the current directory we are in to our book marks, we first add it to our file and then we also add it to our list of bookmarks.

Almost there! We just need to be able to list and select our bookmarks.

...
        } else if tokens[0] == "b" {
            display_location_file(&bookmarks);
            continue;

        } else if tokens[0].starts_with("b") {
            let r = select_location(tokens, &bookmarks, &mut prompt, &mut pwd, &mut stack);
            match r {
                Ok(msg) => println!("{}", msg),
                Err(e) => println!("{}",e)
            }
            continue;
         }
...

Just b by itself will trigger our display routine. If we enter b followed by a number, b2, this will select that location and move us there.

...
fn display_location_file(locations: &Vec<Location>) {
    let mut counter = 0;
    for location in locations {
        counter = counter + 1;
        println!("{}. {}", counter, location.user_visible_name);
    }
}
...

Our display function is just a loop and quite simple!

...
fn select_location(
    tokens: Vec<&str>,
    locations: &Vec<Location>,
    prompt: &mut String,
    pwd: &mut Vec<Location>,
    stack: &mut Vec<Vec<GopherListing>>
) -> Result<String, &'static str>{

    let bookmark_tokens: Vec<&str> = tokens[0].splitn(3, "").collect();
    let choice = bookmark_tokens[2].to_string().parse::<usize>();

    match choice {
        Ok(choice) => {
            let bookmark_pos = choice - 1;
            if bookmark_pos < locations.len() {
                let listing = locations[bookmark_pos].clone();
                let data = visit(&listing.domain, &listing.port, &listing.file_path);
                match data {
                    Ok(data) => {
                        *prompt = String::from(listing.domain.clone());
                        pwd.push(Location::new(
                                listing.domain.clone(),
                                listing.port.clone(),
                                listing.file_path.clone(),
                                listing.user_visible_name.clone()
                        )
                        );
                        stack.push(GopherListing::parse(data));
                        Ok(format!("Switched to {}.", listing.user_visible_name))
                    },
                    Err(_) => {
                        Err("Failed to switch.")
                    }
                }
            } else {
                Err("Bookmark is invalid.")
            }
        },
        Err(_) => Err("Bookmark command is incorrect.")
    }
}
...

Out select function is a little bit more complex, this is because we have a few stacks we need to update, and we also need to make sure the number entered by the user is valid. Ultimately given a number we should be able to grab the element at that index and with that information call our visit function.

With that we have our bookmarks done! Now I'll show the rest of the code but everything else uses the same logic. History is the same bookmarks but instead of directories it saves documents. This means instead of calling visit, it will reuse our more option, and call display document.

This is the complete code below.

Commands available:

  • visit - Go to a gopherhole
  • ls - List current menu
  • cd - Switch into a directory
  • more - Read a document
  • add - Add a directory to our bookmarks
  • b - List our bookmarks
  • b{num} - Select a bookmark and go there
  • h - List the documents we've read
  • h{num} - Show the document that we read
  • where - Show where we currently are
  • bye - Quit our client
  • Search - Search the menu for something

./Cargo.toml

...
[dependencies]
libc = "0.2"

./src/main.rs

use std::io::prelude::*;
use std::net::TcpStream;
use std::io;
use std::fs::{OpenOptions, File};
use libc::{STDOUT_FILENO, c_int, c_ulong, winsize};
use std::mem::zeroed;
static TIOCGWINSZ: c_ulong = 0x5413;

macro_rules! myp {
    ($a:expr) => {
        print!("{}",$a);
        io::stdout().flush().unwrap();
    }
}

extern "C" {
    fn ioctl(fd: c_int, request: c_ulong, ...) -> c_int;
}

unsafe fn get_dimensions_out() -> (usize, usize) {
    let mut window: winsize = zeroed();
    ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut window);
    (window.ws_col as usize, window.ws_row as usize)
}

fn term_dimensions() -> (usize, usize) {
    unsafe { get_dimensions_out() }
}

#[derive(Debug, Clone)]
struct Location {
    user_visible_name: String,
    file_path: String,
    domain: String,
    port: String
}

impl Location {
    fn new(domain: String, port: String, file_path: String, user_visible_name: String) -> Self {
        Location { domain, port, file_path, user_visible_name }
    }
}

#[derive(Debug)]
struct GopherListing {
    id: u32,
    gopher_type: String,
    user_visible_name: String,
    file_path: String,
    domain: String,
    port: String
}

impl GopherListing {
    fn parse(data: String) -> Vec<GopherListing> {
        let mut listings: Vec<GopherListing> = vec![];

        let mut counter = 0;
        for gopher_line in data.split("\r\n") {
            if gopher_line == "." {
                break;
            }

            if gopher_line == "" {
                continue;
            }

            let gopher_type = gopher_line[..1].to_string();

            if gopher_type == "0".to_string() || gopher_type == "1".to_string() || gopher_type == "i" {
                let line: Vec<&str> = gopher_line[1..].split("\t").collect();

                counter = counter + 1;
                listings.push(GopherListing {
                    id: counter,
                    gopher_type,
                    user_visible_name: line[0].to_string(),
                    file_path: line[1].to_string(),
                    domain: line[2].to_string(),
                    port: line[3].to_string()
                });
            }
        }

        listings
    }
}

fn visit(domain: &str, port: &str, file_path: &str) -> Result<String, std::io::Error> {
    let gopher_hole = format!("{}:{}", domain, port);
    let stream = TcpStream::connect(&gopher_hole);

    match stream {
        Ok(mut stream) => {
            let selector = format!("{}\r\n", file_path);
            stream.write(selector.as_bytes()).unwrap();
            stream.flush().unwrap();

            let mut data: Vec<u8> = vec![];
            stream.read_to_end(&mut data).unwrap();
            Ok(String::from_utf8_lossy(&data).to_string())
        },
        Err(e) => {
            Err(e)
        }
    }

}

fn display(listings: &Vec<GopherListing>, needle: &str) {
    for listing in listings {
        if needle == "" || listing.user_visible_name.to_lowercase().contains(&needle.to_lowercase()) {
            if listing.gopher_type == "i" {
                println!("{}", listing.user_visible_name);
            } else {
                println!("{}. {}", listing.id, listing.user_visible_name);
            }
        }
    }
}

fn load_location_file(path: &str) -> Vec<Location> {
    let mut locations :Vec<Location> = vec![];

    let mut location_file = OpenOptions::new()
        .read(true)
        .append(true)
        .create(true)
        .open(path)
        .unwrap();

    let mut location_file_data = String::new();
    location_file.read_to_string(&mut location_file_data).unwrap();
    let location_lines: Vec<&str> = location_file_data.split("\n").collect();

    for location in location_lines {
        let loc: Vec<&str> = location.split("|").collect();
        if loc.len() == 4 {
            locations.push(Location::new(
                    loc[0].to_string(),
                    loc[1].to_string(),
                    loc[2].to_string(),
                    loc[3].to_string()
            )
            );
        }
    }

    locations
}

fn save_in_file(path: &str, location: &Location) {
    let mut location_file = OpenOptions::new()
        .read(true)
        .append(true)
        .create(true)
        .open(path)
        .unwrap();

    writeln!(location_file,
        "{}|{}|{}|{}",
        location.domain,
        location.port,
        location.file_path,
        location.user_visible_name)
        .unwrap();
}

fn display_location_file(locations: &Vec<Location>) {
    let mut counter = 0;
    for location in locations {
        counter = counter + 1;
        println!("{}. {}", counter, location.user_visible_name);
    }
}

fn select_location(
    tokens: Vec<&str>,
    locations: &Vec<Location>,
    prompt: &mut String,
    pwd: &mut Vec<Location>,
    stack: &mut Vec<Vec<GopherListing>>
) -> Result<String, &'static str>{

    let bookmark_tokens: Vec<&str> = tokens[0].splitn(3, "").collect();
    let choice = bookmark_tokens[2].to_string().parse::<usize>();

    match choice {
        Ok(choice) => {
            let bookmark_pos = choice - 1;
            if bookmark_pos < locations.len() {
                let listing = locations[bookmark_pos].clone();
                let data = visit(&listing.domain, &listing.port, &listing.file_path);
                match data {
                    Ok(data) => {
                        *prompt = String::from(listing.domain.clone());
                        pwd.push(Location::new(
                                listing.domain.clone(),
                                listing.port.clone(),
                                listing.file_path.clone(),
                                listing.user_visible_name.clone()
                        )
                        );
                        stack.push(GopherListing::parse(data));
                        Ok(format!("Switched to {}.", listing.user_visible_name))
                    },
                    Err(_) => {
                        Err("Failed to switch.")
                    }
                }
            } else {
                Err("Bookmark is invalid.")
            }
        },
        Err(_) => Err("Bookmark command is incorrect.")
    }
}

fn display_document(data: String) {
    let (_, window_height) = term_dimensions();

    let lines: Vec<&str> = data.split("\n").collect();
    let mut current_pos = 0;
    let mut done = false;

    while !done {
        for i in current_pos..(current_pos + window_height - 1) {
            if i >= lines.len()  {
                done = true;
                break;
            }
            println!("{}", lines[i]);
            current_pos = i;
        }

        if !done {
            myp!("\x1b[92m[Press Enter for Next page]\x1b[0m");
            let mut command = String::new();
            io::stdin().read_line(&mut command).unwrap();
            if command.trim() == "q" {
                done = true;
            }
        }
    }
}

fn main() {

    let mut prompt = "world".to_string();
    let mut pwd :Vec<Location> = vec![];
    let mut stack :Vec<Vec<GopherListing>> = vec![];

    let bookmark_path = "/home/nivethan/gopher.bookmarks";
    let mut bookmarks = load_location_file(bookmark_path);

    let history_path = "/home/nivethan/gopher.history";
    let mut history = load_location_file(history_path);


    loop {
        myp!(format!("\x1b[92m{}>\x1b[0m ", prompt));
        let mut command = String::new();
        io::stdin().read_line(&mut command).unwrap();

        let tokens: Vec<&str> = command.trim().splitn(2, " ").collect();

        if tokens[0] == "ls" {
            if stack.len() > 0 {
                display(&stack.last().unwrap(), "");
            } else {
                println!("Nothing to show.");
            }
            continue;

        } else if tokens[0] == "bye" || tokens[0] == "quit" || tokens[0] == "q" || tokens[0] == "exit" {
            println!("Bye!");
            break;

        } else if tokens[0] == "where" {
            if pwd.len() > 0 {
                println!("{:?}", pwd.last().unwrap());
            } else {
                println!("Nowhere to be.");
            }
            continue;

        } else if tokens[0] == "h" {
            display_location_file(&history);
            continue;
        } else if tokens[0] == "b" {
            display_location_file(&bookmarks);
            continue;

        } else if tokens[0].starts_with("b") {
            let r = select_location(tokens, &bookmarks, &mut prompt, &mut pwd, &mut stack);
            match r {
                Ok(msg) => println!("{}", msg),
                Err(e) => println!("{}",e)
            }
            continue;

        } else if tokens[0].starts_with("h") {
            let history_tokens: Vec<&str> = tokens[0].splitn(3, "").collect();
            let choice = history_tokens[2].to_string().parse::<usize>();

            match choice {
                Ok(choice) => {
                    let history_pos = choice - 1;
                    if history_pos < history.len() && history_pos > 0 {
                        let listing = history[history_pos].clone();
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);
                        match data {
                            Ok(data) => display_document(data),
                            Err(e) => println!("{}", e)
                        }
                    } else {
                        println!("Choice out of range.");
                    }
                },
                Err(_) => println!("Invalid choice.")
            }
            continue;
        }

        if tokens.len() < 2 {
            continue;
        }

        if tokens[0] == "search" {
            if stack.len() > 0 {
                display(&stack.last().unwrap(), tokens[1]);
            } else {
                println!("Nothing to search.");
            }
            continue;

        } else if tokens[0] == "add" {
            if tokens[1] == "." {
                let location = pwd.last().unwrap().clone();

                let mut found = false;
                for bookmark in &bookmarks {
                    if bookmark.file_path == location.file_path
                        && bookmark.domain == location.domain
                    {
                        found = true;
                        break;
                    }
                }

                if !found {
                    println!("Added: {}", location.user_visible_name);
                    save_in_file(bookmark_path, &location);
                    bookmarks.push(location);
                } else {
                    println!("Already added - {}", location.user_visible_name);
                }
            }
            continue;

        } else if tokens[0] == "v" || tokens[0] == "visit" {
            let data = visit(tokens[1], "70", "");
            match data {
                Ok(data) => {
                    prompt = String::from(tokens[1]);
                    pwd.push(Location::new(tokens[1].to_string(), "70".to_string(), "".to_string(), tokens[1].to_string()));
                    stack.push(GopherListing::parse(data));
                },
                Err(e) => {
                    println!("{}", e);
                }
            }

        } else if tokens[0] == "cd" {
            let selected = tokens[1].trim();

            if stack.len() == 0 {
                println!("Nothing to change directory into.");
                continue;
            }

            if selected == ".." {
                pwd.pop();
                stack.pop();
                continue;
            }

            for listing in stack.last().unwrap() {
                if selected == listing.user_visible_name || selected == listing.id.to_string() {
                    if listing.gopher_type == "1" {
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);
                        match data {
                            Ok(data) => {
                                prompt = String::from(listing.domain.clone());
                                pwd.push(Location::new(listing.domain.clone(), listing.port.clone(), listing.file_path.clone(), listing.user_visible_name.clone()));
                                stack.push(GopherListing::parse(data));
                            },
                            Err(e) => {
                                println!("{}", e);
                            }
                        }
                    } else {
                        println!("Not a directory.");
                    }
                    break;
                }
            }

        } else if tokens[0] == "more" {
            let selected = tokens[1].trim();
            if stack.len() == 0 {
                println!("Nothing to print.");
                continue;
            }

            for listing in stack.last().unwrap() {
                if selected == listing.id.to_string() {
                    if listing.gopher_type == "0" {
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);

                        let document = Location::new(
                            listing.domain.clone(),
                            listing.port.clone(),
                            listing.file_path.clone(),
                            format!("{} ({})", listing.user_visible_name, listing.domain)
                        );

                        save_in_file(history_path, &document);
                        history.push(document);

                        match data {
                            Ok(data) => display_document(data),
                            Err(e) => println!("{}", e)
                        }

                    } else {
                        println!("Not a file.");
                    }
                    break;
                }
            }
        } else if tokens[0] == "save" {
            let selected = tokens[1].trim();
            if stack.len() == 0 {
                println!("Nothing to print.");
                continue;
            }

            for listing in stack.last().unwrap() {
                if selected == listing.id.to_string() {
                    if listing.gopher_type == "0" {
                        let data = visit(&listing.domain, &listing.port, &listing.file_path);

                        match data {
                            Ok(data) => {
                                let fp = format!("/home/nivethan/gopher/{}.txt",
                                    &listing.user_visible_name);
                                let mut f = File::create(fp).unwrap();
                                write!(f, "{}", data).unwrap();
                                println!("Saved {}!", &listing.user_visible_name);
                            },
                            Err(e) => println!("{}", e)
                        }

                    } else {
                        println!("Not a file.");
                    }
                    break;
                }
            }
        }

    }
}