rgc 0.3.4

A simple gopher client for the command line
#![allow(clippy::new_without_default)]
extern crate bunt;
extern crate dirs;
extern crate tempfile;
extern crate simple_input;

use std::env;
use std::path::Path;
use std::process::{exit, Command};
use std::thread::sleep;
use std::time::Duration;
use std::fs::{self, File};
use std::io::{Read, self};

use simple_input::input;
use tempfile::NamedTempFile;

mod config;
use config::{Config};

mod util;
use util::{usage, banner, dial, make_key, make_key_str, is_valid_directory_entry, view_telnet};

#[derive(Clone, Debug)]
pub struct Link {
	pub next:     Option<Box<Link>>,
	pub which:    Option<char>,
	pub key:      usize,
	pub host:     String,
	pub port:     u16,
	pub selector: String,
}
#[derive(Clone, Debug)]
pub struct BrowserState {
	pub tmpfilename:      String,
	pub links:            Option<Box<Link>>,
	pub history:          Option<Box<Link>>,
	pub link_key:         usize,
	pub current_host:     String,
	pub current_port:     u16,
	pub current_selector: String,
	pub parsed_host:      String,
	pub parsed_port:      u16,
	pub parsed_selector:  String,
	pub bookmarks:        Vec<String>,
	pub config:           Config,
}

impl BrowserState {
	pub fn new() -> Self {
		Self {
			tmpfilename:      String::new(),
			links:            None,
			history:          None,
			link_key:         0,
			current_host:     String::new(),
			current_port:     0,
			current_selector: String::new(),
			parsed_host:      String::new(),
			parsed_port:      0,
			parsed_selector:  String::new(),
			bookmarks:        Vec::new(),
			config:           Config::new(),
		}
	}

	pub fn download_file(
		&self,
		host: &str,
		port: u16,
		selector: &str,
		file: &mut File,
	) -> bool {
		if self.config.verbose {
			eprintln!("downloading [{}]...", selector);
		}
		let stream = dial(&host, port, selector);
		if stream.is_none() {
			eprintln!("error: downloading [{}] failed", selector);
			return false;
		}
		// todo show progress
		match io::copy(&mut stream.unwrap(), file) {
			Ok(b) => eprintln!("downloaded {} bytes", b),
			Err(e) => {
				eprintln!("error: failed to download file: {}", e);
				return false;
			}
		};

		if self.config.verbose {
			eprintln!("downloading [{}] complete", selector);
		}
		true
	}

	pub fn download_temp(
		&mut self,
		host: &str,
		port: u16,
		selector: &str,
	) -> bool {
		let mut tmpfile = match NamedTempFile::new() {
			Ok(f) => f,
			Err(_) => {
				eprintln!("error: unable to create tmp file");
				return false;
			}
		};
		self.tmpfilename = tmpfile.path().display().to_string();
		if !self.download_file(host, port, selector, tmpfile.as_file_mut()) {
			fs::remove_file(&self.tmpfilename).expect("failed to delete temp file");
			return false;
		}
		let _ = tmpfile.keep();
		true
	}

	pub fn add_link(
		&mut self,
		which: char,
		name: String,
		host: String,
		port: u16,
		selector: String,
	) {
		let mut a: char = '\0';
		let mut b: char = '\0';
		let mut c: char = '\0';
		if host.is_empty() || port == 0 || selector.is_empty() {
			return;
		}
		let link = Link {
			which: Some(which as u8 as char),
			key: self.link_key,
			host,
			port,
			selector,
			next: if self.links.is_none() { None } else { self.links.clone() },
		};

		self.links = Some(Box::new(link));
		make_key_str(self.link_key, &mut a, &mut b, &mut c);
		self.link_key += 1;
		bunt::println!("{[green]}{[green]}{[green]} {}", a, b, c, name);
	}

	pub fn clear_links(&mut self) {
		self.links = None;
		self.link_key = 0;
	}

	pub fn add_history(&mut self) {
		let link: Link = Link {
			which:    None,
			key:      0,
			host:     self.current_host.clone(),
			port:     self.current_port,
			selector: self.current_selector.clone(),
			next:     if self.history.is_none() { None } else { self.history.clone() },
		};
		self.history = Some(Box::new(link));
	}

