trotter 1.0.2

Trotter 🎠 is an experimental crate that aims to make writing Gemini clients fun and easy.
Documentation
use clap::Parser;
use std::{io::Read, path::PathBuf, process::ExitCode, time::Duration};
use trotter::{
	error::ResponseErr,
	parse::{Gemtext, Symbol},
	Actor, Status, Titan, UserAgent,
};

#[derive(thiserror::Error, Debug)]
enum TrotErr {
	#[error("{0}")]
	ActorErr(#[from] trotter::error::ActorError),

	#[error("{0}")]
	Response(#[from] trotter::error::ResponseErr),

	#[error("Expected one of these: archiver, indexer, researcher, webproxy")]
	BadUserAgent,

	#[error("Upload failed: {0}")]
	UploadIo(std::io::Error),

	#[error("Couldn't guess the mimetype of the file you tried to upload. Try changing its file extension.")]
	UploadMimeGuess,
}

/// 🎠 Trot: A command-line gemini client. Non-success status numbers are reflected in the exit code
#[derive(Parser)]
struct Cli {
	/// Gemini url
	url: String,

	/// Input query (ignored when --upload is used)
	input: Option<Vec<String>>,

	/// πŸ“ Path to your identity's certificate
	#[clap(short, long)]
	cert: Option<PathBuf>,

	/// πŸ“ Path to your identity's key
	#[clap(short, long)]
	key: Option<PathBuf>,

	/// πŸ‘½ archiver, indexer, researcher, webproxy
	#[clap(long)]
	user_agent: Option<String>,

	/// ⏰ Adjust timeout in seconds (default 5)
	#[clap(long)]
	timeout: Option<u64>,

	/// πŸ’Ύ Write output to file
	#[clap(short, long)]
	output: Option<String>,

	/// πŸŒ• Titan: Upload a file to this route
	#[clap(long, short)]
	upload: Option<PathBuf>,

	/// πŸŒ• Titan: Set token value
	#[clap(short, long)]
	token: Option<String>,

	/// 🚯 Only allow gemtext responses. (has no effect when using --output)
	#[clap(short, long)]
	gemtext_only: bool,

	/// 🎨 Disable pretty-print gemtext responses
	#[clap(short, long)]
	no_color: bool,

	/// Print the mimetype returned
	#[clap(short, long)]
	mime: bool,

	/// πŸ“œ Print the server's certificate
	#[clap(long)]
	server_cert: bool,

	/// πŸ“œ Print the server's info
	#[clap(long)]
	server_info: bool,

	/// πŸ“œ Print the server's fingerprint
	#[clap(long)]
	server_fingerprint: bool,
}

async fn run() -> Result<(), TrotErr> {
	let opt = Cli::parse();

	let actor = Actor::default();

	// Cert
	let actor = if let Some(cert) = opt.cert {
		actor.cert_file(cert)
	} else {
		actor
	};

	// Key
	let actor = if let Some(key) = opt.key {
		actor.key_file(key)
	} else {
		actor
	};

	// User agent
	let actor = if let Some(agent) = opt.user_agent {
		actor.user_agent(match agent.as_str() {
			"archiver" => UserAgent::Archiver,
			"indexer" => UserAgent::Indexer,
			"researcher" => UserAgent::Researcher,
			"webproxy" => UserAgent::Webproxy,
			_ => return Err(TrotErr::BadUserAgent),
		})
	} else {
		actor
	};

	// Timeout
	let actor = if let Some(t) = opt.timeout {
		actor.timeout(Duration::from_secs(t))
	} else {
		actor
	};

	// Make request/get response
	let response = match opt.upload {
		Some(path) => {
			// With Titan
			let mut file = std::fs::File::open(&path).map_err(|e| TrotErr::UploadIo(e))?;
			let mut content: Vec<u8> = Vec::new();
			file.read_to_end(&mut content)
				.map_err(|e| TrotErr::UploadIo(e))?;

			let t = Titan {
				token: opt.token,
				mimetype: mime_guess::from_path(&path)
					.first()
					.ok_or(TrotErr::UploadMimeGuess)?
					.to_string(),
				content,
			};
			actor.upload(&opt.url, t).await?
		}
		None => {
			// With Gemini
			if let Some(input) = opt.input {
				let mut input: String = input.iter().map(|x| format!("{x} ")).collect();
				let _ = input.pop();
				actor.input(&opt.url, &input).await?
			} else {
				actor.get(&opt.url).await?
			}
		}
	};

	// Print mimetype
	if opt.mime {
		if response.status == Status::Success.into() {
			println!("{}", response.meta);
			return Ok(());
		}

		return Err(TrotErr::Response(ResponseErr::UnexpectedStatus {
			expected: Status::Success,
			received: response.status.into(),
			meta: response.meta,
		}));
	}

	// Fingerprint..?
	if opt.server_fingerprint {
		println!("{}", response.certificate_fingerprint()?);
		return Ok(());
	}

	// Cert...?
	if opt.server_cert {
		println!("{}", response.certificate_pem()?);
		return Ok(());
	}

	// Info...?
	if opt.server_info {
		println!("{}", response.certificate_info()?);
		return Ok(());
	}

	// Save output to file...?
	if let Some(output) = opt.output {
		response.save_to_path(output)?;
		return Ok(());
	}

	// Get text
	let text = if opt.gemtext_only {
		response.gemtext()?
	} else {
		response.text()?
	};

	if !opt.no_color && response.is_gemtext() {
		// Pretty print
		for g in Gemtext::parse(&text).inner() {
			match g {
				Symbol::Text(a) => print!("{a}"),
				Symbol::Link(a, b) => print!("\x1b[0;4m{b}\x1b[0m \x1b[2m{a}"),
				Symbol::List(a) => print!("β€’ {a}"),
				Symbol::Quote(a) => print!("\x1b[33;3;1mΒ« {a} Β»"),
				Symbol::Header1(a) => print!("\x1b[32;1m▍ {a}"),
				Symbol::Header2(a) => print!("\x1b[36;1mβ–‹ {a}"),
				Symbol::Header3(a) => print!("\x1b[34;1mβ–ˆ {a}"),
				Symbol::Codeblock(a, b) => {
					if !a.is_empty() {
						print!("\x1b[35;2m{a}\x1b[0m\n")
					}
					print!("\x1b[35m{b}")
				}
			}
			println!("\x1b[0m");
		}
	} else {
		// BlasΓ© print
		println!("{text}");
	}
	Ok(())
}

#[tokio::main]
async fn main() -> ExitCode {
	match run().await {
		Err(e) => match e {
			TrotErr::Response(ResponseErr::UnexpectedStatus {
				expected: _,
				received,
				meta,
			}) => {
				println!("{meta}");
				ExitCode::from(received.value())
			}
			_ => {
				eprintln!("🎠 Trot error :: {e}");
				ExitCode::from(1)
			}
		},
		Ok(_) => ExitCode::from(0),
	}
}