loading...

A Gopher Client in Rust - 03 Bookmarks and Full Code

krowemoh profile image Nivethan Updated on ・9 min read

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 }
    }
}
...
Enter fullscreen mode Exit fullscreen mode

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();
}
...
Enter fullscreen mode Exit fullscreen mode

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
}
...
Enter fullscreen mode Exit fullscreen mode

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;

        }
...
Enter fullscreen mode Exit fullscreen mode

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;
         }
...
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
...
Enter fullscreen mode Exit fullscreen mode

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.")
    }
}
...
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

./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;
                }
            }
        }

    }
}

Enter fullscreen mode Exit fullscreen mode

Discussion

pic
Editor guide