	pub fn handle_directory_line(&mut self, line: &str) {
		let fields = {
			line[1..].split('\t')
				.enumerate()
				.filter(|(i, x)| *i == 0 || !x.is_empty())
				.map(|(_, x)| x)
				.collect::<Vec<_>>()
		};
		/* determine listing type */
		match line.chars().next() {
			Some('i') | Some('3') => println!("   {}", fields[0]),
			Some('.') => println!("\0"), // some gopher servers use this
			Some(w @ '0') | Some(w @ '1') | Some(w @ '5') | Some(w @ '7')
			| Some(w @ '8') | Some(w @ '9') | Some(w @ 'g') | Some(w @ 'I')
			| Some(w @ 'p') | Some(w @ 'h') | Some(w @ 's') => {
				match fields.len() {
					1 => self.add_link(
						w,
						fields[0].to_string(),
						self.current_host.clone(),
						self.current_port,
						fields[0].to_string(),
					),
					2 => self.add_link(
						w,
						fields[0].to_string(),
						self.current_host.clone(),
						self.current_port,
						fields[1].to_string(),
					),
					3 => self.add_link(
						w,
						fields[0].to_string(),
						fields[2].to_string(),
						self.current_port,
						fields[1].to_string(),
					),
					x if x >= 4 => self.add_link(
						w,
						fields[0].to_string(),
						fields[2].to_string(),
						fields[3].parse().unwrap_or(70), // todo oof
						fields[1].to_string(),
					),
					_ => (),
				}
			}
			Some(x) => eprintln!("miss [{}]: {}", x, fields[0]),
			None => (),
		}
	}

	pub fn view_directory(
		&mut self,
		host: &str,
		port: u16,
		selector: &str,
		make_current: bool,
	) {
		let stream = dial(&host, port, selector);
		let mut buffer = String::new();
		self.clear_links();
		let mut stream = match stream {
			Some(s) => s,
			None => return,
		};
		/* only adapt current prompt when successful */
		/* make history entry */
		if make_current {
			self.add_history();
		}
		/* don't overwrite the current_* things... */
		if host != self.current_host {
			self.current_host = host.to_string();
		} /* quit if not successful */
		if port != self.current_port {
			self.current_port = port;
		}
		if selector != self.current_selector {
			self.current_selector = selector.to_string();
		}

		if let Err(e) = stream.read_to_string(&mut buffer) {
			eprintln!("failed to read response body: {}", e);
		}

		for line in buffer.lines() {
			if !is_valid_directory_entry(line.chars().next().unwrap_or('\0')) {
				println!(
					"invalid: [{}] {}",
					line.chars().next().unwrap_or('\0'),
					line.chars().skip(1).collect::<String>()
				);
				continue;
			}

			self.handle_directory_line(line);
		}
	}

	pub fn view_file(
		&mut self,
		cmd: &str,
		host: &str,
		port: u16,
		selector: &str,
	) {
		if !self.download_temp(host, port, selector) {
			return;
		}

		if self.config.verbose {
			println!("h({}) p({}) s({})", host, port, selector);
		}
		if self.config.verbose {
			println!("executing: {} {}", cmd, self.tmpfilename);
		}
		/* execute */
		match Command::new(cmd).arg(&self.tmpfilename).spawn() {
			Ok(mut c) =>
				if let Err(e) = c.wait() {
					eprintln!("failed to wait for command to exit: {}", e);
				},
			Err(e) => eprintln!("error: failed to run command: {}", e),
		}

		/* to wait for browsers etc. that return immediately */
		sleep(Duration::from_secs(1));
		fs::remove_file(&self.tmpfilename).expect("failed to delete temp file");
	}

	pub fn view_download(
		&mut self,
		host: &str,
		port: u16,
		selector: &str,
	) {
		let mut filename: String =
			Path::new(selector).file_name().unwrap_or_default().to_string_lossy().into();
		let line: String = match input(&format!(
			"enter filename for download [{}]: ",
			filename
		))
		.as_str()
		{
			"" => {
				println!("download aborted");
				return;
			}
			x => x.into(),
		};
		filename = line; // TODO something stinky going on here
		let mut file = match File::create(&filename) {
			Ok(f) => f,
			Err(e) => {
				println!("error: unable to create file [{}]: {}", filename, e,);
				return;
			}
		};
		if !self.download_file(host, port, selector, &mut file) {
			println!("error: unable to download [{}]", selector);
			fs::remove_file(filename).expect("failed to delete file");
		}
	}

	pub fn view_search(
		&mut self,
		host: &str,
		port: u16,
		selector: &str,
	) {
		let search_selector: String = match input("enter search string: ").as_str() {
			"" => {
				println!("search aborted");
				return;
			}
			s => format!("{}\t{}", selector, s),
		};
		self.view_directory(host, port, &search_selector, true);
	}

	pub fn view_history(&mut self, key: Option<usize>) {
		let mut history_key: usize = 0;
		let mut a: char = '\0';
		let mut b: char = '\0';
		let mut c: char = '\0';
		let mut link: Option<Box<Link>>;
		if self.history.is_none() {
			println!("(empty history)");
			return;
		}
		if key.is_none() {
			println!("(history)");
			link = self.history.clone();
			while let Some(l) = link {
				let fresh10 = history_key;
				history_key += 1;
				make_key_str(fresh10, &mut a, &mut b, &mut c);
				bunt::println!("{[green]}{[green]}{[green]} {}:{}/{}", a, b, c, (*l).host, (*l).port, (*l).selector,);
				link = (*l).next
			}
		} else if let Some(key) = key {
			/* traverse history list */
			link = self.history.clone();
			while let Some(l) = link {
				if history_key == key {
					self.view_directory(
						&(*l).host,
						(*l).port,
						&(*l).selector,
						false,
					);
					return;
				}
				link = (*l).next;
				history_key += 1
			}
			println!("history item not found");
		};
	}

	pub fn view_bookmarks(&mut self, key: Option<usize>) {
		let mut a: char = '\0';
		let mut b: char = '\0';
		let mut c: char = '\0';
		if key.is_none() {
			println!("(bookmarks)");
			for (i, bookmark) in self.bookmarks.iter().enumerate() {
				make_key_str(i, &mut a, &mut b, &mut c);
				println!("{}{}{} {}", a, b, c, bookmark);
			}
		} else if let Some(key) = key {
			for (i, bookmark) in self.bookmarks.clone().iter().enumerate() {
				if i == key {
					if self.parse_uri(bookmark) {
						self.view_directory(
							&self.parsed_host.clone(),
							self.parsed_port,
							&self.parsed_selector.clone(),
							false,
						);
					} else {
						println!("invalid gopher URI: {}", bookmark,);
					}
					return;
				}
			}
		};
	}

	pub fn pop_history(&mut self) {
		match &self.history.clone() {
			None => println!("(empty history)"),
			Some(h) => {
				/* reload page from history (and don't count as history) */
				self.view_directory(
					&(*h).host,
					(*h).port,
					&(*h).selector,
					false,
				);
				/* history is history... :) */
				self.history = h.next.clone();
			}
		}
	}

	pub fn follow_link(&mut self, key: usize) -> bool {
		let mut link: Option<Box<Link>> = self.links.clone();

		while let Some(ref l) = link {
			if (*l).key != key {
				link = (*l).next.clone()
			} else if let Some(w) = (*l).which {
				match w {
					'0' => {
						self.view_file(
							&self.config.cmd_text.clone(),
							&(*l).host,
							(*l).port,
							&(*l).selector,
						);
					}
					'1' => {
						self.view_directory(
							&(*l).host,
							(*l).port,
							&(*l).selector,
							true,
						);
					}
					'7' => {
						self.view_search(&(*l).host, (*l).port, &(*l).selector);
					}
					'5' | '9' => {
						self.view_download(&(*l).host, (*l).port, &(*l).selector);
					}
					'8' => {
						view_telnet(&(*l).host, (*l).port);
					}
					'f' | 'I' | 'p' => {
						self.view_file(
							&self.config.cmd_image.clone(),
							&(*l).host,
							(*l).port,
							&(*l).selector,
						);
					}
					'h' => {
						self.view_file(
							&self.config.cmd_browser.clone(),
							&(*l).host,
							(*l).port,
							&(*l).selector,
						);
					}
					's' => {
						self.view_file(
							&self.config.cmd_player.clone(),
							&(*l).host,
							(*l).port,
							&(*l).selector,
						);
					}
					_ => {
						println!("missing handler [{}]", w);
					}
				}
				return true;
			}
		}
		false
	}

	pub fn download_link(&mut self, key: usize) {
		let mut link: Option<Box<Link>> = self.links.clone();
		while let Some(l) = link {
			if (*l).key != key {
				link = (*l).next
			} else {
				self.view_download(&(*l).host, (*l).port, &(*l).selector);
				return;
			}
		}
		println!("link not found");
	}

	/* function prototypes */
	pub fn parse_uri(&mut self, uri: &str) -> bool {
		let mut tmp: &str = &uri[..];
		/* strip gopher:// */
		if uri.starts_with("gopher://") {
			tmp = &uri[9..];
		}
		self.parsed_host =
			tmp.chars().take_while(|x| !(*x == ':' || *x == '/')).collect();

		if self.parsed_host.is_empty() {
			return false;
		}

		if tmp.contains(':') {
			let port_string: String = tmp
				.chars()
				.skip_while(|x| *x != ':')
				.skip(1)
				.take_while(|x| *x != '/')
				.collect();

			if port_string.is_empty() {
				self.parsed_port = 70;
			} else {
				self.parsed_port = match port_string.parse() {
					Ok(p) => p,
					Err(_) => {
						eprintln!("failed to parse port");
						exit(1);
					}
				};
			}
		}
		tmp = &tmp[tmp.find('/').unwrap_or(0)..];
		/* parse selector (ignore slash and selector type) */
		if tmp.is_empty() || tmp.find('/').is_none() {
			self.parsed_selector = "/".to_string();
		} else {
			self.parsed_selector = tmp.into();
		}
		true
	}

	pub fn init(&mut self, argv: Vec<String>) -> i32 {
		let mut i: usize = 1;
		/* copy defaults */
		self.config.init();
		self.bookmarks = self.config.bookmarks.clone();

		let mut uri: String = self.config.start_uri.clone();
		/* parse command line */
		while i < argv.len() {
			if argv[i].starts_with('-') {
				match argv[i].chars().nth(1) {
					Some('H') => {
						usage();
					}
					Some('v') => {
						banner(true);
						exit(0);
					}
					_ => {
						usage();
					}
				}
			} else {
				uri = argv[i].clone();
			}
			i += 1
		}
		/* parse uri */
		if !self.parse_uri(&uri) {
			banner(false);
			eprintln!("invalid gopher URI: {}", argv[i],);
			exit(1);
		}
		/* main loop */
		self.view_directory(
			&self.parsed_host.clone(),
			self.parsed_port,
			&self.parsed_selector.clone(),
			false,
		); /* to display the prompt */
		loop {
			bunt::print!(
				"{[blue]}:{[blue]}{[blue]} ",
				self.current_host, self.current_port, &self.current_selector
			);
			let line = input("");

			match line.chars().next() {
				Some('?') => {
					bunt::println!(
						"{$yellow}?{/$}          help\
						\n{$yellow}*{/$}          reload directory\
						\n{$yellow}<{/$}          go back in history\
						\n{$yellow}.[LINK]{/$}    download the given link\
						\n{$yellow}H{/$}          show history\
						\n{$yellow}H[LINK]{/$}    jump to the specified history item\
						\n{$yellow}G[URI]{/$}     jump to the given gopher URI\
						\n{$yellow}B{/$}          show bookmarks\
						\n{$yellow}B[LINK]{/$}    jump to the specified bookmark item\
						\n{$yellow}C^d{/$}        quit");
				}
				Some('<') => {
					self.pop_history();
				}
				Some('*') => {
					self.view_directory(
						&self.current_host.clone(),
						self.current_port,
						&self.current_selector.clone(),
						false,
					);
				}
				Some('.') => {
					self.download_link(
						make_key(
							line.chars().next().unwrap(),
							line.chars().nth(1).unwrap_or('\0'),
							line.chars().nth(2).unwrap_or('\0'))
							.unwrap_or(0),
					);
				}
				Some('H') =>
					if i == 1 || i == 3 || i == 4 {
						self.view_history(make_key(
							line.chars().next().unwrap(),
							line.chars().nth(1).unwrap_or('\0'),
							line.chars().nth(2).unwrap_or('\0'),
						));
					},
				Some('G') => {
					if self.parse_uri(&line[1..]) {
						self.view_directory(
							&self.parsed_host.clone(),
							self.parsed_port,
							&self.parsed_selector.clone(),
							true,
						);
					} else {
						println!("invalid gopher URI");
					}
				}
				Some('B') =>
					if i == 1 || i == 3 || i == 4 {
						self.view_bookmarks(make_key(
							line.chars().next().unwrap(),
							line.chars().nth(1).unwrap_or('\0'),
							line.chars().nth(2).unwrap_or('\0'),
						));
					},
				x if x.is_some() => {
					self.follow_link(
						make_key(
							line.chars().next().unwrap(),
							line.chars().nth(1).unwrap_or('\0'),
							line.chars().nth(2).unwrap_or('\0'))
							.unwrap_or(0),
					);
				}
				_ => (),
			}
		}
	}
}

fn main() {
	let mut state = BrowserState::new();
	let args: Vec<String> = env::args().collect();
	exit(state.init(args))
}